All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 2m41s
108 lines
8.6 KiB
TypeScript
108 lines
8.6 KiB
TypeScript
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;
|