// ---- Adapters: DB row <-> UI shape ----
const STATUS_DB_TO_UI = {
ativo: 'Ativo', em_avaliacao: 'Em Avaliação', inativo: 'Inativo', bloqueado: 'Bloqueado',
};
const STATUS_UI_TO_DB = {
'Ativo': 'ativo', 'Em Avaliação': 'em_avaliacao', 'Inativo': 'inativo', 'Bloqueado': 'bloqueado',
};
const toUiSupplier = (row) => ({
...row,
contact: row.contact_name || '—',
whats: row.whatsapp || '—',
response: row.response_rate ?? 0,
avgDelivery: row.avg_delivery || '—',
status: STATUS_DB_TO_UI[row.status] || 'Em Avaliação',
});
function ScreenSuppliers({ setRoute }) {
const { CATEGORIES } = window.SCP;
const [q, setQ] = useState('');
const [cat, setCat] = useState('todas');
const [rows, setRows] = useState([]);
const [loading, setLoading] = useState(true);
const [loadErr, setLoadErr] = useState(null);
const [showNew, setShowNew] = useState(false);
const [highlightId, setHighlightId] = useState(null);
const [openSup, setOpenSup] = useState(null);
useEffect(() => {
let mounted = true;
(async () => {
try {
const data = await window.scpDb.suppliers.list();
if (mounted) setRows(data.map(toUiSupplier));
} catch (e) {
if (mounted) setLoadErr(e.message || 'Falha ao carregar fornecedores');
} finally {
if (mounted) setLoading(false);
}
})();
return () => { mounted = false; };
}, []);
const list = rows.filter(s =>
(cat === 'todas' || (s.cats || []).includes(cat)) &&
(!q || (s.name + (s.cnpj || '') + (s.city || '')).toLowerCase().includes(q.toLowerCase()))
);
const statusTone = { Ativo: 'emerald', 'Em Avaliação': 'amber', Inativo: 'muted', Bloqueado: 'coral' };
const kpis = useMemo(() => {
const ativos = rows.filter(r => r.status === 'Ativo').length;
const avaliacao = rows.filter(r => r.status === 'Em Avaliação').length;
const bloqueados = rows.filter(r => r.status === 'Bloqueado').length;
const scoreSum = rows.reduce((s, r) => s + (Number(r.score) || 0), 0);
const scoreAvg = rows.length ? (scoreSum / rows.length) : 0;
const respSum = rows.reduce((s, r) => s + (Number(r.response) || 0), 0);
const respAvg = rows.length ? Math.round(respSum / rows.length) : 0;
const catCount = {};
rows.forEach(r => (r.cats || []).forEach(k => { catCount[k] = (catCount[k] || 0) + 1; }));
let topCat = null, topCount = 0;
Object.entries(catCount).forEach(([k, n]) => { if (n > topCount) { topCount = n; topCat = k; } });
const topCatLabel = topCat ? (CATEGORIES.find(c => c.key === topCat)?.label || topCat) : '—';
return { ativos, avaliacao, bloqueados, scoreAvg, respAvg, topCatLabel, topCatCount: topCount };
}, [rows]);
const handleCreate = async (data) => {
try {
const payload = {
name: data.name,
cnpj: data.cnpj || null,
city: data.city || null,
address: data.address || null,
contact_name: data.contact || null,
whatsapp: data.whats || null,
email: data.email || null,
cats: data.cats?.length ? data.cats : ['secos'],
score: 75, wins: 0, response_rate: 0,
avg_delivery: null,
status: 'em_avaliacao',
};
const created = await window.scpDb.suppliers.create(payload);
const uiRow = toUiSupplier(created);
setRows(prev => [uiRow, ...prev]);
setShowNew(false);
setHighlightId(uiRow.id);
window.scpToast('Fornecedor cadastrado', { kind: 'emerald', sub: uiRow.name });
setTimeout(() => setHighlightId(null), 2400);
} catch (e) {
window.scpToast('Erro ao cadastrar', { kind: 'coral', sub: e.message || 'Tente novamente' });
}
};
const handleDelete = async (supplier) => {
if (!confirm(`Desativar "${supplier.name}"? Ele sai da lista mas o histórico (cotações, pedidos) fica preservado.`)) return;
try {
await window.scpDb.suppliers.remove(supplier.id);
setRows(prev => prev.filter(r => r.id !== supplier.id));
setOpenSup(null);
window.scpToast('Fornecedor desativado', { kind: 'amber', sub: supplier.name });
} catch (e) {
window.scpToast('Erro ao desativar', { kind: 'coral', sub: e.message || 'Tente novamente' });
}
};
return (
}/>
}/>
}/>
}/>
{loadErr && (
Falha ao carregar fornecedores:{' '}
{loadErr}
Verifique se o schema foi aplicado no Supabase e se o seed-suppliers.sql foi rodado.
)}
{loading && (
Carregando fornecedores…
)}
{!loading && list.length === 0 && (
{rows.length === 0 ? 'Nenhum fornecedor cadastrado ainda.' : 'Nenhum fornecedor com este filtro.'}
{rows.length === 0 && (
} onClick={() => setShowNew(true)}>
Cadastrar primeiro fornecedor
)}
)}
{!loading && list.map(s => (
setOpenSup(s)}
role="button"
tabIndex={0}
onKeyDown={e => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); setOpenSup(s); } }}
>
{s.name}
{s.status}
CNPJ {s.cnpj}
{s.city}
{s.contact} · {s.whats}
{s.cats.map(k => {
const c = CATEGORIES.find(x => x.key === k);
return c ?
{c.label} : null;
})}
Score{s.score}
Vitórias{s.wins}
Resposta{s.response}%
Entrega{s.avgDelivery}
e.stopPropagation()}>
} title="WhatsApp"/>
} title="E-mail"/>
} title="Editar"/>
))}
{showNew &&
setShowNew(false)} onCreate={handleCreate} categories={CATEGORIES} />}
setOpenSup(null)}
eyebrow={openSup?.id}
title={openSup?.name || ''}
headerRight={openSup && {openSup.status}}
footer={openSup && (
<>
} onClick={() => handleDelete(openSup)} style={{ color: 'oklch(0.82 0.14 25)', marginRight: 'auto' }}>Excluir
} onClick={() => {
if (openSup.email && openSup.email !== '—') window.open(`mailto:${openSup.email}?subject=${encodeURIComponent('Contato · ' + window.SCP.COMPANY.name)}`, '_self');
else window.scpToast('Sem e-mail cadastrado', { kind: 'amber', sub: openSup.name });
}}>E-mail
} onClick={() => {
const num = (openSup.whats || '').replace(/\D/g, '');
if (num) window.open(`https://wa.me/55${num}`, '_blank', 'noopener');
else window.scpToast('Sem WhatsApp cadastrado', { kind: 'amber' });
}}>Abrir WhatsApp
>
)}
>
{openSup && }
);
}
function SupplierDetail({ supplier: s, categories }) {
const { BRL } = window.SCP;
const [recentOrders, setRecentOrders] = useState(null);
const [products, setProducts] = useState(null);
useEffect(() => {
let mounted = true;
(async () => {
const [orders, prods] = await Promise.all([
window.scpDb.suppliers.ordersBy(s.id),
window.scpDb.suppliers.productsBy(s.id),
]);
if (!mounted) return;
setRecentOrders(orders);
setProducts(prods);
})();
return () => { mounted = false; };
}, [s.id]);
return (
<>
{s.name}
{s.city} · {s.id}
{s.cats.map(k => {
const c = categories.find(x => x.key === k);
return c ?
{c.label} : null;
})}
Performance
Score
{s.score}
Pontualidade · preço · vitórias
Vitórias
{s.wins}
Cotações vencidas (90d)
Taxa de resposta
{s.response}%
Cotações respondidas no prazo
Prazo médio
{s.avgDelivery}
Entrega após confirmação
Contato
- CNPJ
- {s.cnpj}
- Cidade
- {s.city}
- Endereço
- {s.address || '—'}
- Responsável
- {s.contact}
- WhatsApp
- {s.whats}
- E-mail
- {s.email || '—'}
Produtos fornecidos
{products === null ? (
Carregando…
) : products.length ? (
{products.map(p => (
{p.name}
{p.code} · {p.unit}
{p.last_price ? BRL(p.last_price) : '—'}
))}
) : (
Nenhum produto vinculado a este fornecedor.
)}
Últimos pedidos
{recentOrders === null ? (
Carregando…
) : recentOrders.length ? (
{recentOrders.map(o => (
{o.code}
{new Date(o.emitted_at).toLocaleDateString('pt-BR')}
{BRL(o.total)}
))}
) : (
Sem pedidos recentes deste fornecedor.
)}
>
);
}
function NewSupplierModal({ onClose, onCreate, categories }) {
const [name, setName] = useState('');
const [cnpj, setCnpj] = useState('');
const [city, setCity] = useState('');
const [address, setAddress] = useState('');
const [contact, setContact] = useState('');
const [whats, setWhats] = useState('');
const [email, setEmail] = useState('');
const [cats, setCats] = useState([]);
const emailValid = !email || /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email.trim());
const canSubmit = name.trim().length >= 2 && emailValid;
const toggleCat = (k) => {
setCats(cats.includes(k) ? cats.filter(x => x !== k) : [...cats, k]);
};
const submit = () => {
if (!canSubmit) return;
onCreate({
name: name.trim(), cnpj: cnpj.trim(), city: city.trim(),
address: address.trim(), contact: contact.trim(),
whats: whats.trim(), email: email.trim(), cats,
});
};
return ReactDOM.createPortal((
e.stopPropagation()}>
Novo Fornecedor
} size="sm" onClick={onClose}/>
Será cadastrado como Em Avaliação
Cancelar
}>Cadastrar fornecedor
), document.body);
}
(function() {
if (document.getElementById('scp-sup-css')) return;
const s = document.createElement('style'); s.id = 'scp-sup-css';
s.textContent = `
.scp-sup-list { display: flex; flex-direction: column; }
.scp-sup-row {
display: grid; grid-template-columns: 42px 1.4fr 1fr auto; gap: 16px; padding: 16px 22px;
align-items: center; border-bottom: 1px solid var(--border-soft);
transition: background .12s;
}
.scp-sup-row:hover { background: var(--surface-2); }
.scp-sup-row.clickable { cursor: pointer; transition: background .12s; }
.scp-sup-row.clickable:focus-visible { outline: none; background: var(--surface-2); box-shadow: inset 3px 0 0 oklch(0.78 0.16 160); }
.scp-sup-row:last-child { border-bottom: 0; }
.scp-sup-row .info .head { display: flex; align-items: center; gap: 8px; }
.scp-sup-row .info .n { font-size: 14px; font-weight: 600; color: var(--fg); }
.scp-sup-row .info .meta { display: flex; gap: 16px; flex-wrap: wrap; font-size: 11.5px; color: var(--fg-3); margin-top: 4px; }
.scp-sup-row .info .cats { display: flex; gap: 4px; flex-wrap: wrap; margin-top: 8px; }
.scp-sup-row .stats { display: grid; grid-template-columns: repeat(4, 1fr); gap: 16px; min-width: 320px; }
.scp-sup-row .stats > div { display: flex; flex-direction: column; gap: 2px; align-items: flex-start; }
.scp-sup-row .stats span { font-size: 10px; color: var(--fg-3); text-transform: uppercase; letter-spacing: .08em; font-weight: 600; }
.scp-sup-row .stats strong { font-size: 16px; font-weight: 600; color: var(--fg); }
.scp-sup-row .stats strong.em { color: oklch(0.92 0.14 160); }
.scp-sup-row .stats strong.num { font-family: 'JetBrains Mono', monospace; }
.scp-sup-row .acts { display: flex; gap: 4px; }
.scp-sup-row.just-added {
background: oklch(0.26 0.06 160 / .35);
animation: justAddedPulse 2.4s ease-out;
}
@keyframes justAddedPulse {
0% { background: oklch(0.34 0.10 160 / .55); box-shadow: inset 3px 0 0 oklch(0.78 0.16 160); }
100% { background: oklch(0.205 0.012 250); box-shadow: inset 3px 0 0 transparent; }
}
.scp-modal-sup { width: 640px; }
.scp-modal-sup .scp-modal-body { max-height: 60vh; }
.scp-modal-sup .scp-input { width: 100%; }
`;
document.head.appendChild(s);
})();
window.ScreenSuppliers = ScreenSuppliers;