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 && (
{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 ? (
| Cotação |
Status |
Itens |
Resp. |
Valor |
Economia |
{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)}>
|
{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;