Use Tiny compositions for purchase planning
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 2m41s

This commit is contained in:
Cauê Faleiros
2026-08-03 10:22:39 -03:00
parent 1a3fed3dda
commit e993bfdaf3
11 changed files with 544 additions and 40 deletions

107
src/pages/DataHealth.tsx Normal file
View File

@@ -0,0 +1,107 @@
import { useEffect, useMemo, useState } from 'react';
import { AlertTriangle, CheckCircle2, Database, PackageSearch, RefreshCw } from 'lucide-react';
import PaginationControls from '../components/PaginationControls';
import { fetchDataHealth } from '../dataService';
import type { DataHealthIssue, DataHealthSummary } from '../types';
const emptySummary: DataHealthSummary = {
totals: { products: 0, productsWithComposition: 0, productsWithStockLink: 0, materials: 0, materialsWithStock: 0, compositions: 0, components: 0 },
issues: [],
};
const formatNumber = (value: number) => new Intl.NumberFormat('pt-BR').format(value);
const percent = (part: number, total: number) => total ? Math.round((part / total) * 100) : 0;
const severityStyle: Record<DataHealthIssue['severity'], string> = {
critical: 'border-red-400/30 bg-red-400/10 text-red-300',
attention: 'border-amber-400/30 bg-amber-400/10 text-amber-300',
info: 'border-sky-400/30 bg-sky-400/10 text-sky-300',
};
const DataHealth = () => {
const [summary, setSummary] = useState<DataHealthSummary>(emptySummary);
const [isLoading, setIsLoading] = useState(true);
const [filter, setFilter] = useState<'all' | DataHealthIssue['type']>('all');
const [search, setSearch] = useState('');
const [currentPage, setCurrentPage] = useState(1);
const [itemsPerPage, setItemsPerPage] = useState(20);
const load = async () => {
setIsLoading(true);
const next = await fetchDataHealth();
if (next) setSummary(next);
setIsLoading(false);
};
useEffect(() => {
const timer = window.setTimeout(() => { void load(); }, 0);
return () => window.clearTimeout(timer);
}, []);
const filteredIssues = useMemo(() => {
const query = search.trim().toLowerCase();
return summary.issues.filter(issue => (
(filter === 'all' || issue.type === filter) &&
(!query || `${issue.title} ${issue.detail} ${issue.productSku} ${issue.component}`.toLowerCase().includes(query))
));
}, [filter, search, summary.issues]);
const totalPages = Math.ceil(filteredIssues.length / itemsPerPage);
const safePage = Math.min(currentPage, totalPages || 1);
const startIndex = (safePage - 1) * itemsPerPage;
const visibleIssues = filteredIssues.slice(startIndex, startIndex + itemsPerPage);
const count = (type: DataHealthIssue['type']) => summary.issues.filter(issue => issue.type === type).length;
const metrics = [
{ label: 'Produtos com composição', value: percent(summary.totals.productsWithComposition, summary.totals.products), detail: `${formatNumber(summary.totals.productsWithComposition)} de ${formatNumber(summary.totals.products)}` },
{ label: 'Produtos com estoque', value: percent(summary.totals.productsWithStockLink, summary.totals.products), detail: `${formatNumber(summary.totals.productsWithStockLink)} de ${formatNumber(summary.totals.products)}` },
{ label: 'Materiais com estoque', value: percent(summary.totals.materialsWithStock, summary.totals.materials), detail: `${formatNumber(summary.totals.materialsWithStock)} de ${formatNumber(summary.totals.materials)}` },
];
return (
<div className="space-y-6">
<div className="flex flex-col gap-4 md:flex-row md:items-start md:justify-between">
<div>
<h1 className="text-2xl font-bold text-dark-text">Saúde dos Dados</h1>
<p className="mt-1 font-medium text-dark-muted">Confira se composição, vínculo de estoque e consumo estão prontos para orientar compra e corte.</p>
</div>
<button type="button" onClick={() => void load()} className="inline-flex h-10 items-center justify-center gap-2 rounded-xl border border-dark-border bg-dark-card px-4 text-sm font-bold text-dark-text hover:border-brand-primary cursor-pointer">
<RefreshCw className={`h-4 w-4 text-brand-primary ${isLoading ? 'animate-spin' : ''}`} /> Atualizar
</button>
</div>
<div className="grid grid-cols-1 gap-4 md:grid-cols-3">
{metrics.map(metric => (
<div key={metric.label} className="rounded-2xl border border-dark-border bg-dark-card p-5 shadow-sm">
<p className="text-xs font-bold uppercase tracking-widest text-dark-muted">{metric.label}</p>
<div className="mt-3 flex items-end justify-between gap-3"><p className="text-3xl font-bold text-dark-text">{metric.value}%</p><p className="text-xs font-semibold text-dark-muted">{metric.detail}</p></div>
<div className="mt-3 h-2 overflow-hidden rounded-full bg-dark-border"><div className="h-full rounded-full bg-brand-primary" style={{ width: `${metric.value}%` }} /></div>
</div>
))}
</div>
<div className="grid grid-cols-2 gap-3 md:grid-cols-5">
{[
['missing_product_sku', 'Sem SKU'], ['component_without_stock_link', 'Sem vínculo'], ['suspicious_quantity', 'Qtd. suspeita'], ['raw_or_service_structure', 'Matéria-prima/serviço'], ['product_without_composition', 'Sem composição'],
].map(([type, label]) => (
<button key={type} type="button" onClick={() => { setFilter(type as DataHealthIssue['type']); setCurrentPage(1); }} className={`rounded-xl border p-4 text-left transition-colors cursor-pointer ${filter === type ? 'border-brand-primary/50 bg-brand-primary/10' : 'border-dark-border bg-dark-card hover:border-brand-primary/30'}`}>
<p className="text-[10px] font-bold uppercase tracking-widest text-dark-muted">{label}</p>
<p className="mt-2 text-2xl font-bold text-dark-text">{formatNumber(count(type as DataHealthIssue['type']))}</p>
</button>
))}
</div>
<section className="overflow-hidden rounded-2xl border border-dark-border bg-dark-card shadow-sm">
<div className="flex flex-col gap-3 border-b border-dark-border p-4 md:flex-row">
<input value={search} onChange={event => { setSearch(event.target.value); setCurrentPage(1); }} placeholder="Buscar produto, SKU ou componente..." className="h-10 flex-1 rounded-xl border border-dark-border bg-dark-input px-3 text-sm font-semibold text-dark-text outline-none focus:border-brand-primary" />
<select value={filter} onChange={event => { setFilter(event.target.value as typeof filter); setCurrentPage(1); }} className="h-10 rounded-xl border border-dark-border bg-dark-input px-3 text-sm font-bold text-dark-text">
<option value="all">Todas as revisões</option><option value="missing_product_sku">Produtos sem SKU</option><option value="component_without_stock_link">Componentes sem vínculo</option><option value="suspicious_quantity">Quantidades suspeitas</option><option value="raw_or_service_structure">Matéria-prima / serviço</option><option value="product_without_composition">Sem composição</option>
</select>
</div>
{isLoading ? <div className="flex h-48 items-center justify-center text-sm font-bold text-dark-muted"><Database className="mr-2 h-5 w-5 text-brand-primary" /> Verificando dados</div> : !visibleIssues.length ? <div className="flex h-48 flex-col items-center justify-center text-sm font-bold text-emerald-300"><CheckCircle2 className="mb-2 h-7 w-7" /> Nenhuma pendência neste filtro.</div> : (
<><div className="divide-y divide-dark-border">{visibleIssues.map((issue, index) => <div key={`${issue.type}-${issue.productSku}-${issue.component}-${index}`} className="flex gap-3 p-4"><AlertTriangle className="mt-0.5 h-4 w-4 shrink-0 text-amber-300" /><div className="min-w-0 flex-1"><div className="flex flex-wrap items-center gap-2"><p className="font-bold text-dark-text">{issue.title}</p><span className={`rounded-full border px-2 py-0.5 text-[10px] font-bold ${severityStyle[issue.severity]}`}>{issue.severity === 'critical' ? 'Crítico' : issue.severity === 'attention' ? 'Revisar' : 'Informativo'}</span></div><p className="mt-1 text-sm font-medium text-dark-muted">{issue.detail}</p>{(issue.productSku || issue.component) && <p className="mt-1 font-mono text-[11px] text-dark-muted">{[issue.productSku && `SKU ${issue.productSku}`, issue.component && `Comp. ${issue.component}`].filter(Boolean).join(' · ')}</p>}</div><PackageSearch className="h-4 w-4 shrink-0 text-dark-muted" /></div>)}</div>
<PaginationControls totalItems={filteredIssues.length} currentPage={safePage} totalPages={totalPages} pageSize={itemsPerPage} pageSizeOptions={[20, 50, 100]} itemLabel="pendências" pageSizeLabel="itens por página" startIndex={startIndex} endIndex={Math.min(startIndex + itemsPerPage, filteredIssues.length)} onPageChange={setCurrentPage} onPageSizeChange={size => { setItemsPerPage(size); setCurrentPage(1); }} className="border-t border-dark-border px-4 py-3" /></>
)}
</section>
</div>
);
};
export default DataHealth;