function ScreenReports() { const { BRL, BRLk } = window.SCP; const [period, setPeriod] = useState('90d'); const [productId, setProductId] = useState(null); const [data, setData] = useState({ quotations: [], orders: [], products: [], receivings: [] }); const [loading, setLoading] = useState(true); const [loadErr, setLoadErr] = useState(null); const [priceHistory, setPriceHistory] = useState([]); useEffect(() => { let mounted = true; (async () => { try { const [d, sups] = await Promise.all([ window.scpDb.dashboard.load(), window.scpDb.suppliers.list(), ]); if (!mounted) return; setData({ ...d, suppliers: sups }); // Default: produto mais cotado const topPid = computeTopProduct(d.orders); if (topPid) setProductId(topPid); } catch (e) { if (mounted) setLoadErr(e.message || 'Falha ao carregar relatórios'); } finally { if (mounted) setLoading(false); } })(); return () => { mounted = false; }; }, []); // Carrega histórico de preço quando productId muda useEffect(() => { if (!productId || !data.orders?.length) { setPriceHistory([]); return; } const points = []; (data.orders || []) .filter(o => o.status !== 'cancelado' && o.emitted_at) .forEach(o => { (o.items || []).forEach(it => { if (it.product_id === productId && it.unit_price) { points.push({ d: new Date(o.emitted_at), v: Number(it.unit_price) }); } }); }); points.sort((a, b) => a.d - b.d); setPriceHistory(points.map((p, i) => ({ d: p.d.toLocaleDateString('pt-BR', { day: '2-digit', month: 'short' }).replace('.', ''), v: p.v, }))); }, [productId, data.orders]); const days = period === '30d' ? 30 : period === '90d' ? 90 : period === '12m' ? 365 : 365 * 5; const today = new Date(); const periodStart = new Date(today.getTime() - days * 86400000); const inPeriod = (d) => d && new Date(d) >= periodStart; // ====== KPIs ====== const kpis = useMemo(() => { const fechadasPeriod = (data.quotations || []).filter(q => q.status === 'fechada' && inPeriod(q.closed_at)); const allClosed = (data.quotations || []).filter(q => q.status === 'fechada'); const economiaTotal = allClosed.reduce((s, q) => s + Number(q.saving_value || 0), 0); const cotacoesFechadas = fechadasPeriod.length; const ordersPeriod = (data.orders || []).filter(o => inPeriod(o.emitted_at) && o.status !== 'cancelado'); const volume = ordersPeriod.reduce((s, o) => s + Number(o.total || 0), 0); const totalInvited = (data.quotations || []).reduce((s, q) => s + (q.invited?.length || 0), 0); const totalResponded = (data.quotations || []).reduce((s, q) => s + (q.invited || []).filter(i => i.status === 'respondida').length, 0); const taxa = totalInvited ? Math.round(100 * totalResponded / totalInvited) : 0; return { economiaTotal, cotacoesFechadas, volume, taxa }; }, [data, period]); // ====== Evolução de preço ====== const priceStats = useMemo(() => { if (!priceHistory.length) return { min: 0, max: 0, current: 0, variation: 0 }; const vals = priceHistory.map(p => p.v); const min = Math.min(...vals); const max = Math.max(...vals); const current = vals[vals.length - 1]; const first = vals[0]; const variation = first ? ((current - first) / first) * 100 : 0; return { min, max, current, variation }; }, [priceHistory]); // ====== Savings (últimos 6 meses) ====== const savingsData = useMemo(() => { const byMonth = new Map(); for (let i = 5; 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 }); } (data.quotations || []) .filter(q => q.status === 'fechada' && q.closed_at && q.saving_value) .forEach(q => { const key = new Date(q.closed_at).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]); // ====== Performance dos fornecedores ====== const supplierPerf = useMemo(() => { const map = {}; (data.orders || []).forEach(o => { if (o.status === 'cancelado') return; const sid = o.supplier?.id; const name = o.supplier?.name; if (!sid || !name) return; if (!map[sid]) map[sid] = { sid, name, wins: 0 }; map[sid].wins += 1; }); const arr = Object.values(map).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]); // ====== Lista de produtos (para o select de evolução de preços) ====== const productOptions = useMemo(() => { const used = new Map(); (data.orders || []).forEach(o => { (o.items || []).forEach(it => { if (!it.product_id) return; used.set(it.product_id, (used.get(it.product_id) || 0) + 1); }); }); return (data.products || []) .filter(p => used.has(p.id)) .map(p => ({ id: p.id, name: p.name, count: used.get(p.id) || 0 })) .sort((a, b) => b.count - a.count) .slice(0, 20); }, [data]); const dateRangeLabel = `${periodStart.toLocaleDateString('pt-BR')} até ${today.toLocaleDateString('pt-BR')}`; if (loading) { return
Carregando relatórios…
; } return (
{loadErr && ( Falha ao carregar relatórios:{' '} {loadErr} )}
{[['30d','30 dias'],['90d','90 dias'],['12m','12 meses'],['ano','Este ano']].map(([k, l]) => ( ))}
· {dateRangeLabel}
} onClick={() => window.scpToast('Gerando PDF', { kind: 'sky', sub: 'Relatório consolidado · ~12s' })}>Exportar PDF } onClick={() => window.scpToast('Gerando planilha', { kind: 'sky', sub: 'Excel com 4 abas · ~6s' })}>Exportar Excel } onClick={() => window.scpToast('Envio programado', { kind: 'emerald', sub: 'Toda segunda às 09h' })}>Agendar envio
} /> } /> inPeriod(o.emitted_at) && o.status !== 'cancelado').length} pedidos · ${(data.suppliers?.length || 0)} fornecedores`} icon={} /> } />
setProductId(e.target.value)}> {productOptions.map(p => ( ))} }/> {priceHistory.length >= 2 ? ( <>
Mínimo: {BRL(priceStats.min)}
Máximo: {BRL(priceStats.max)}
Atual: {BRL(priceStats.current)}
Variação: {priceStats.variation <= 0 ? '↓' : '↑'} {Math.abs(priceStats.variation).toFixed(1)}%
) : (
{productId ? 'Este produto só foi comprado em 1 pedido (ou nenhum). Selecione outro produto.' : 'Selecione um produto para ver a evolução do preço.'}
)}
{supplierPerf.length === 0 && (
Sem dados de pedidos no período.
)} {supplierPerf.map((s, i) => (
{i+1}
{s.name}
{s.wins}
))}
}/>
{[ { t: 'Cotações realizadas', s: 'Fornecedores participantes e vencedores', i: }, { t: 'Evolução de preços', s: 'Gráficos de linha por produto', i: }, { t: 'Performance de fornecedores',s: 'Taxa de resposta, vitórias, prazo médio', i: }, { t: 'Economia gerada', s: 'Comparativo com preços históricos', i: }, { t: 'Pedidos de compra', s: 'Emitidos, confirmados e atrasados', i: }, { t: 'Movimentação de estoque', s: 'Entradas, saídas e curva ABC', i: }, { t: 'NFe e fiscal', s: 'Conferência e divergências de XML', i: }, { t: 'Curva ABC de produtos', s: 'Importância no faturamento', i: }, ].map(r => ( ))}
); } function computeTopProduct(orders) { const count = new Map(); (orders || []).forEach(o => { (o.items || []).forEach(it => { if (!it.product_id) return; count.set(it.product_id, (count.get(it.product_id) || 0) + 1); }); }); let top = null, max = 0; for (const [pid, c] of count) { if (c > max) { max = c; top = pid; } } return top; } (function() { if (document.getElementById('scp-reports-css')) return; const s = document.createElement('style'); s.id = 'scp-reports-css'; s.textContent = ` .scp-reports-grid { display: grid; grid-template-columns: repeat(4, 1fr); gap: 10px; margin-top: 8px; } .scp-report-card { display: grid; grid-template-columns: 36px 1fr 18px; gap: 12px; align-items: center; padding: 14px; border-radius: 12px; background: var(--surface-2); border: 1px solid var(--border-soft); text-align: left; transition: background .12s, border-color .12s, transform .08s; cursor: pointer; } .scp-report-card:hover { background: var(--surface-3); border-color: var(--border); } .scp-report-card:active { transform: translateY(1px); } .scp-report-card .ico { width: 36px; height: 36px; border-radius: 9px; display: grid; place-items: center; background: oklch(0.30 0.06 160 / .5); color: oklch(0.92 0.14 160); } .scp-report-card .t { display: block; font-size: 13px; font-weight: 600; color: var(--fg); } .scp-report-card .s { display: block; font-size: 11.5px; color: var(--fg-3); margin-top: 2px; line-height: 1.3; } .scp-report-card .arr { color: var(--fg-3); } .scp-report-card:hover .arr { color: var(--fg); transform: translateX(2px); transition: transform .12s; } @media (max-width: 1200px) { .scp-reports-grid { grid-template-columns: repeat(2, 1fr); } } `; document.head.appendChild(s); })(); window.ScreenReports = ScreenReports;