// === Showcase: AI Analysis of an active quotation (Supabase) === function ScreenQuotationDetail({ setRoute, quoteId }) { const { BRL } = window.SCP; const [loading, setLoading] = useState(true); const [loadErr, setLoadErr] = useState(null); const [Q, setQ] = useState(null); useEffect(() => { if (!quoteId) return; let mounted = true; setLoading(true); (async () => { try { const raw = await window.scpDb.quotations.getByIdOrCode(quoteId); if (!mounted) return; // ----- Transform DB shape → UI shape esperado pelo componente ----- const items = (raw.items || []) .sort((a, b) => (a.position || 0) - (b.position || 0)) .map(it => ({ id: it.id, product_id: it.product_id, name: it.name_snapshot || it.product?.name, unit: it.unit_snapshot || it.product?.unit, qty: Number(it.qty), lastPrice: Number(it.last_price) || 0, })); const suppliers = (raw.invited || []).map(i => i.supplier_id); const bidsMap = {}; (raw.invited || []).forEach(inv => { bidsMap[inv.supplier_id] = { _status: inv.status, _at: inv.responded_at ? new Date(inv.responded_at).toLocaleString('pt-BR', { dateStyle: 'short', timeStyle: 'short' }) : null, }; }); (raw.bids || []).forEach(b => { if (!bidsMap[b.supplier_id]) bidsMap[b.supplier_id] = {}; bidsMap[b.supplier_id][b.item_id] = { price: b.price != null ? Number(b.price) : null, alt: b.alt_brand, skip: !!b.skip, }; }); const supObj = Object.fromEntries( (raw.invited || []).map(i => [i.supplier_id, { id: i.supplier_id, name: i.supplier?.name || '—', cnpj: i.supplier?.cnpj, city: i.supplier?.city, score: i.supplier?.score, avgDelivery: i.supplier?.avg_delivery, }]) ); setQ({ id: raw.id, code: raw.code, title: raw.title, status: raw.status, createdAt: raw.created_at ? new Date(raw.created_at).toLocaleString('pt-BR', { dateStyle: 'short', timeStyle: 'short' }) : '—', deadline: raw.deadline ? new Date(raw.deadline).toLocaleString('pt-BR', { dateStyle: 'short', timeStyle: 'short' }) : '—', buyer: raw.created_by_name || window.SCP.COMPANY.owner, obs: raw.obs, items, suppliers, bids: bidsMap, _supObj: supObj, }); } catch (e) { if (mounted) setLoadErr(e.message || 'Falha ao carregar cotação'); } finally { if (mounted) setLoading(false); } })(); return () => { mounted = false; }; }, [quoteId]); const responded = useMemo( () => (Q ? Q.suppliers.filter(s => Q.bids[s]?._status === 'respondida') : []), [Q] ); const supObj = Q?._supObj || {}; // For each item, find best price and AI-recommended supplier (heuristic over bids) const analysis = useMemo(() => { if (!Q) return []; return Q.items.map(item => { const offers = responded .map(sid => ({ sid, bid: Q.bids[sid]?.[item.id] })) .filter(o => o.bid && o.bid.price != null && !o.bid.skip); if (offers.length === 0) return { item, best: null, second: null, allOffers: offers, savings: 0 }; offers.sort((a, b) => a.bid.price - b.bid.price); const best = offers[0]; const second = offers[1] ?? null; const savings = (item.lastPrice - best.bid.price) * item.qty; return { item, best, second, allOffers: offers, savings }; }); }, [Q, responded]); const totals = useMemo(() => { let total = 0, lastTotal = 0, savings = 0; analysis.forEach(a => { if (a.best) { total += a.best.bid.price * a.item.qty; lastTotal += a.item.lastPrice * a.item.qty; savings += (a.item.lastPrice - a.best.bid.price) * a.item.qty; } }); return { total, lastTotal, savings, pct: lastTotal ? savings / lastTotal * 100 : 0 }; }, [analysis]); // Group suggested by supplier (purchase orders preview) const bySupplier = useMemo(() => { const map = {}; analysis.forEach(a => { if (!a.best) return; (map[a.best.sid] ||= []).push(a); }); return map; }, [analysis]); const [iaState, setIaState] = useState('idle'); // idle | running | done | error const [iaResult, setIaResult] = useState(null); // { recommendation, model, tokens_used, duration_ms } const [iaError, setIaError] = useState(null); // Carrega análise existente OU dispara nova useEffect(() => { if (!Q) return; let mounted = true; setIaState('idle'); setIaResult(null); setIaError(null); (async () => { try { // Tenta pegar análise cacheada primeiro const cached = await window.scpDb.ai.getLatestAnalysis(Q.id); if (cached && mounted) { setIaResult({ recommendation: cached.recommendation, model: cached.model, tokens_used: cached.tokens_used, duration_ms: cached.duration_ms, created_at: cached.created_at, }); setIaState('done'); return; } // Se não, chama Edge Function if (mounted) setIaState('running'); const res = await window.scpDb.ai.analyzeQuotation(Q.id); if (!mounted) return; setIaResult(res); setIaState('done'); } catch (e) { if (mounted) { setIaError(e.message || String(e)); setIaState('error'); } } })(); return () => { mounted = false; }; }, [Q?.id]); const reanalyze = async () => { if (!Q) return; setIaState('running'); setIaError(null); try { const res = await window.scpDb.ai.analyzeQuotation(Q.id); setIaResult(res); setIaState('done'); window.scpToast('Nova análise concluída', { kind: 'emerald', sub: `${res.tokens_used} tokens · ${res.duration_ms}ms` }); } catch (e) { setIaError(e.message || String(e)); setIaState('error'); window.scpToast('Erro na análise', { kind: 'coral', sub: e.message }); } }; const [overrides, setOverrides] = useState({}); const setPick = (itemId, sid) => setOverrides(o => ({ ...o, [itemId]: sid })); // Pick padrão = IA real se disponível, senão melhor preço (fallback heurístico) const aiPicks = iaResult?.recommendation?.per_item || {}; const pickFor = (itemId, defaultSid) => overrides[itemId] || aiPicks[itemId] || defaultSid; const matrixRef = useRef(null); const [approving, setApproving] = useState(false); const pendingSuppliers = Q ? Q.suppliers.filter(sid => Q.bids[sid]?._status !== 'respondida') : []; // === Early returns DEPOIS de todos os hooks (regra do React) === if (loading) { return
Carregando cotação…
; } if (loadErr || !Q) { return
Falha ao carregar cotação.

{loadErr || 'Cotação não encontrada.'}

setRoute('quotations')}>Voltar para cotações
; } const handleRemind = () => { if (pendingSuppliers.length === 0) { window.scpToast('Todos já responderam', { kind: 'emerald', sub: 'Nenhum lembrete necessário' }); return; } const names = pendingSuppliers.map(sid => supObj[sid]?.name).filter(Boolean).join(', '); window.scpToast(`Lembrete enviado · ${pendingSuppliers.length} fornecedor${pendingSuppliers.length === 1 ? '' : 'es'}`, { kind: 'sky', sub: names }); }; const handleExport = () => { window.scpToast('Exportação iniciada', { kind: 'sky', sub: `Cotação ${Q.code} · PDF com matriz comparativa` }); }; const handleApprove = async () => { if (Q.status === 'fechada') { window.scpToast('Cotação já está fechada', { kind: 'amber' }); return; } setApproving(true); try { // 1) Cria pedidos por fornecedor (1 pedido para cada grupo de itens vencidos) const orders = await window.scpDb.orders.createFromQuotation(Q, bySupplier); // 2) Marca a cotação como fechada + salva total/economia await window.scpDb.quotations.updateStatus(Q.id, 'fechada'); window.scpToast(`Cotação fechada · ${orders.length} pedidos emitidos`, { kind: 'emerald', sub: `${Q.code} · economia de ${BRL(totals.savings)}`, }); setTimeout(() => setRoute && setRoute('orders'), 700); } catch (e) { window.scpToast('Erro ao fechar cotação', { kind: 'coral', sub: e.message }); } finally { setApproving(false); } }; const handleManual = () => { window.scpToast('Modo manual ativado', { kind: 'sky', sub: 'Clique nas células da matriz para escolher fornecedor por item' }); matrixRef.current?.scrollIntoView({ behavior: 'smooth', block: 'start' }); }; const handleDelete = async () => { const msg = `Excluir a cotação ${Q.code} (${Q.title})?\n\nIsso vai apagar todos os itens, lances e convites de fornecedores.\nPedidos já emitidos a partir desta cotação continuarão preservados.\n\nAção não pode ser desfeita.`; if (!confirm(msg)) return; try { await window.scpDb.quotations.remove(Q.id); window.scpToast('Cotação excluída', { kind: 'amber', sub: Q.code }); setRoute('quotations'); } catch (e) { window.scpToast('Erro ao excluir', { kind: 'coral', sub: e.message }); } }; return (
{/* Header bar */}
} onClick={() => setRoute('quotations')}>Voltar
{QSTATUS[Q.status]?.label || 'Em Análise'} {Q.code} · Criada {Q.createdAt} · por {Q.buyer}

{Q.title}

{Q.obs}

Itens{Q.items.length}
Respostas{responded.length}/{Q.suppliers.length}
Prazo{Q.deadline}
} onClick={handleDelete} style={{ color: 'oklch(0.82 0.14 25)' }}>Excluir } onClick={handleRemind}>Cobrar fornecedores } onClick={handleExport}>Exportar
{/* IA recommendation banner */}
Análise IA · OpenAI ({iaResult?.model || 'gpt-4o'}) {iaState === 'idle' && 'preparando…'} {iaState === 'running' && `processando ${Q.suppliers.length} fornecedores · ${Q.items.length} itens…`} {iaState === 'done' && iaResult?.duration_ms && `concluída em ${(iaResult.duration_ms / 1000).toFixed(2)}s · ${iaResult.tokens_used} tokens`} {iaState === 'done' && !iaResult?.duration_ms && 'análise carregada do cache'} {iaState === 'error' && 'falha na análise'}
{iaState === 'error' ? (

Não consegui completar a análise. {iaError || 'Erro desconhecido.'}

Verifique se a Edge Function analyze-quotation foi deployada e se o secret OPENAI_API_KEY está configurado.

Tentar novamente
) : iaState !== 'done' ? (
Cruzando lances Avaliando histórico Otimizando combinações Validando prazos
) : (

{iaResult?.recommendation?.reasoning || 'Recomendação gerada com base em preço, confiabilidade e prazo.'}

Valor estimado{BRL(iaResult?.recommendation?.total_value ?? totals.total)}
Economia vs. último ciclo↓ {BRL(iaResult?.recommendation?.savings_value ?? totals.savings)} ({(iaResult?.recommendation?.savings_pct ?? totals.pct).toFixed(1)}%)
Fornecedores selecionados{Object.keys(iaResult?.recommendation?.supplier_orders || bySupplier).length}
Confiança{iaResult?.recommendation?.confidence ?? 96}%
)}
} disabled={iaState !== 'done' || approving || Q.status === 'fechada'} onClick={handleApprove}> {approving ? 'Emitindo…' : Q.status === 'fechada' ? 'Cotação fechada' : 'Aprovar e emitir pedidos'}
Ajustar manualmente {iaState === 'done' && ( }>Re-analisar )}
{/* Comparative matrix */}
Melhor preço Sugestão IA Não trabalha Sem resposta
{Q.suppliers.map(sid => { const s = supObj[sid]; const status = Q.bids[sid]?._status; return ( ); })} {analysis.map(a => { const aiPick = a.best?.sid; const userPick = pickFor(a.item.id, aiPick); return ( {Q.suppliers.map(sid => { const bid = Q.bids[sid]?.[a.item.id]; const status = Q.bids[sid]?._status; const isBest = aiPick === sid; const isPicked = userPick === sid; let cell; if (status === 'pendente') cell = ; else if (!bid || bid.skip) cell = não trabalha; else cell = ( ); return ; })} ); })} {Q.suppliers.map(sid => { const sup = supObj[sid]; const status = Q.bids[sid]?._status; if (status !== 'respondida') return ; let total = 0; let count = 0; Q.items.forEach(it => { const b = Q.bids[sid][it.id]; if (b && b.price != null && !b.skip) { total += b.price * it.qty; count++; } }); return ( ); })}
Produto Qtd Últ. preço
{s.name}
{status === 'respondida' ? <>respondida {Q.bids[sid]._at} : <>aguardando}
Sugestão
{a.item.name}
{a.item.id} · {a.item.unit}
{a.item.qty} {BRL(a.item.lastPrice)}{cell} {a.best ? (
{supObj[userPick].name.split(' ')[0]} ↓ {BRL(a.savings)}
) : aguardando}
Total sugerido {Q.items.reduce((s, i) => s + i.qty, 0)} {BRL(totals.lastTotal)}
{BRL(total)}
{count} itens
{BRL(totals.total)}
{/* Purchase order preview cards */}
{Object.keys(bySupplier).length} fornecedores · {analysis.filter(a => a.best).length} itens} />
{Object.entries(bySupplier).map(([sid, items]) => { const sup = supObj[sid]; const total = items.reduce((s, a) => s + a.best.bid.price * a.item.qty, 0); return (
{sup.name} {sup.cnpj} · {sup.city}
{items.length} itens
{items.map(a => (
{a.item.name} {a.item.qty} {a.item.unit} {BRL(a.best.bid.price * a.item.qty)}
))}
Score do fornecedor {sup.score}/100 {BRL(total)}
); })}
); } (function() { if (document.getElementById('scp-qdetail-css')) return; const s = document.createElement('style'); s.id = 'scp-qdetail-css'; s.textContent = ` .scp-q-head { padding: 22px 24px; display: grid; grid-template-columns: 1.4fr auto; gap: 22px; } .scp-q-head-titles { margin-top: 6px; } .scp-q-head-tag { display: flex; align-items: center; gap: 10px; flex-wrap: wrap; margin: 12px 0 6px; } .scp-q-head-tag .scp-q-id { font-size: 12px; color: var(--fg-3); } .scp-q-head-tag .sep { color: var(--fg-4); } .scp-q-head-tag .meta { font-size: 12px; color: var(--fg-3); } .scp-q-head h2 { margin: 4px 0 6px; font-size: 24px; font-weight: 600; letter-spacing: -0.02em; } .scp-q-head p { margin: 0; color: var(--fg-3); font-size: 13px; line-height: 1.5; } .scp-q-head-right { display: flex; flex-direction: column; align-items: flex-end; gap: 14px; } .scp-q-meta { display: flex; gap: 24px; } .scp-q-meta > div { display: flex; flex-direction: column; align-items: flex-end; gap: 2px; } .scp-q-meta span { font-size: 11px; color: var(--fg-3); text-transform: uppercase; letter-spacing: .08em; font-weight: 600; } .scp-q-meta strong { font-size: 18px; font-weight: 600; font-feature-settings: "tnum"; } .scp-q-meta strong small { font-size: 13px; color: var(--fg-3); font-weight: 500; margin-left: 4px; } .scp-q-actions { display: flex; gap: 8px; } /* IA banner */ .scp-ia-banner { display: grid; grid-template-columns: 56px 1fr auto; gap: 18px; padding: 18px 22px; align-items: center; background: radial-gradient(80% 100% at 0% 0%, oklch(0.30 0.10 160 / .25), transparent 60%), linear-gradient(180deg, oklch(0.21 0.025 250), oklch(0.19 0.020 250)); border-color: oklch(0.34 0.06 160 / .4); } .ia-orb { width: 56px; height: 56px; filter: drop-shadow(0 8px 20px oklch(0.5 0.14 160 / .5)); } .ia-body { min-width: 0; } .ia-tag { display: flex; align-items: center; gap: 8px; font-size: 11.5px; color: oklch(0.92 0.13 160); font-weight: 600; letter-spacing: .01em; } .ia-status { color: var(--fg-3); font-weight: 500; font-family: 'JetBrains Mono', monospace; font-size: 11px; } .ia-status.running::before { content: "●"; color: oklch(0.78 0.16 160); margin-right: 4px; animation: blink 1s infinite; } @keyframes blink { 50% { opacity: 0.3; } } .ia-skel { margin-top: 10px; } .ia-skel .bar { height: 5px; background: oklch(0.28 0.01 250); border-radius: 999px; overflow: hidden; } .ia-skel .bar > span { display: block; height: 100%; width: 30%; background: linear-gradient(90deg, oklch(0.50 0.12 160), oklch(0.78 0.16 160)); animation: slide 1.6s ease-in-out infinite; border-radius: 999px; } @keyframes slide { 0% { transform: translateX(-100%); width: 30%; } 50% { width: 60%; } 100% { transform: translateX(280%); width: 30%; } } .ia-steps { display: flex; gap: 16px; margin-top: 8px; font-size: 11.5px; color: var(--fg-4); } .ia-steps .on { color: oklch(0.92 0.13 160); } .ia-steps .on::before { content: "✓ "; color: oklch(0.78 0.16 160); } .ia-result p { margin: 6px 0 12px; color: var(--fg-2); font-size: 13px; line-height: 1.5; max-width: 580px; } .ia-result p strong { color: var(--fg); font-weight: 600; } .ia-numbers { display: grid; grid-template-columns: repeat(4, auto); gap: 24px; } .ia-numbers > div { display: flex; flex-direction: column; gap: 2px; } .ia-numbers span { font-size: 11px; color: var(--fg-3); text-transform: uppercase; letter-spacing: .08em; font-weight: 600; } .ia-numbers strong { font-size: 15px; font-weight: 600; } .ia-numbers strong.em { color: oklch(0.92 0.13 160); } .ia-cta { display: flex; flex-direction: column; align-items: flex-end; gap: 6px; } /* Matrix */ .scp-matrix-head { padding: 18px 22px; border-bottom: 1px solid var(--border-soft); display: flex; align-items: center; justify-content: space-between; gap: 16px; flex-wrap: wrap; } .scp-matrix-legend { display: flex; gap: 14px; font-size: 11.5px; color: var(--fg-3); } .scp-matrix-legend > span { display: inline-flex; align-items: center; gap: 6px; } .scp-matrix-legend .dot { width: 8px; height: 8px; border-radius: 3px; } .scp-matrix-legend .dot.tone-emerald { background: oklch(0.78 0.16 160); } .scp-matrix-legend .dot.tone-violet { background: oklch(0.74 0.14 285); } .scp-matrix-legend .dot.tone-muted { background: oklch(0.40 0.012 250); } .scp-matrix-legend .dot.tone-amber { background: oklch(0.84 0.14 78); } .scp-matrix-wrap { overflow-x: auto; } .scp-matrix { width: 100%; border-collapse: separate; border-spacing: 0; font-size: 13px; } .scp-matrix thead th { padding: 12px 14px; background: var(--surface); font-size: 11px; color: var(--fg-3); text-transform: uppercase; letter-spacing: .08em; font-weight: 600; text-align: left; border-bottom: 1px solid var(--border-soft); white-space: nowrap; } .scp-matrix th.sticky-l, .scp-matrix td.sticky-l { position: sticky; left: 0; background: var(--surface); z-index: 1; box-shadow: 1px 0 0 var(--border-soft); min-width: 240px; max-width: 280px; } .scp-matrix tbody td.sticky-l { background: var(--surface); } .scp-matrix tbody tr:hover td.sticky-l { background: var(--surface-2); } .scp-matrix tbody tr:hover td { background: var(--surface-2); } .scp-matrix .supplier-head { min-width: 130px; padding: 10px 14px; vertical-align: bottom; } .scp-matrix .supplier-head.pending { color: oklch(0.78 0.10 78); } .scp-matrix .sh-top { display: flex; align-items: center; gap: 7px; text-transform: none; letter-spacing: 0; color: var(--fg); font-size: 12px; font-weight: 600; } .scp-matrix .sh-top .n { white-space: nowrap; overflow: hidden; text-overflow: ellipsis; max-width: 110px; } .scp-matrix .sh-bot { display: flex; align-items: center; gap: 4px; font-size: 10.5px; color: var(--fg-3); text-transform: none; letter-spacing: 0; font-weight: 500; margin-top: 4px; } .scp-matrix .supplier-head.pending .sh-bot { color: oklch(0.78 0.12 78); } .scp-matrix tbody td { padding: 10px 14px; border-bottom: 1px solid var(--border-soft); vertical-align: middle; } .scp-matrix tbody td.cell-td { padding: 6px 8px; } .scp-matrix tbody td.cell-td.best { background: oklch(0.24 0.06 160 / .18) !important; } .scp-matrix tbody tr:hover td.cell-td.best { background: oklch(0.28 0.07 160 / .22) !important; } .scp-matrix .cell { display: inline-flex; flex-direction: column; align-items: flex-start; gap: 2px; padding: 6px 10px; border-radius: 8px; min-width: 100px; text-align: left; transition: background .12s, border-color .12s; border: 1px solid transparent; } .scp-matrix .cell.price { cursor: pointer; } .scp-matrix .cell .v { font-family: 'JetBrains Mono', monospace; font-weight: 600; color: var(--fg); font-size: 13px; } .scp-matrix .cell.best { background: oklch(0.32 0.08 160 / .6); border-color: oklch(0.50 0.10 160); } .scp-matrix .cell.best .v { color: oklch(0.96 0.16 160); } .scp-matrix .cell.picked { background: oklch(0.30 0.08 285 / .55); border-color: oklch(0.48 0.10 285); } .scp-matrix .cell.picked .v { color: oklch(0.94 0.13 285); } .scp-matrix .cell .alt { font-size: 10.5px; color: var(--fg-3); } .scp-matrix .cell .marker { font-size: 9px; padding: 1px 6px; border-radius: 999px; display: inline-flex; align-items: center; gap: 3px; font-weight: 700; letter-spacing: .04em; margin-top: 3px; } .scp-matrix .cell .marker.ia { background: oklch(0.45 0.12 160); color: white; } .scp-matrix .cell .marker.user { background: oklch(0.45 0.12 285); color: white; } .scp-matrix .cell.skip { color: var(--fg-4); font-size: 11px; padding: 6px 4px; font-style: italic; } .scp-matrix .cell.pending { color: oklch(0.78 0.10 78); } .scp-matrix .suggestion { display: flex; flex-direction: column; align-items: flex-start; gap: 4px; min-width: 120px; } .scp-matrix .suggestion .save { font-size: 11px; color: oklch(0.86 0.14 160); font-weight: 600; } .scp-matrix tfoot td { padding: 14px 14px; background: oklch(0.18 0.012 250); border-top: 1px solid var(--border-soft); font-size: 13px; font-weight: 600; } .scp-matrix tfoot td.em { color: var(--fg); } /* Order preview cards */ .scp-q-orders-grid { display: grid; grid-template-columns: repeat(2, 1fr); gap: 14px; margin-top: 8px; } .scp-q-order { padding: 16px 18px; } .scp-q-order .oh { display: flex; align-items: center; gap: 10px; padding-bottom: 12px; border-bottom: 1px solid var(--border-soft); } .scp-q-order .oh .info { flex: 1; min-width: 0; } .scp-q-order .oh .info .n { display: block; font-weight: 600; color: var(--fg); font-size: 14px; } .scp-q-order .oh .info .m { display: block; font-size: 11.5px; color: var(--fg-3); margin-top: 2px; } .scp-q-order .oitems { display: flex; flex-direction: column; padding: 10px 0; } .scp-q-order .oi { display: grid; grid-template-columns: 1fr auto auto; gap: 12px; padding: 7px 0; font-size: 12.5px; align-items: center; } .scp-q-order .oi .n { color: var(--fg); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } .scp-q-order .oi .q { color: var(--fg-3); font-size: 11px; } .scp-q-order .oi .v { color: var(--fg-2); font-weight: 600; font-family: 'JetBrains Mono', monospace; } .scp-q-order .of { display: grid; grid-template-columns: 1fr auto auto; gap: 12px; padding-top: 12px; border-top: 1px solid var(--border-soft); align-items: center; font-size: 12px; color: var(--fg-3); } .scp-q-order .of .num { font-family: 'JetBrains Mono', monospace; color: var(--fg-2); display: inline-flex; align-items: center; gap: 4px; } .scp-q-order .of .num svg { color: oklch(0.84 0.14 78); } .scp-q-order .of .total { font-size: 16px; font-weight: 700; color: oklch(0.92 0.14 160); } `; document.head.appendChild(s); })(); window.ScreenQuotationDetail = ScreenQuotationDetail;