/* global React, Icon, Sparkline, Section, BRL, Ticker, KANBAN_LIC */
const { useState: uS, useEffect: uE, useMemo: uM, useRef: uR } = React;
function MDashboard({ onNav }) {
const [now, setNow] = uS(new Date());
const [stats, setStats] = uS(null);
const [loading, setLoading] = uS(true);
uE(() => { const i = setInterval(() => setNow(new Date()), 1000); return () => clearInterval(i); }, []);
uE(() => {
let mounted = true;
window.dataApi.getDashboardStats()
.then(s => { if (mounted) { setStats(s); setLoading(false); } })
.catch(() => { if (mounted) setLoading(false); });
return () => { mounted = false; };
}, []);
const hour = now.getHours();
const greeting = hour < 12 ? 'Bom dia' : hour < 18 ? 'Boa tarde' : 'Boa noite';
const tt = String(now.getHours()).padStart(2, '0') + ':' + String(now.getMinutes()).padStart(2, '0') + ':' + String(now.getSeconds()).padStart(2, '0');
// Métricas derivadas (memoizadas)
const derived = uM(() => {
const editais = stats?.editais || [];
const todayStr = new Date().toISOString().slice(0, 10);
const futuros = editais.filter(e => new Date(e.abertura) > now).sort((a, b) => new Date(a.abertura) - new Date(b.abertura));
const proximo = futuros[0] || null;
const aderentes24h = editais.filter(e => {
const created = new Date(e.created_at);
return (now - created) / 1000 / 3600 < 36;
}).length;
const pregoesHoje = editais.filter(e => new Date(e.abertura).toISOString().slice(0, 10) === todayStr).length;
const ativos = editais.filter(e => !['descartado', 'ganho'].includes(e.status)).length;
const emDisputaHoje = editais.filter(e => e.status === 'disputa' && new Date(e.abertura).toISOString().slice(0, 10) === todayStr).length;
const carteira = editais.filter(e => e.status === 'ganho').reduce((s, e) => s + (parseFloat(e.valor) || 0), 0);
return { editais, proximo, aderentes24h, pregoesHoje, ativos, emDisputaHoje, carteira };
}, [stats, now]);
// Countdown ao próximo pregão real
const proxAbertura = derived.proximo ? new Date(derived.proximo.abertura) : null;
const diff = proxAbertura ? Math.max(0, Math.floor((proxAbertura - now) / 1000)) : 0;
const ch = String(Math.floor(diff / 3600)).padStart(2, '0');
const cm = String(Math.floor((diff % 3600) / 60)).padStart(2, '0');
const cs = String(diff % 60).padStart(2, '0');
return (
<>
{/* HERO */}
Painel · {now.toLocaleDateString('pt-BR', { day: '2-digit', month: 'short', year: 'numeric' }).replace('.', '')}
M00
● AO VIVO {tt}
{greeting}, {(stats && stats.editais.length === 0) ? 'pronto pra começar' : ((window.sglUser?.displayName || '').split(' ')[0] || 'tudo certo')}.
{loading ? 'Carregando dados...' : (
<>
{derived.aderentes24h} {derived.aderentes24h === 1 ? 'edital' : 'editais'} {derived.aderentes24h === 1 ? 'adicionado' : 'adicionados'} nas últimas 36h · {derived.pregoesHoje} {derived.pregoesHoje === 1 ? 'pregão hoje' : 'pregões hoje'}
>
)}
{derived.proximo ? (
onNav('disputa')}
/>
) : (
PRÓX. PREGÃO
—
Nenhum pregão agendado
)}
onNav('auditoria')} />
onNav('financeiro')} />
{/* KPI ROW */}
onNav('prospeccao')} />
onNav('disputa')} />
onNav('precificacao')} placeholder />
onNav('financeiro')} />
{/* MAIN GRID — Disputa AO VIVO + Revenue + AI */}
{/* SECOND ROW — Agenda + Pipeline funnel + Top performers */}
{/* THIRD ROW — Heatmap BR + Margem por categoria + Certidões */}
{/* KANBAN */}
onNav('prospeccao')}>Abrir módulo }>
>
);
}
/* ─────────────────────────────────────────────────────────── */
function AnimNum({ to }) {
const [v, setV] = uS(0);
uE(() => {
let start = 0; const dur = 800; const t0 = performance.now();
const tick = (t) => { const k = Math.min(1, (t - t0) / dur); setV(Math.round(start + (to - start) * (1 - Math.pow(1 - k, 3)))); if (k < 1) requestAnimationFrame(tick); };
requestAnimationFrame(tick);
}, [to]);
return {String(v).padStart(2, '0')};
}
function CountdownBlock({ label, h, m, s, sub, onClick }) {
return (
(e.key === 'Enter' || e.key === ' ') && onClick()) : undefined} style={{ borderLeft: '1px solid var(--line-1)', paddingLeft: 20, borderRadius: 4 }}>
{label}
{h}:{m}:{s}
{sub}
);
}
function MiniStat({ label, v, sub, sk, cls, onClick }) {
return (
(e.key === 'Enter' || e.key === ' ') && onClick()) : undefined} style={{ borderLeft: '1px solid var(--line-1)', paddingLeft: 20, borderRadius: 4 }}>
{label}
{v}
{sub}
);
}
function KpiCard({ label, value, delta, deltaCls, trend, color = 'lime', onClick, placeholder }) {
return (
(e.key === 'Enter' || e.key === ' ') && onClick()) : undefined} style={placeholder ? { opacity: 0.55 } : undefined}>
{label}
{value}
{deltaCls === 'up' && '↑'} {delta}
{!placeholder && trend && trend.length > 1 &&
}
);
}
function PlaceholderStat({ label, note, onClick }) {
return (
(e.key === 'Enter' || e.key === ' ') && onClick()) : undefined} style={{ borderLeft: '1px solid var(--line-1)', paddingLeft: 20, borderRadius: 4, opacity: 0.55 }}>
{label}
—
{note}
);
}
/* ── LIVE DISPUTA (placeholder até ter disputa ativa) ─────── */
function LiveDisputaCard({ onNav }) {
return (
onNav('disputa')} onKeyDown={e => (e.key === 'Enter' || e.key === ' ') && onNav('disputa')}>
Disputa ao vivo
nenhuma ativa
Nenhuma disputa ativa no momento
Abra o M04 para iniciar uma disputa a partir de um edital elegível.
);
}
/* ── REVENUE CHART (placeholder até M07 ter série temporal) ── */
function RevenueChart({ onNav }) {
return (
onNav('financeiro')} onKeyDown={e => (e.key === 'Enter' || e.key === ' ') && onNav('financeiro')}>
Receita × Custo
aguarda hist. M07
Histórico não disponível
O gráfico será gerado conforme as NFs forem emitidas e os títulos liquidados.
);
}
/* ── AI INSIGHTS (placeholder até OpenAI estar integrado) ──── */
function AiInsightsCard({ onNav }) {
return (
onNav('ia')} onKeyDown={e => (e.key === 'Enter' || e.key === ' ') && onNav('ia')}>
IA · Diagnóstico do dia
aguarda OpenAI
IA ainda não conectada
Configure a OpenAI em M13 para receber diagnósticos automáticos diários.
);
}
function AICard({ tone, title, body, onClick }) {
const cols = { red: 'var(--red)', amber: 'var(--amber)', cyan: 'var(--cyan)', lime: 'var(--lime)' };
const bgs = { red: 'rgba(255,93,93,0.06)', amber: 'rgba(255,176,32,0.06)', cyan: 'rgba(106,215,229,0.06)', lime: 'rgba(212,247,82,0.06)' };
return (
{ e.stopPropagation(); onClick(); }) : undefined} onKeyDown={onClick ? (e => (e.key === 'Enter' || e.key === ' ') && (e.stopPropagation(), onClick())) : undefined} style={{ background: bgs[tone], border: `1px solid ${cols[tone]}33`, borderLeft: `2px solid ${cols[tone]}`, padding: '8px 10px', borderRadius: 4 }}>
{title}
{body}
);
}
/* ── AGENDA ──────────────────────────────────────────────── */
function AgendaCard({ onNav, editais }) {
const todayStr = new Date().toISOString().slice(0, 10);
const now = new Date();
const items = (editais || [])
.filter(e => new Date(e.abertura).toISOString().slice(0, 10) === todayStr)
.sort((a, b) => new Date(a.abertura) - new Date(b.abertura))
.map(e => {
const ab = new Date(e.abertura);
const h = String(ab.getHours()).padStart(2, '0') + ':' + String(ab.getMinutes()).padStart(2, '0');
const done = ab < now;
const hot = (ab - now) / 1000 / 60 < 60 && (ab - now) > 0;
const toneByStatus = { novo: 'cyan', analise: 'cyan', precificacao: 'amber', disputa: 'red', ganho: 'lime', descartado: 'fg' };
return { h, t: e.numero, s: `${e.orgao} · ${BRL(e.valor)}`, tone: toneByStatus[e.status] || 'fg', done, hot };
});
const todayLabel = now.toLocaleDateString('pt-BR', { day: '2-digit', month: '2-digit' });
return (
Agenda · hoje {todayLabel}
{items.length} {items.length === 1 ? 'EVENTO' : 'EVENTOS'}
{items.length === 0 && (
Nenhum pregão agendado para hoje.
)}
{items.map((it, i) => {
const cols = { cyan: 'var(--cyan)', amber: 'var(--amber)', red: 'var(--red)', lime: 'var(--lime)', fg: 'var(--fg-3)' };
return (
onNav('prospeccao')} style={{ display: 'grid', gridTemplateColumns: '54px 1fr 16px', gap: 8, padding: '10px 14px', borderBottom: i < items.length - 1 ? '1px solid var(--line-1)' : 'none', alignItems: 'center', cursor: 'pointer', opacity: it.done ? 0.4 : 1 }}>
{it.h}
{it.t}
{it.hot && }
{it.s}
);
})}
);
}
/* ── PIPELINE FUNNEL ─────────────────────────────────────── */
function PipelineFunnel({ onNav, editais }) {
const list = editais || [];
const stages = [
{ n: 'Prospec.', v: list.filter(e => e.status === 'novo').length, c: 'var(--fg-2)', mod: 'prospeccao' },
{ n: 'Análise', v: list.filter(e => e.status === 'analise').length, c: 'var(--cyan)', mod: 'ocr' },
{ n: 'Precific.', v: list.filter(e => e.status === 'precificacao').length, c: 'var(--lime-2)', mod: 'precificacao' },
{ n: 'Disputa', v: list.filter(e => e.status === 'disputa').length, c: 'var(--lime)', mod: 'disputa' },
{ n: 'Vitória', v: list.filter(e => e.status === 'ganho').length, c: 'var(--lime)', mod: 'empenhos' },
{ n: 'Empenho', v: 0, c: 'var(--fg-3)', mod: 'empenhos' },
];
const max = Math.max(...stages.map(s => s.v), 1);
const total = list.length;
const conv = total > 0 ? ((stages[4].v / total) * 100).toFixed(1) : '0.0';
return (
Funil · editais por status
{conv}% VITÓRIA
{stages.map((s, i) => {
const w = (s.v / max) * 100;
return (
onNav(s.mod)} onKeyDown={e => (e.key === 'Enter' || e.key === ' ') && onNav(s.mod)} style={{ marginBottom: 8, padding: '2px 4px', borderRadius: 4 }}>
{s.n}
{s.v}
{i > 0 &&
{((s.v / stages[i - 1].v) * 100).toFixed(0)}%}
);
})}
);
}
/* ── TOP PERFORMERS ──────────────────────────────────────── */
function TopPerformersCard({ onNav, editais, fornecedores, produtos }) {
const [tab, setTab] = uS('clientes');
const clientes = uM(() => {
const map = {};
(editais || []).forEach(e => {
if (!map[e.orgao]) map[e.orgao] = { n: e.orgao, v: 0, q: 0 };
map[e.orgao].v += parseFloat(e.valor) || 0;
map[e.orgao].q += 1;
});
return Object.values(map).sort((a, b) => b.v - a.v).slice(0, 5);
}, [editais]);
const fornec = uM(() => {
// ranking por número de produtos associados (proxy de relacionamento)
const map = {};
(produtos || []).forEach(p => {
if (!p.fornecedor_id) return;
if (!map[p.fornecedor_id]) {
const f = (fornecedores || []).find(x => x.id === p.fornecedor_id);
map[p.fornecedor_id] = { n: f?.nome || '—', v: 0, q: 0 };
}
map[p.fornecedor_id].v += (p.custo || 0) * (p.st || 0);
map[p.fornecedor_id].q += 1;
});
return Object.values(map).sort((a, b) => b.v - a.v).slice(0, 5);
}, [produtos, fornecedores]);
const items = tab === 'clientes' ? clientes : fornec;
const max = items[0]?.v || 1;
return (
Top {tab === 'clientes' ? 'Clientes' : 'Fornecedores'} · 90d
setTab('clientes')}>CLIENTES
setTab('fornec')}>FORNEC.
{items.length === 0 && (
{tab === 'clientes' ? 'Nenhum edital cadastrado ainda.' : 'Nenhum produto vinculado a fornecedor.'}
)}
{items.map((it, i) => {
const w = (it.v / max) * 100;
const target = tab === 'clientes' ? 'prospeccao' : 'pdv';
return (
onNav(target)} onKeyDown={e => (e.key === 'Enter' || e.key === ' ') && onNav(target)} style={{ marginBottom: 10, padding: '4px 6px', borderRadius: 4 }}>
{i + 1}. {it.n}
{BRL(it.v)}
{it.q} {tab === 'clientes' ? 'editais' : 'produtos'}
·
{tab === 'clientes' ? `valor total` : `volume R$`}
);
})}
);
}
/* ── BRASIL HEATMAP ──────────────────────────────────────── */
function BrasilHeatmap({ onNav, editais }) {
const list = editais || [];
const counts = {};
list.forEach(e => { counts[e.uf] = (counts[e.uf] || 0) + 1; });
const ALL_UFS = ['AC','AL','AP','AM','BA','CE','DF','ES','GO','MA','MT','MS','MG','PA','PB','PR','PE','PI','RJ','RN','RS','RO','RR','SC','SP','SE','TO'];
const ufs = ALL_UFS.map(u => ({ u, v: counts[u] || 0 }));
const max = Math.max(...ufs.map(x => x.v), 1);
const total = list.length;
const ufsAtivas = ufs.filter(x => x.v > 0).length;
return (
Distribuição geográfica · editais
{ufsAtivas} UFs · {total} EDITAIS
{ufs.map(uf => {
const intensity = uf.v / max;
const isHot = uf.v > 30;
const canClick = uf.v > 0;
return (
onNav('prospeccao') : undefined}
onKeyDown={canClick ? (e => (e.key === 'Enter' || e.key === ' ') && onNav('prospeccao')) : undefined}
style={{ aspectRatio: '1', borderRadius: 3, background: uf.v === 0 ? 'var(--bg-2)' : `rgba(212, 247, 82, ${0.1 + intensity * 0.7})`, border: '1px solid var(--line-1)', display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center', position: 'relative' }}>
0 ? 'var(--fg-0)' : 'var(--fg-3)', fontWeight: 600, fontFamily: 'IBM Plex Mono' }}>{uf.u}
{uf.v || '·'}
);
})}
);
}
/* ── MARGEM POR CATEGORIA ────────────────────────────────── */
function MargemCategoria({ onNav }) {
const cats = [
{ n: 'Papelaria', m: 41.2, v: 184 },
{ n: 'Escritório', m: 38.4, v: 248 },
{ n: 'Arquivamento', m: 36.8, v: 92 },
{ n: 'Escolar', m: 34.1, v: 142 },
{ n: 'Brinquedos', m: 28.6, v: 48 },
{ n: 'Tintas/EPI', m: 24.2, v: 18 },
];
return (
onNav('precificacao')} onKeyDown={e => (e.key === 'Enter' || e.key === ' ') && onNav('precificacao')}>
Margem × Volume por categoria
DEMO · aguarda M03
);
}
/* ── CERTIDÕES ───────────────────────────────────────────── */
function CertidoesCard({ onNav }) {
const [certs, setCerts] = uS(null);
uE(() => {
let mounted = true;
window.dataApi.listCertidoes()
.then(rows => { if (mounted) setCerts(rows); })
.catch(() => { if (mounted) setCerts([]); });
return () => { mounted = false; };
}, []);
return (
Certidões · habilitação
{certs ? `${certs.filter(c => c.status === 'critico' || c.status === 'vencida').length} crítica(s)` : '...'}
{certs === null && (
Carregando certidões...
)}
{certs && certs.length === 0 && (
Nenhuma certidão cadastrada.
)}
{(certs || []).slice(0, 8).map(c => {
const color = c.status === 'vencida' ? 'var(--red)' : c.status === 'critico' ? 'var(--red)' : c.status === 'alerta' ? 'var(--amber)' : 'var(--lime)';
const pct = Math.max(2, Math.min(100, (180 - c.dias) / 180 * 100));
return (
onNav('auditoria')} onKeyDown={e => (e.key === 'Enter' || e.key === ' ') && onNav('auditoria')} style={{ display: 'grid', gridTemplateColumns: '1fr 70px 50px', gap: 10, padding: '8px 14px', borderBottom: '1px solid var(--line-1)', alignItems: 'center' }}>
{c.dias < 0 ? `+${Math.abs(c.dias)}d` : `${c.dias}d`}
);
})}
);
}
/* ── KANBAN ──────────────────────────────────────────────── */
function KanbanLicitacao({ onNav, editais }) {
const list = editais || [];
const colMap = {
prospec: list.filter(e => e.status === 'novo'),
analise: list.filter(e => e.status === 'analise'),
precif: list.filter(e => e.status === 'precificacao'),
disputa: list.filter(e => e.status === 'disputa'),
ganhos: list.filter(e => e.status === 'ganho'),
descart: list.filter(e => e.status === 'descartado'),
};
return (
{KANBAN_LIC.map(col => (
{col.label}
{colMap[col.id]?.length || 0}
{(colMap[col.id] || []).map(e => (
onNav('prospeccao')}>
{e.numero}
{e.orgao}
{e.uf}·{e.itens || '—'} itens
{BRL(e.valor)}
))}
{(colMap[col.id] || []).length === 0 && (
—
)}
))}
);
}
window.MDashboard = MDashboard;