function ScreenDashboard({ setRoute, openQuotation }) { const { BRL, BRLk, PERIODS, ALERT_ROUTE } = window.SCP; const [period, setPeriod] = useState('30d'); const [savingsRange, setSavingsRange] = useState('6m'); const [shareView, setShareView] = useState('cat'); const [data, setData] = useState({ quotations: [], orders: [], products: [], receivings: [], categories: [] }); const [loading, setLoading] = useState(true); const [loadErr, setLoadErr] = useState(null); useEffect(() => { let mounted = true; (async () => { try { const d = await window.scpDb.dashboard.load(); if (mounted) setData(d); } catch (e) { if (mounted) setLoadErr(e.message || 'Falha ao carregar dashboard'); } finally { if (mounted) setLoading(false); } })(); return () => { mounted = false; }; }, []); const P = PERIODS[period]; const today = new Date(); const periodStart = new Date(today.getTime() - P.days * 86400000); const inPeriod = (dateStr) => { if (!dateStr) return false; const d = new Date(dateStr); return d >= periodStart && d <= today; }; // ====== KPIs reais ====== const kpis = useMemo(() => { const { quotations, orders, products } = data; const cotacoesAbertas = quotations.filter(q => (q.status === 'aberta' || q.status === 'analise') && inPeriod(q.created_at)); const cotacoesPend = cotacoesAbertas.filter(q => q.status === 'aberta').length; const cotacoesAnalise = cotacoesAbertas.filter(q => q.status === 'analise').length; const cotacoesNovas = quotations.filter(q => inPeriod(q.created_at)).length; const pedidosPeriodo = orders.filter(o => inPeriod(o.emitted_at) && o.status !== 'cancelado'); const pedidosTotal = pedidosPeriodo.reduce((s, o) => s + Number(o.total || 0), 0); const cotacoesFechadasPeriodo = quotations.filter(q => q.status === 'fechada' && inPeriod(q.closed_at)); const economia = cotacoesFechadasPeriodo.reduce((s, q) => s + Number(q.saving_value || 0), 0); const economiaAcum = quotations.filter(q => q.status === 'fechada').reduce((s, q) => s + Number(q.saving_value || 0), 0); // ====== Período anterior (mesma duração, anterior ao atual) ====== const prevStart = new Date(periodStart.getTime() - P.days * 86400000); const prevEnd = periodStart; const inPrev = (d) => { if (!d) return false; const x = new Date(d); return x >= prevStart && x < prevEnd; }; const pedidosPrevPeriodo = orders.filter(o => inPrev(o.emitted_at) && o.status !== 'cancelado'); const pedidosPrevTotal = pedidosPrevPeriodo.reduce((s, o) => s + Number(o.total || 0), 0); const economiaPrev = quotations.filter(q => q.status === 'fechada' && inPrev(q.closed_at)).reduce((s, q) => s + Number(q.saving_value || 0), 0); const pct = (curr, prev) => { if (prev === 0 && curr === 0) return null; if (prev === 0) return null; // não dá pra comparar return ((curr - prev) / prev) * 100; }; const pedidosDelta = pct(pedidosPeriodo.length, pedidosPrevPeriodo.length); const economiaDelta = pct(economia, economiaPrev); const lowStock = products.filter(p => Number(p.stock) < Number(p.min_stock)); const criticos = lowStock.length; const criticosAbaixo = lowStock.filter(p => Number(p.stock) < Number(p.min_stock) / 2).length; return { cotacoesAbertas: cotacoesAbertas.length, cotacoesPend, cotacoesAnalise, cotacoesNovas, pedidos: pedidosPeriodo.length, pedidosTotal, pedidosDelta, economia, economiaAcum, economiaDelta, criticos, criticosAbaixo, }; }, [data, period]); // ====== Últimas cotações ====== const filteredQuotations = useMemo(() => { return (data.quotations || []) .filter(q => inPeriod(q.created_at)) .map(q => ({ ...q, items: q.items?.length || 0, suppliers: q.invited?.length || 0, responded: (q.invited || []).filter(i => i.status === 'respondida').length, })); }, [data, period]); // ====== Active quotation (hero) ====== const activeQuotation = useMemo(() => { return (data.quotations || []).find(q => q.status === 'analise' || q.status === 'aberta'); }, [data]); // Carrega análise IA da cotação ativa (se existir) para popular o orbe + pings const [activeAnalysis, setActiveAnalysis] = useState(null); useEffect(() => { if (!activeQuotation) { setActiveAnalysis(null); return; } window.scpDb.ai.getLatestAnalysis(activeQuotation.id).then(setActiveAnalysis).catch(() => {}); }, [activeQuotation?.id]); // Top fornecedores recomendados pela IA — com % de economia por fornecedor const aiPings = useMemo(() => { if (!activeAnalysis?.recommendation) return []; const rec = activeAnalysis.recommendation; const supOrders = rec.supplier_orders || {}; const ids = Object.keys(supOrders).slice(0, 3); // Para cada fornecedor, calculamos % média de economia somando economia dos itens dele return ids.map(sid => { const items = supOrders[sid] || []; const supInvited = (activeQuotation?.invited || []).find(i => i.supplier_id === sid); const name = supInvited?.supplier?.name || 'Fornecedor'; const shortName = name.split(' ')[0]; // Calcula % de economia: precisaria dos preços dos bids - como já temos a recomendação, // usamos uma estimativa simples const pct = items.length > 0 ? -((rec.savings_pct || 0) / Math.max(1, ids.length)).toFixed(1) : 0; return { sid, name, shortName, pct }; }); }, [activeAnalysis, activeQuotation]); // ====== Savings chart real (agregado por mês) ====== const savingsData = useMemo(() => { const fechadas = (data.quotations || []).filter(q => q.status === 'fechada' && q.closed_at && q.saving_value); if (savingsRange === 'ano') { const byYear = {}; fechadas.forEach(q => { const y = String(new Date(q.closed_at).getFullYear()); byYear[y] = (byYear[y] || 0) + Number(q.saving_value); }); const arr = Object.entries(byYear) .sort(([a], [b]) => a.localeCompare(b)) .map(([m, value]) => ({ m, value, quotes: 0 })); return arr.length ? arr : [{ m: String(today.getFullYear()), value: 0, quotes: 0 }]; } const months = savingsRange === '12m' ? 12 : 6; const byMonth = new Map(); for (let i = months - 1; i >= 0; i--) { const d = new Date(today.getFullYear(), today.getMonth() - i, 1); const key = d.toISOString().slice(0, 7); const label = d.toLocaleDateString('pt-BR', { month: 'short', year: '2-digit' }).replace('.', '').replace(' de ', '/'); byMonth.set(key, { m: label, value: 0, quotes: 0 }); } fechadas.forEach(q => { const d = new Date(q.closed_at); const key = d.toISOString().slice(0, 7); if (byMonth.has(key)) { const row = byMonth.get(key); row.value += Number(q.saving_value); row.quotes += 1; } }); return Array.from(byMonth.values()); }, [data, savingsRange]); const savingsSub = savingsRange === '6m' ? 'Últimos 6 meses · economia em cotações fechadas' : savingsRange === '12m' ? 'Últimos 12 meses · economia em cotações fechadas' : 'Histórico anual · economia em cotações fechadas'; // ====== Donut · Categoria ====== const categoryShare = useMemo(() => { const map = {}; (data.orders || []) .filter(o => inPeriod(o.emitted_at) && o.status !== 'cancelado') .forEach(o => { (o.items || []).forEach(it => { const cat = it.product?.cat; if (!cat) return; map[cat] = (map[cat] || 0) + Number(it.qty) * Number(it.unit_price); }); }); return Object.entries(map) .map(([catKey, value]) => { const c = (data.categories || []).find(x => x.key === catKey); return { cat: catKey, label: c?.label || catKey, value: Math.round(value), tone: c?.tone || 'fg' }; }) .sort((a, b) => b.value - a.value); }, [data, period]); // ====== Donut · Fornecedor ====== const supplierShare = useMemo(() => { const map = {}; (data.orders || []) .filter(o => inPeriod(o.emitted_at) && o.status !== 'cancelado') .forEach(o => { const sid = o.supplier?.id; if (!sid) return; if (!map[sid]) map[sid] = { sid, label: o.supplier.name, value: 0, tone: 'sky' }; map[sid].value += Number(o.total); }); const arr = Object.values(map).sort((a, b) => b.value - a.value).slice(0, 8); const tones = ['emerald','sky','coral','amber','violet','sky','coral','amber']; return arr.map((s, i) => ({ ...s, cat: s.sid, value: Math.round(s.value), tone: tones[i % tones.length] })); }, [data, period]); // ====== Top fornecedores (últimos 90d sempre) ====== const topSuppliers = useMemo(() => { const map = {}; (data.orders || []) .filter(o => o.status === 'entregue' || o.status === 'em_transito' || o.status === 'confirmado') .forEach(o => { const name = o.supplier?.name; if (!name) return; map[name] = (map[name] || 0) + 1; }); const arr = Object.entries(map).map(([name, wins]) => ({ name, wins })).sort((a, b) => b.wins - a.wins).slice(0, 10); const max = arr[0]?.wins || 1; return arr.map(s => ({ ...s, share: Math.round(100 * s.wins / max) })); }, [data]); // ====== Top produtos ====== const topProducts = useMemo(() => { const map = {}; (data.orders || []).forEach(o => { (o.items || []).forEach(it => { const pid = it.product_id; if (!pid) return; if (!map[pid]) map[pid] = { product_id: pid, name: '', vol: 0, quotes: 0 }; map[pid].vol += Number(it.qty); map[pid].quotes += 1; }); }); // pega nomes Object.values(map).forEach(item => { const p = (data.products || []).find(x => x.id === item.product_id); item.name = p?.name || '—'; }); return Object.values(map).filter(p => p.name !== '—').sort((a, b) => b.quotes - a.quotes).slice(0, 10); }, [data]); // ====== Alertas gerados ====== const alerts = useMemo(() => { const out = []; // Estoque crítico (top 3) const crit = (data.products || []) .filter(p => Number(p.stock) < Number(p.min_stock)) .sort((a, b) => (a.stock / Math.max(1, a.min_stock)) - (b.stock / Math.max(1, b.min_stock))) .slice(0, 2); crit.forEach(p => out.push({ id: 'stock-' + p.id, kind: 'estoque', text: `${p.name} abaixo do mínimo (${p.stock} / ${p.min_stock})`, at: 'agora', tone: 'amber', })); // Recebimentos pendentes com divergência const recPend = (data.receivings || []).filter(r => r.status === 'pendente' && r.divergence_count > 0).slice(0, 1); recPend.forEach(r => out.push({ id: 'rec-' + r.id, kind: 'pedido', text: `${r.code} pendente com ${r.divergence_count} divergência${r.divergence_count > 1 ? 's' : ''}`, at: 'há minutos', tone: 'coral', })); // Cotações com fornecedores pendentes const pendQuotes = (data.quotations || []) .filter(q => q.status === 'analise' || q.status === 'aberta') .map(q => ({ ...q, pendingCount: (q.invited || []).filter(i => i.status === 'pendente').length })) .filter(q => q.pendingCount > 0).slice(0, 1); pendQuotes.forEach(q => out.push({ id: 'q-' + q.id, kind: 'cotacao', text: `${q.code} sem resposta de ${q.pendingCount} fornecedor${q.pendingCount > 1 ? 'es' : ''}`, at: 'há 1h', tone: 'amber', })); // IA sugestão na cotação ativa if (activeQuotation && activeQuotation.status === 'analise') { out.push({ id: 'ia-' + activeQuotation.id, kind: 'ia', text: `IA sugere fechar ${activeQuotation.code} com análise concluída`, at: 'há 3h', tone: 'emerald', }); } // Pedido em trânsito recente const transito = (data.orders || []).filter(o => o.status === 'em_transito').slice(0, 1); transito.forEach(o => out.push({ id: 'o-' + o.id, kind: 'pedido', text: `${o.code} em trânsito · entrega prevista ${o.eta ? new Date(o.eta).toLocaleDateString('pt-BR') : 'em breve'}`, at: 'há 2h', tone: 'sky', })); return out.slice(0, 6); }, [data, activeQuotation]); const handleAlertClick = (alert) => { const route = ALERT_ROUTE[alert.kind] || 'dashboard'; if (route === 'quotationDetail' && activeQuotation) { openQuotation(activeQuotation.id); } else { setRoute(route); } }; if (loading) { return
Carregando dashboard…
; } return (
{loadErr && ( Falha ao carregar dashboard:{' '} {loadErr} )} {/* Period selector bar */}
Visão geral Dashboard · {P.sub}
{Object.keys(PERIODS).map(k => ( ))}
0 ? `+${kpis.cotacoesNovas} no período` : null} deltaTone="emerald" icon={} sub={`${kpis.cotacoesAnalise} em análise · ${kpis.cotacoesPend} aguardando`} onClick={() => setRoute('quotations')} hint="Ver cotações" /> = 0 ? '+' : ''}${kpis.pedidosDelta.toFixed(0)}% vs. anterior` : null} deltaTone={kpis.pedidosDelta >= 0 ? 'emerald' : 'coral'} icon={} sub={`${BRL(kpis.pedidosTotal)} em compras`} onClick={() => setRoute('orders')} hint="Ver pedidos" /> = 0 ? '+' : ''}${kpis.economiaDelta.toFixed(1)}% vs. anterior` : null} deltaTone={kpis.economiaDelta >= 0 ? 'emerald' : 'coral'} icon={} sub={`Acumulado: ${BRL(kpis.economiaAcum)}`} onClick={() => setRoute('reports')} hint="Ver relatórios" /> 0 ? 'Reposição sugerida' : 'Tudo em dia'} deltaTone={kpis.criticos > 0 ? 'amber' : 'emerald'} icon={} sub={`${kpis.criticosAbaixo} abaixo de 50% do mínimo`} onClick={() => setRoute('stock')} hint="Repor estoque" />
{/* Hero: active quotation + IA recommendation */}
IA OpenAI · {activeQuotation?.status === 'analise' ? 'Análise concluída' : activeQuotation ? 'Cotação aberta' : 'Sem cotação ativa'}
{activeQuotation ? (() => { const itemsCount = activeQuotation.items?.length || 0; const supCount = activeQuotation.invited?.length || 0; const respCount = (activeQuotation.invited || []).filter(i => i.status === 'respondida').length; const deadline = activeQuotation.deadline ? new Date(activeQuotation.deadline).toLocaleString('pt-BR', { day: '2-digit', month: '2-digit', hour: '2-digit', minute: '2-digit' }).replace(',', ' ·') : '—'; return <>

Cotação ativa
{activeQuotation.title}

{respCount} de {supCount} fornecedores responderam. A IA sugere o melhor mix por item considerando preço, prazo e histórico.

Itens{itemsCount}
Fornecedores{respCount}/{supCount}
Prazo{deadline}
Código{activeQuotation.code}
} onClick={() => openQuotation(activeQuotation.id)}>Abrir análise IA } onClick={() => openQuotation(activeQuotation.id)}>Comparar lances
; })() : ( <>

Cotação ativa
Nenhuma cotação em andamento

Crie uma nova cotação para começar a economizar com a análise da IA.

} onClick={() => setRoute('newQuotation')}>Nova Cotação
)}
{activeQuotation && (
{activeAnalysis?.recommendation?.savings_value ? ( <> ECONOMIA {BRLk(activeAnalysis.recommendation.savings_value)} ) : ( <> ANÁLISE IA )}
{aiPings.length > 0 && (
{aiPings.map((p, i) => ( ))}
)}
)}
{/* Savings + Donut */}
} />
} />
setRoute(shareView === 'cat' ? 'reports' : 'suppliers')} />
{(shareView === 'cat' ? categoryShare : supplierShare).map(c => ( ))}
{/* Recent quotations */}
} onClick={() => setRoute('quotations')}>Ver todas} />
{filteredQuotations.length > 0 ? ( {filteredQuotations.slice(0, 6).map(q => { const status = QSTATUS[q.status] || { tone: 'fg', label: q.status }; const created = q.created_at ? new Date(q.created_at).toLocaleDateString('pt-BR') : '—'; return ( openQuotation(q.id)}> ); })}
Cotação Status Itens Resp. Valor Economia
{q.title}
{q.code} · {created}
{status.label} {q.items} {q.responded}/{q.suppliers} {q.total_value ? BRL(q.total_value) : '—'} {q.saving_value ? '↓ ' + BRL(q.saving_value) : '—'}
) : (

Nenhuma cotação criada {P.sub.toLowerCase()}.

setRoute('newQuotation')}>Criar cotação
)}
{/* Alerts feed */} } />
{alerts.length === 0 && (
Nenhum alerta no momento. Tudo em ordem.
)} {alerts.map(a => ( ))}
{/* Top suppliers */} } />
{topSuppliers.map((s, i) => ( ))}
{/* Top products */} } />
{topProducts.map((p, i) => ( ))}
); } (function() { if (document.getElementById('scp-dash-css')) return; const s = document.createElement('style'); s.id = 'scp-dash-css'; s.textContent = ` .scp-screen { padding: 24px 28px 40px; display: flex; flex-direction: column; gap: 18px; max-width: 1480px; } .scp-dash-grid { display: grid; grid-template-columns: repeat(4, 1fr); gap: 16px; } .scp-row { display: grid; gap: 16px; } .scp-row-2-1 { grid-template-columns: 1.7fr 1fr; } .scp-row-1-1 { grid-template-columns: 1fr 1fr; } /* Period selector bar */ .scp-period-bar { display: flex; align-items: center; justify-content: space-between; gap: 16px; background: var(--surface); border: 1px solid var(--border-soft); border-radius: var(--radius-lg); padding: 12px 18px; } .scp-period-left { display: flex; flex-direction: column; gap: 2px; min-width: 0; } .scp-period-eyebrow { font-size: 10.5px; color: var(--fg-3); text-transform: uppercase; letter-spacing: .14em; font-weight: 700; } .scp-period-title { font-size: 14.5px; color: var(--fg); font-weight: 600; letter-spacing: -0.01em; } .scp-period-right { display: flex; align-items: center; gap: 10px; } .scp-hero { padding: 28px; display: grid; grid-template-columns: 1.6fr 1fr; gap: 28px; background: radial-gradient(60% 80% at 100% 0%, oklch(0.30 0.08 160 / .25), transparent 60%), radial-gradient(60% 80% at 0% 100%, oklch(0.30 0.07 285 / .15), transparent 60%), var(--surface); position: relative; overflow: hidden; } .scp-hero-left .muted { font-size: 12px; color: var(--fg-3); text-transform: uppercase; letter-spacing: .14em; font-weight: 600; } .scp-hero-left h2 { font-size: 28px; font-weight: 600; letter-spacing: -0.02em; line-height: 1.15; margin: 6px 0 12px; } .scp-hero-left p { color: var(--fg-2); font-size: 14px; line-height: 1.55; margin: 0; max-width: 520px; } .scp-hero-left p strong { color: oklch(0.92 0.13 160); font-weight: 600; } .scp-hero-meta { display: grid; grid-template-columns: repeat(4, 1fr); gap: 12px; margin: 18px 0; padding-top: 18px; border-top: 1px solid var(--border-soft); } .scp-hero-meta > div { display: flex; flex-direction: column; gap: 2px; } .scp-hero-meta span { font-size: 11px; color: var(--fg-3); text-transform: uppercase; letter-spacing: .08em; font-weight: 600; } .scp-hero-meta strong { font-size: 16px; font-weight: 600; letter-spacing: -0.01em; font-feature-settings: "tnum"; } .scp-hero-meta strong.emerald { color: oklch(0.92 0.14 160); } .scp-hero-cta { display: flex; gap: 8px; } .scp-hero-tag { display: inline-flex; align-items: center; gap: 7px; padding: 5px 10px; border-radius: 999px; background: oklch(0.28 0.06 160 / .5); border: 1px solid oklch(0.40 0.08 160 / .55); color: oklch(0.92 0.14 160); font-size: 11px; font-weight: 600; letter-spacing: .02em; position: relative; } .scp-ai-glow { width: 7px; height: 7px; border-radius: 999px; background: oklch(0.78 0.18 160); box-shadow: 0 0 0 0 oklch(0.78 0.18 160 / .55); animation: pulse 1.8s ease-out infinite; } @keyframes pulse { 0% { box-shadow: 0 0 0 0 oklch(0.78 0.18 160 / .55); } 100% { box-shadow: 0 0 0 10px oklch(0.78 0.18 160 / 0); } } .scp-hero-right { position: relative; display: grid; place-items: center; } .scp-ai-orb { width: 240px; height: 240px; filter: drop-shadow(0 20px 60px oklch(0.40 0.10 160 / .45)); } .scp-ai-pings { position: absolute; inset: 0; pointer-events: none; } .scp-ai-pings .ping { position: absolute; padding: 4px 9px; border-radius: 999px; font-size: 10.5px; font-weight: 600; background: oklch(0.20 0.04 160 / .9); border: 1px solid oklch(0.40 0.08 160 / .6); color: oklch(0.92 0.13 160); font-family: 'JetBrains Mono', monospace; letter-spacing: -0.01em; animation: floaty 4.5s ease-in-out infinite; pointer-events: auto; cursor: pointer; transition: transform .12s, border-color .15s, background .15s; } .scp-ai-pings .ping:hover { background: oklch(0.28 0.06 160 / .95); border-color: oklch(0.55 0.12 160 / .8); transform: scale(1.08); } .ping-1 { top: 8%; left: 4%; } .ping-2 { top: 44%; right: 0%; animation-delay: -2.5s; } .ping-3 { bottom: 6%; left: 6%; animation-delay: -1.2s; } @keyframes floaty { 0%,100% { transform: translateY(0); } 50% { transform: translateY(-6px); } } /* Legend */ .scp-legend { display: flex; flex-direction: column; gap: 4px; flex: 1; min-width: 0; } .scp-legend-item { display: grid; grid-template-columns: 10px 1fr auto; gap: 10px; align-items: center; font-size: 12.5px; padding: 6px 8px; border-radius: 8px; background: transparent; border: 1px solid transparent; text-align: left; transition: background .12s, border-color .12s; } .scp-legend-item.clickable { cursor: pointer; } .scp-legend-item.clickable:hover { background: var(--surface-2); border-color: var(--border-soft); } .scp-legend-item .dot { width: 10px; height: 10px; border-radius: 3px; } .scp-legend-item .dot.tone-emerald { background: oklch(0.78 0.16 160); } .scp-legend-item .dot.tone-coral { background: oklch(0.74 0.18 25); } .scp-legend-item .dot.tone-sky { background: oklch(0.78 0.13 230); } .scp-legend-item .dot.tone-amber { background: oklch(0.84 0.14 78); } .scp-legend-item .dot.tone-violet { background: oklch(0.74 0.14 285); } .scp-legend-item .lbl { color: var(--fg-2); } .scp-legend-item .val { color: var(--fg); font-family: 'JetBrains Mono', monospace; font-weight: 600; } /* Alerts */ .scp-alerts { display: flex; flex-direction: column; gap: 6px; max-height: 320px; overflow: auto; } .scp-alert { display: grid; grid-template-columns: 10px 1fr auto; gap: 12px; align-items: flex-start; padding: 12px; border-radius: 10px; background: var(--surface-2); border: 1px solid var(--border-soft); cursor: pointer; text-align: left; transition: background .12s, border-color .12s, transform .12s; } .scp-alert:hover { background: var(--surface-3); border-color: var(--border); transform: translateX(2px); } .scp-alert:hover .arrow { opacity: 1; transform: translateX(0); } .scp-alert .dot { width: 8px; height: 8px; border-radius: 999px; margin-top: 6px; } .scp-alert.tone-emerald .dot { background: oklch(0.78 0.16 160); box-shadow: 0 0 0 4px oklch(0.78 0.16 160 / .15); } .scp-alert.tone-amber .dot { background: oklch(0.82 0.14 78); box-shadow: 0 0 0 4px oklch(0.82 0.14 78 / .15); } .scp-alert.tone-coral .dot { background: oklch(0.74 0.18 25); box-shadow: 0 0 0 4px oklch(0.74 0.18 25 / .15); } .scp-alert .body { min-width: 0; } .scp-alert .body p { margin: 0; font-size: 12.5px; line-height: 1.4; color: var(--fg); } .scp-alert .body span { font-size: 11px; color: var(--fg-3); margin-top: 4px; display: inline-block; } .scp-alert .arrow { color: var(--fg-3); margin-top: 4px; opacity: 0; transform: translateX(-4px); transition: opacity .15s, transform .15s; } /* Ranking */ .scp-rank { display: flex; flex-direction: column; gap: 0; } .scp-rank-row { display: grid; grid-template-columns: 30px 1fr auto; gap: 12px; align-items: center; padding: 10px 10px; border-bottom: 1px dashed var(--border-soft); background: transparent; border-radius: 8px; text-align: left; transition: background .12s, transform .12s; width: 100%; } .scp-rank-row.clickable { cursor: pointer; } .scp-rank-row.clickable:hover { background: var(--surface-2); transform: translateX(2px); } .scp-rank-row:last-child { border-bottom: 0; } .scp-rank-row .rk { width: 26px; height: 26px; border-radius: 7px; display: grid; place-items: center; font-size: 12px; font-weight: 700; background: var(--surface-2); color: var(--fg-2); font-family: 'JetBrains Mono', monospace; } .scp-rank-row .rk.gold { background: oklch(0.32 0.07 78 / .6); color: oklch(0.92 0.14 78); } .scp-rank-row .rk.silver { background: oklch(0.32 0.01 250 / .8); color: oklch(0.88 0.005 250); } .scp-rank-row .rk.bronze { background: oklch(0.32 0.07 60 / .55); color: oklch(0.85 0.13 60); } .scp-rank-row .info { display: flex; flex-direction: column; gap: 6px; min-width: 0; } .scp-rank-row .info .n { font-size: 13px; font-weight: 500; color: var(--fg); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } .scp-rank-row .info .meta { font-size: 11.5px; color: var(--fg-3); } .scp-rank-row .info .bar { height: 4px; background: var(--surface-2); border-radius: 999px; overflow: hidden; } .scp-rank-row .info .bar > span { display: block; height: 100%; background: linear-gradient(90deg, oklch(0.78 0.16 160), oklch(0.65 0.14 230)); border-radius: 999px; } .scp-rank-row .v { font-size: 14px; font-weight: 600; color: var(--fg); font-family: 'JetBrains Mono', monospace; } .scp-rank-row .v .u { font-size: 10px; color: var(--fg-3); font-weight: 500; } /* Empty state */ .scp-empty { display: flex; flex-direction: column; align-items: center; gap: 10px; padding: 36px 22px 32px; color: var(--fg-3); text-align: center; } .scp-empty p { margin: 0; font-size: 13px; } /* responsive squish */ @media (max-width: 1180px) { .scp-dash-grid { grid-template-columns: repeat(2, 1fr); } .scp-row-2-1, .scp-row-1-1 { grid-template-columns: 1fr; } .scp-hero { grid-template-columns: 1fr; } } `; document.head.appendChild(s); })(); window.ScreenDashboard = ScreenDashboard;