Files
graphs/src/pages/Supplies.tsx
Cauê Faleiros eb5cc01d56
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 1m25s
Paginate stock inventory tab
2026-07-21 11:30:00 -03:00

1976 lines
89 KiB
TypeScript

import { type FormEvent, useEffect, useMemo, useState } from 'react';
import { Link as RouterLink, Navigate, useOutletContext, useParams } from 'react-router-dom';
import {
AlertTriangle,
ArrowLeft,
ArrowRight,
BarChart3,
Boxes,
ClipboardCheck,
ClipboardList,
Download,
Link as LinkIcon,
Package,
PackageSearch,
Pencil,
RefreshCw,
Repeat2,
Ruler,
Save,
Search,
Scissors,
Truck,
Trash2,
Warehouse,
} from 'lucide-react';
import { buildConsumptionReferencePath } from '../catalogLinks';
import DateRangePicker from '../components/DateRangePicker';
import PaginationControls from '../components/PaginationControls';
import ProductTypeBadge from '../components/ProductTypeBadge';
import { classifyCutFamily } from '../analytics/cutting';
import { adjustSupplyLotInventory, approveSupplyReceipt, consumeSupplyLotForProduction, createSupplyFabricPlan, createSupplyReceipt, deleteSupplyFabricPlan, deleteSupplyReceipt, fetchCuttingSettings, fetchProductAnalytics, fetchStock, fetchSupplySummary } from '../dataService';
import { parseProductName } from '../productParsing';
import { resolveProductType, type ProductTypeKey } from '../productClassification';
import { getPlanningStock } from '../planningStock';
import type { CuttingSettings, DateRange, ProductAnalyticsItem, StockData, SupplyFabricPlan, SupplyLot, SupplyMovement, SupplyPurchaseNeed, SupplyReceipt, SupplySummary } from '../types';
type InventoryTab = 'dashboard' | 'stock' | 'balance' | 'receipts' | 'inventory' | 'movements';
type ReceiptView = 'new' | 'pending' | 'history';
const pageClassName = 'flex w-full flex-col gap-6';
const panelClassName = 'rounded-2xl border border-dark-border bg-dark-card shadow-sm';
const buttonClassName = 'inline-flex h-10 items-center justify-center gap-2 rounded-lg border border-dark-border bg-dark-input px-3 text-sm font-bold text-dark-text transition-colors hover:border-brand-primary cursor-pointer';
const inputClassName = 'h-10 w-full rounded-lg border border-dark-border bg-dark-input px-3 text-sm font-semibold text-dark-text outline-none placeholder:text-dark-muted focus:border-brand-primary';
const emptyStateClassName = 'flex min-h-[190px] flex-col items-center justify-center gap-2 px-4 py-10 text-center';
const emptySupplySummary: SupplySummary = {
receipts: [],
lots: [],
movements: [],
fabricPlans: [],
purchaseNeeds: [],
stats: {
totalQuantityKg: 0,
activeLots: 0,
rolls: 0,
alerts: 0,
pendingReceipts: 0,
approvedReceipts: 0,
},
};
const formatNumber = (value: number, maximumFractionDigits = 2) => (
new Intl.NumberFormat('pt-BR', { maximumFractionDigits }).format(value)
);
const parseDecimal = (value: string) => {
const number = Number(value.replace(',', '.'));
return Number.isFinite(number) ? number : 0;
};
const formatDateTime = (value: string | null) => {
if (!value) return '-';
return new Intl.DateTimeFormat('pt-BR', {
day: '2-digit',
month: '2-digit',
year: '2-digit',
hour: '2-digit',
minute: '2-digit',
}).format(new Date(value));
};
const normalizeSearch = (value: string) => value.normalize('NFD').replace(/[\u0300-\u036f]/g, '').toLowerCase();
const exportCsv = (filename: string, rows: Array<Record<string, string | number | null>>) => {
if (!rows.length) return;
const headers = Object.keys(rows[0]);
const escapeCell = (value: string | number | null) => `"${String(value ?? '').replace(/"/g, '""')}"`;
const csv = [
headers.join(','),
...rows.map(row => headers.map(header => escapeCell(row[header])).join(',')),
].join('\n');
const blob = new Blob([csv], { type: 'text/csv;charset=utf-8;' });
const url = URL.createObjectURL(blob);
const anchor = document.createElement('a');
anchor.href = url;
anchor.download = filename;
anchor.click();
URL.revokeObjectURL(url);
};
const inventoryTabs: Array<{ id: InventoryTab; name: string; icon: typeof BarChart3 }> = [
{ id: 'dashboard', name: 'Dashboard', icon: Warehouse },
{ id: 'stock', name: 'Estoque', icon: PackageSearch },
{ id: 'balance', name: 'Saldo', icon: BarChart3 },
{ id: 'receipts', name: 'Recebimentos', icon: Truck },
{ id: 'inventory', name: 'Inventário', icon: ClipboardCheck },
{ id: 'movements', name: 'Movimentações', icon: Repeat2 },
];
const receiptCategories: Array<{ name: string; icon: typeof Package }> = [
{ name: 'Malha / Tecido', icon: Ruler },
{ name: 'Embalagem', icon: Package },
{ name: 'Material de Limpeza', icon: Boxes },
{ name: 'Acessórios / Botões', icon: Warehouse },
{ name: 'Etiquetas', icon: LinkIcon },
{ name: 'Outro material', icon: ClipboardList },
];
type DemandQueue = 'apparel' | 'accessories' | 'inputs' | 'review' | 'dead';
type DemandRow = ProductAnalyticsItem & {
productType: ProductTypeKey;
color: string;
size: string;
dailySales: number;
daysOfCover: number | null;
targetDemand: number;
suggestedUnits: number;
missingData: string[];
queue: DemandQueue;
};
const demandQueueLabels: Record<DemandQueue, string> = {
apparel: 'Cortar / Repor',
accessories: 'Repor acessórios',
inputs: 'Comprar insumos',
review: 'Revisar dados',
dead: 'Estoque parado',
};
const demandQueueDescriptions: Record<DemandQueue, string> = {
apparel: 'Produtos acabados com demanda ativa e cobertura abaixo da meta.',
accessories: 'Acessórios acabados com demanda ativa e cobertura abaixo da meta.',
inputs: 'Embalagens, matérias-primas e insumos de produção com necessidade de reposição.',
review: 'SKUs com informações pendentes para qualificar o planejamento.',
dead: 'Itens com saldo disponível e baixa movimentação no período.',
};
const directPurchaseTypes = new Set<ProductTypeKey>(['packaging', 'dtf_input', 'raw_material', 'machine_part']);
const planningReviewTypes = new Set<ProductTypeKey>(['unknown', 'kit_bundle']);
const Header = ({ title, subtitle, backTo }: { title: string; subtitle: string; backTo?: string }) => (
<div className="flex flex-col gap-3">
{backTo && (
<RouterLink to={backTo} className="inline-flex w-fit items-center gap-2 text-sm font-bold text-dark-muted transition-colors hover:text-dark-text">
<ArrowLeft className="h-4 w-4" />
Suprimentos
</RouterLink>
)}
<div>
<h1 className="mb-2 text-2xl font-bold text-zinc-900 dark:text-dark-text">{title}</h1>
<p className="font-medium text-zinc-500 dark:text-dark-muted">{subtitle}</p>
</div>
</div>
);
const ModuleCard = ({
title,
description,
icon: Icon,
to,
}: {
title: string;
description: string;
icon: typeof Package;
to: string;
}) => {
return (
<RouterLink to={to} className={`${panelClassName} flex min-h-32 items-start gap-4 p-5 transition-colors hover:border-brand-primary`}>
<div className="flex h-11 w-11 items-center justify-center rounded-xl border border-dark-border bg-dark-input text-brand-primary">
<Icon className="h-5 w-5" />
</div>
<div className="min-w-0 flex-1">
<div className="flex items-start justify-between gap-3">
<h2 className="text-base font-bold text-dark-text">{title}</h2>
<ArrowRight className="mt-0.5 h-4 w-4 shrink-0 text-dark-muted" />
</div>
<p className="mt-2 text-sm font-semibold text-dark-muted">{description}</p>
</div>
</RouterLink>
);
};
const SuppliesHub = () => (
<div className={pageClassName}>
<Header title="Suprimentos" subtitle="Corte, estoque, malha e compras em um fluxo operacional." />
<div className="grid grid-cols-1 gap-4 lg:grid-cols-2">
<ModuleCard title="Planejamento Operacional" description="Prioridades de reposição, compra e revisão calculadas por demanda, estoque e cobertura." icon={PackageSearch} to="/supplies/current-planning" />
<ModuleCard title="Planejamento de Corte" description="Ruptura por cor e tamanho, montagem do corte e prioridade por cobertura." icon={Scissors} to="/cutting" />
<ModuleCard title="Controle de Estoque" description="Saldo, lotes, recebimentos, inventário e movimentações." icon={Package} to="/supplies/inventory" />
<ModuleCard title="Ordens de Produção" description="Ordens geradas pelo corte e acompanhamento de produção." icon={ClipboardList} to="/production-orders" />
<ModuleCard title="Planejamento de Malha" description="Fila de matéria-prima para compra, recebimento e abastecimento do corte." icon={Ruler} to="/supplies/fabric-planning" />
<ModuleCard title="Necessidade de Compra" description="Itens abaixo do mínimo e necessidade projetada para compra." icon={BarChart3} to="/supplies/purchase-needs" />
</div>
</div>
);
const getRangeDays = (range: DateRange) => {
const start = new Date(range.start);
const end = new Date(range.end);
start.setHours(0, 0, 0, 0);
end.setHours(0, 0, 0, 0);
return Math.max(1, Math.round((end.getTime() - start.getTime()) / 86_400_000) + 1);
};
const formatDays = (value: number | null) => {
if (value === null) return '-';
if (value > 999) return '999+ dias';
return `${formatNumber(value, value < 10 ? 1 : 0)} dias`;
};
const buildDemandRow = (
product: ProductAnalyticsItem,
settings: CuttingSettings,
rangeDays: number,
targetCoverageDays: number
): DemandRow => {
const override = settings.productOverrides[product.id];
const metadata = parseProductName(product.name);
const productType = resolveProductType(product.name, override);
const color = override?.color || metadata.color;
const size = (override?.size || metadata.size).toUpperCase();
const dailySales = product.quantitySold / rangeDays;
const planningStock = getPlanningStock(product.stock);
const daysOfCover = dailySales > 0 ? planningStock / dailySales : null;
const targetDemand = dailySales * targetCoverageDays;
const suggestedUnits = Math.max(0, Math.ceil(targetDemand - planningStock));
const missingData: string[] = [];
if (planningReviewTypes.has(productType)) missingData.push('tipo');
if (productType === 'finished_apparel') {
if (!color) missingData.push('cor');
if (!size) missingData.push('tamanho');
if ((override?.familyKey || classifyCutFamily(metadata.baseName).key) === 'OUTROS') missingData.push('família');
}
let queue: DemandQueue = 'review';
if (product.stock > 0 && (dailySales === 0 || (daysOfCover !== null && daysOfCover > 120))) {
queue = 'dead';
} else if (missingData.length) {
queue = 'review';
} else if (productType === 'finished_apparel') {
queue = 'apparel';
} else if (productType === 'finished_accessory') {
queue = 'accessories';
} else if (directPurchaseTypes.has(productType)) {
queue = 'inputs';
}
return {
...product,
productType,
color,
size,
dailySales,
daysOfCover,
targetDemand,
suggestedUnits,
missingData,
queue,
};
};
const DemandPlanningScreen = () => {
const { dateRange, setDateRange } = useOutletContext<{
dateRange: DateRange,
setDateRange: (range: DateRange) => void
}>();
const [products, setProducts] = useState<ProductAnalyticsItem[]>([]);
const [settings, setSettings] = useState<CuttingSettings>({ familyYields: {}, productOverrides: {} });
const [isLoading, setIsLoading] = useState(true);
const [queue, setQueue] = useState<DemandQueue>('apparel');
const [search, setSearch] = useState('');
const [targetCoverageDays, setTargetCoverageDays] = useState(30);
const [currentPage, setCurrentPage] = useState(1);
const [itemsPerPage, setItemsPerPage] = useState(10);
useEffect(() => {
let isMounted = true;
const load = async () => {
setIsLoading(true);
const [productData, planningSettings] = await Promise.all([
fetchProductAnalytics(dateRange),
fetchCuttingSettings(),
]);
if (isMounted) {
setProducts(productData);
setSettings(planningSettings);
setIsLoading(false);
}
};
void load();
return () => {
isMounted = false;
};
}, [dateRange]);
const rows = useMemo(() => {
const rangeDays = getRangeDays(dateRange);
return products.map(product => buildDemandRow(product, settings, rangeDays, targetCoverageDays));
}, [dateRange, products, settings, targetCoverageDays]);
const queueRows = useMemo(() => {
const normalizedSearch = normalizeSearch(search);
return rows
.filter(row => row.queue === queue)
.filter(row => {
if (queue === 'dead') return row.stock > 0;
if (queue === 'review') return row.quantitySold > 0 || row.stock > 0;
return row.dailySales > 0 && (row.suggestedUnits > 0 || (row.daysOfCover !== null && row.daysOfCover <= targetCoverageDays));
})
.filter(row => (
!normalizedSearch || normalizeSearch(`${row.id} ${row.name} ${row.color} ${row.size} ${row.missingData.join(' ')}`).includes(normalizedSearch)
))
.sort((a, b) => {
if (queue === 'dead') {
return (b.stock * b.lastPrice) - (a.stock * a.lastPrice);
}
if (queue === 'review') {
if (b.quantitySold !== a.quantitySold) return b.quantitySold - a.quantitySold;
return b.revenue - a.revenue;
}
if (b.suggestedUnits !== a.suggestedUnits) return b.suggestedUnits - a.suggestedUnits;
return b.dailySales - a.dailySales;
});
}, [queue, rows, search, targetCoverageDays]);
const queueStats = useMemo(() => {
return (Object.keys(demandQueueLabels) as DemandQueue[]).reduce<Record<DemandQueue, number>>((acc, item) => {
acc[item] = rows.filter(row => {
if (row.queue !== item) return false;
if (item === 'dead') return row.stock > 0;
if (item === 'review') return row.quantitySold > 0 || row.stock > 0;
return row.dailySales > 0 && (row.suggestedUnits > 0 || (row.daysOfCover !== null && row.daysOfCover <= targetCoverageDays));
}).length;
return acc;
}, { apparel: 0, accessories: 0, inputs: 0, review: 0, dead: 0 });
}, [rows, targetCoverageDays]);
const totalPages = Math.ceil(queueRows.length / itemsPerPage);
const safeCurrentPage = Math.min(currentPage, totalPages || 1);
const startIndex = (safeCurrentPage - 1) * itemsPerPage;
const paginatedRows = queueRows.slice(startIndex, startIndex + itemsPerPage);
const suggestedTotal = queueRows.reduce((total, row) => total + (queue === 'dead' ? row.stock * row.lastPrice : row.suggestedUnits), 0);
return (
<div className={pageClassName}>
<div className="grid grid-cols-1 gap-4 2xl:grid-cols-[minmax(520px,1fr)_auto] 2xl:items-start">
<Header title="Planejamento Operacional" subtitle="Prioridades de reposição, compra e revisão calculadas por demanda, estoque e cobertura." backTo="/supplies" />
<DateRangePicker
dateRange={dateRange}
onChange={(range) => {
setDateRange(range);
setCurrentPage(1);
}}
/>
</div>
<div className="grid grid-cols-1 gap-3 md:grid-cols-2 xl:grid-cols-5">
{(Object.keys(demandQueueLabels) as DemandQueue[]).map(item => (
<button
key={item}
type="button"
onClick={() => {
setQueue(item);
setCurrentPage(1);
}}
className={`rounded-2xl border p-4 text-left transition-colors cursor-pointer ${
queue === item ? 'border-brand-primary/45 bg-brand-primary/10' : 'border-dark-border bg-dark-card hover:border-brand-primary/35'
}`}
>
<p className="text-xs font-bold uppercase tracking-widest text-dark-muted">{demandQueueLabels[item]}</p>
<p className="mt-2 text-3xl font-bold text-dark-text">{formatNumber(queueStats[item], 0)}</p>
<p className="mt-1 line-clamp-2 text-xs font-semibold text-dark-muted">{demandQueueDescriptions[item]}</p>
</button>
))}
</div>
<div className="grid grid-cols-1 gap-3 rounded-2xl border border-dark-border bg-dark-card p-4 shadow-sm xl:grid-cols-[minmax(360px,1fr)_auto] xl:items-center">
<div className="flex flex-col gap-4 xl:flex-row xl:items-center xl:justify-between">
<div>
<h2 className="text-base font-bold text-dark-text">{demandQueueLabels[queue]}</h2>
<p className="mt-1 text-sm font-semibold text-dark-muted">{demandQueueDescriptions[queue]}</p>
</div>
</div>
<div className="flex flex-col gap-2 sm:flex-row xl:justify-end">
<label className="relative min-w-72">
<Search className="pointer-events-none absolute left-3 top-3 h-4 w-4 text-dark-muted" />
<input
value={search}
onChange={(event) => {
setSearch(event.target.value);
setCurrentPage(1);
}}
className={`${inputClassName} pl-9`}
placeholder="Buscar SKU, produto, cor..."
/>
</label>
<select
value={targetCoverageDays}
onChange={(event) => {
setTargetCoverageDays(Number(event.target.value));
setCurrentPage(1);
}}
className={inputClassName}
title="Cobertura alvo"
>
{[15, 30, 45, 60].map(days => <option key={days} value={days}>{days} dias</option>)}
</select>
<button
type="button"
onClick={() => exportCsv('planejamento-dados-atuais.csv', queueRows.map(row => ({
fila: demandQueueLabels[queue],
sku: row.id,
produto: row.name,
tipo: row.productType,
cor: row.color,
tamanho: row.size,
vendido: row.quantitySold,
estoque: row.stock,
media_diaria: row.dailySales.toFixed(2).replace('.', ','),
cobertura_dias: row.daysOfCover === null ? '' : row.daysOfCover.toFixed(1).replace('.', ','),
sugestao_unidades: row.suggestedUnits,
pendencias: row.missingData.join(' | '),
})))}
className={buttonClassName}
>
<Download className="h-4 w-4" />
CSV
</button>
</div>
</div>
<div className="grid grid-cols-1 gap-3 md:grid-cols-3">
<div className="rounded-xl border border-dark-border bg-dark-input/35 p-4">
<p className="text-xs font-bold uppercase tracking-widest text-dark-muted">Itens na fila</p>
<p className="mt-2 text-2xl font-bold text-dark-text">{formatNumber(queueRows.length, 0)}</p>
</div>
<div className="rounded-xl border border-dark-border bg-dark-input/35 p-4">
<p className="text-xs font-bold uppercase tracking-widest text-dark-muted">{queue === 'dead' ? 'Valor em estoque' : 'Unidades sugeridas'}</p>
<p className="mt-2 text-2xl font-bold text-dark-text">
{queue === 'dead'
? new Intl.NumberFormat('pt-BR', { style: 'currency', currency: 'BRL' }).format(suggestedTotal)
: `${formatNumber(suggestedTotal, 0)} un.`}
</p>
</div>
<div className="rounded-xl border border-dark-border bg-dark-input/35 p-4">
<p className="text-xs font-bold uppercase tracking-widest text-dark-muted">Cobertura alvo</p>
<p className="mt-2 text-2xl font-bold text-dark-text">{targetCoverageDays} dias</p>
</div>
</div>
{isLoading ? (
<div className={`${panelClassName} ${emptyStateClassName}`}>
<PackageSearch className="h-8 w-8 text-brand-primary" />
<h3 className="text-base font-bold text-dark-text">Carregando dados de produtos...</h3>
</div>
) : queueRows.length ? (
<div className="overflow-hidden rounded-2xl border border-dark-border bg-dark-card shadow-sm">
<div className="overflow-x-auto">
<table className="w-full min-w-[1080px] table-fixed border-collapse">
<colgroup>
<col className="w-[120px]" />
<col />
<col className="w-[140px]" />
<col className="w-[120px]" />
<col className="w-[120px]" />
<col className="w-[120px]" />
<col className="w-[130px]" />
<col className="w-[100px]" />
</colgroup>
<thead className="border-b border-dark-border bg-dark-header text-xs font-bold uppercase tracking-widest text-dark-muted">
<tr>
<th scope="col" className="px-4 py-3 text-left">SKU</th>
<th scope="col" className="px-4 py-3 text-left">Produto</th>
<th scope="col" className="px-4 py-3 text-left">Tipo</th>
<th scope="col" className="px-4 py-3 text-right">Média/dia</th>
<th scope="col" className="px-4 py-3 text-right">Estoque</th>
<th scope="col" className="px-4 py-3 text-right">Cobertura</th>
<th scope="col" className="px-4 py-3 text-right">{queue === 'dead' ? 'Valor estoque' : 'Sugestão'}</th>
<th scope="col" className="px-4 py-3 text-right">Ações</th>
</tr>
</thead>
<tbody className="divide-y divide-dark-border">
{paginatedRows.map(row => (
<tr key={row.id} className="transition-colors hover:bg-dark-input/50">
<td className="px-4 py-2.5 font-mono text-[11px] text-dark-muted">#{row.id}</td>
<td className="px-4 py-2.5">
<p className="truncate text-sm font-bold text-dark-text" title={row.name}>{row.name}</p>
<p className="mt-1 truncate text-xs font-semibold text-dark-muted">
Cor: {row.color || '-'} · Tam.: {row.size || '-'}
{row.missingData.length ? ` · Falta: ${row.missingData.join(', ')}` : ''}
</p>
</td>
<td className="px-4 py-2.5"><ProductTypeBadge type={row.productType} /></td>
<td className="px-4 py-2.5 text-right text-sm font-bold text-dark-text">{formatNumber(row.dailySales, 2)}</td>
<td className="px-4 py-2.5 text-right text-sm font-bold text-dark-text">{formatNumber(row.stock, 0)} un.</td>
<td className="px-4 py-2.5 text-right text-sm font-bold text-dark-text">{formatDays(row.daysOfCover)}</td>
<td className={`px-4 py-2.5 text-right text-sm font-bold ${queue === 'dead' ? 'text-amber-300' : row.suggestedUnits > 0 ? 'text-amber-300' : 'text-emerald-300'}`}>
{queue === 'dead'
? new Intl.NumberFormat('pt-BR', { style: 'currency', currency: 'BRL' }).format(row.stock * row.lastPrice)
: `${formatNumber(row.suggestedUnits, 0)} un.`}
</td>
<td className="px-4 py-2.5 text-right">
<div className="flex justify-end gap-2">
{queue === 'review' && (
<RouterLink
to="/planning-issues"
className="inline-flex h-8 w-8 items-center justify-center rounded-lg bg-dark-input text-dark-text transition-colors hover:bg-dark-border"
title="Abrir dados pendentes"
aria-label="Abrir dados pendentes"
>
<Pencil className="h-3.5 w-3.5" />
</RouterLink>
)}
<RouterLink
to={`/products/${row.id}`}
className="inline-flex h-8 w-8 items-center justify-center rounded-lg bg-brand-primary/10 text-brand-primary transition-opacity hover:opacity-80"
title={`Ver SKU ${row.id}`}
aria-label={`Ver SKU ${row.id}`}
>
<ArrowRight className="h-3.5 w-3.5" />
</RouterLink>
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
<PaginationControls
totalItems={queueRows.length}
currentPage={safeCurrentPage}
totalPages={totalPages}
pageSize={itemsPerPage}
pageSizeOptions={[10, 20, 50, 100]}
itemLabel="SKUs"
pageSizeLabel="itens por página"
startIndex={startIndex}
endIndex={Math.min(startIndex + itemsPerPage, queueRows.length)}
onPageChange={setCurrentPage}
onPageSizeChange={(pageSize) => {
setItemsPerPage(pageSize);
setCurrentPage(1);
}}
className="border-t border-dark-border px-4 py-3"
/>
</div>
) : (
<div className={`${panelClassName} ${emptyStateClassName}`}>
<PackageSearch className="h-8 w-8 text-brand-primary" />
<h3 className="text-base font-bold text-dark-text">Nada nesta fila</h3>
<p className="text-sm font-semibold text-dark-muted">Ajuste a busca, período ou cobertura alvo.</p>
</div>
)}
</div>
);
};
const StatGrid = ({ summary }: { summary: SupplySummary }) => {
const stats = [
{ label: 'Total em estoque', value: `${formatNumber(summary.stats.totalQuantityKg)} kg` },
{ label: 'Lotes ativos', value: `${summary.stats.activeLots}` },
{ label: 'Rolos', value: `${formatNumber(summary.stats.rolls, 0)}` },
{ label: 'Alertas', value: `${summary.stats.alerts}` },
];
return (
<div className="grid grid-cols-2 gap-3 lg:grid-cols-4">
{stats.map(stat => (
<div key={stat.label} className="rounded-xl border border-dark-border bg-dark-input/40 px-4 py-4 text-center">
<p className="text-2xl font-bold text-dark-text">{stat.value}</p>
<p className="mt-1 text-xs font-semibold text-dark-muted">{stat.label}</p>
</div>
))}
</div>
);
};
const InventoryDashboard = ({ summary, onNewReceipt }: { summary: SupplySummary; onNewReceipt: () => void }) => {
const categories = summary.lots.reduce<Record<string, { quantity: number; unit: string; lots: number }>>((acc, lot) => {
const current = acc[lot.category] || { quantity: 0, unit: lot.unit, lots: 0 };
acc[lot.category] = {
quantity: current.quantity + lot.quantity,
unit: current.unit === lot.unit ? lot.unit : 'mix',
lots: current.lots + 1,
};
return acc;
}, {});
const latestReceipts = summary.receipts.slice(0, 4);
return (
<div className="space-y-4">
<StatGrid summary={summary} />
<div className={`${panelClassName} p-5`}>
<h2 className="text-base font-bold text-dark-text">Por categoria de material</h2>
{Object.keys(categories).length ? (
<div className="mt-4 grid grid-cols-1 gap-3 md:grid-cols-2">
{Object.entries(categories).map(([category, data]) => (
<div key={category} className="rounded-xl border border-dark-border bg-dark-input/35 p-4">
<p className="text-sm font-bold text-dark-text">{category}</p>
<p className="mt-2 text-2xl font-bold text-dark-text">{formatNumber(data.quantity)} {data.unit}</p>
<p className="mt-1 text-xs font-semibold text-dark-muted">{data.lots} lote(s) ativo(s)</p>
</div>
))}
</div>
) : (
<div className={emptyStateClassName}>
<Warehouse className="h-8 w-8 text-brand-primary" />
<h3 className="text-base font-bold text-dark-text">Nenhuma categoria com saldo</h3>
<p className="text-sm font-semibold text-dark-muted">Registre entradas para agrupar o estoque por tipo de material.</p>
</div>
)}
</div>
<div className={`${panelClassName} p-5`}>
<h2 className="flex items-center gap-2 text-base font-bold text-dark-text"><AlertTriangle className="h-4 w-4 text-yellow-400" /> Alertas de estoque</h2>
<p className="mt-4 text-sm font-semibold text-dark-muted">Nenhum alerta no momento.</p>
</div>
<div className={`${panelClassName} p-5`}>
<div className="flex items-center justify-between gap-3">
<h2 className="text-base font-bold text-dark-text">Últimas entradas</h2>
<button type="button" onClick={onNewReceipt} className={buttonClassName}>Nova entrada</button>
</div>
{latestReceipts.length ? (
<div className="mt-4 divide-y divide-dark-border overflow-hidden rounded-xl border border-dark-border">
{latestReceipts.map(receipt => (
<div key={receipt.id} className="grid grid-cols-1 gap-2 bg-dark-input/25 p-3 md:grid-cols-[1fr_auto_auto] md:items-center">
<div>
<p className="text-sm font-bold text-dark-text">{receipt.product}</p>
<p className="text-xs font-semibold text-dark-muted">{receipt.category} · {formatDateTime(receipt.createdAt)}</p>
</div>
<p className="text-sm font-bold text-dark-text">{formatNumber(receipt.quantity)} {receipt.unit}</p>
<span className={`w-fit rounded-full border px-2.5 py-1 text-xs font-bold ${
receipt.status === 'approved'
? 'border-emerald-400/30 bg-emerald-400/10 text-emerald-300'
: 'border-amber-400/30 bg-amber-400/10 text-amber-300'
}`}>
{receipt.status === 'approved' ? 'Aprovado' : 'Pendente'}
</span>
</div>
))}
</div>
) : (
<p className="mt-5 text-sm font-semibold text-dark-muted">Nenhuma entrada registrada.</p>
)}
</div>
</div>
);
};
const BalanceTab = ({ summary, onRefresh }: { summary: SupplySummary; onRefresh: () => void }) => {
const [search, setSearch] = useState('');
const [category, setCategory] = useState('all');
const [supplier, setSupplier] = useState('all');
const categories = Array.from(new Set(summary.lots.map(lot => lot.category))).sort();
const suppliers = Array.from(new Set(summary.lots.map(lot => lot.supplier || 'Sem fornecedor'))).sort();
const normalizedSearch = normalizeSearch(search);
const visibleLots = summary.lots.filter(lot => {
const lotSupplier = lot.supplier || 'Sem fornecedor';
const matchesSearch = !normalizedSearch || normalizeSearch(`${lot.product} ${lot.category} ${lotSupplier} ${lot.invoice} ${lot.id}`).includes(normalizedSearch);
const matchesCategory = category === 'all' || lot.category === category;
const matchesSupplier = supplier === 'all' || lotSupplier === supplier;
return matchesSearch && matchesCategory && matchesSupplier;
});
return (
<div className="space-y-4">
<div className="flex flex-wrap gap-2">
<button
type="button"
onClick={() => exportCsv('saldo-suprimentos.csv', visibleLots.map(lot => ({
lote: lot.id,
material: lot.product,
categoria: lot.category,
quantidade: lot.quantity,
unidade: lot.unit,
fornecedor: lot.supplier || 'Sem fornecedor',
nota_fiscal: lot.invoice || '',
criado_em: lot.createdAt,
})))}
className={buttonClassName}
>
<Download className="h-4 w-4" /> Exportar saldo CSV
</button>
<button type="button" onClick={onRefresh} className={buttonClassName}><RefreshCw className="h-4 w-4" /> Atualizar saldo</button>
</div>
<div className={`${panelClassName} p-4`}>
<div className="grid grid-cols-1 gap-3 lg:grid-cols-[1fr_220px_220px]">
<label className="relative">
<Search className="pointer-events-none absolute left-3 top-3 h-4 w-4 text-dark-muted" />
<input value={search} onChange={(event) => setSearch(event.target.value)} className={`${inputClassName} pl-9`} placeholder="Buscar por SKU, lote, tipo ou fornecedor..." />
</label>
<select value={category} onChange={(event) => setCategory(event.target.value)} className={inputClassName}>
<option value="all">Todos os tipos</option>
{categories.map(item => <option key={item} value={item}>{item}</option>)}
</select>
<select value={supplier} onChange={(event) => setSupplier(event.target.value)} className={inputClassName}>
<option value="all">Todos os fornecedores</option>
{suppliers.map(item => <option key={item} value={item}>{item}</option>)}
</select>
</div>
</div>
<div className={`${panelClassName} p-5`}>
<StatGrid summary={summary} />
</div>
<div className={`${panelClassName} p-5`}>
<div className="flex items-center justify-between gap-3">
<h2 className="text-base font-bold text-dark-text">Saldo por tipo</h2>
<span className="text-xs font-semibold text-dark-muted">Clique para expandir lotes</span>
</div>
{visibleLots.length ? (
<div className="mt-4 overflow-hidden rounded-xl border border-dark-border">
<div className="hidden grid-cols-[1.2fr_1fr_120px_1fr_140px] border-b border-dark-border bg-dark-input/40 px-4 py-3 text-xs font-bold uppercase tracking-widest text-dark-muted md:grid">
<span>Material</span>
<span>Categoria</span>
<span>Saldo</span>
<span>Fornecedor</span>
<span>Lote</span>
</div>
<div className="divide-y divide-dark-border">
{visibleLots.map(lot => (
<div key={lot.id} className="grid grid-cols-1 gap-2 bg-dark-card px-4 py-3 md:grid-cols-[1.2fr_1fr_120px_1fr_140px] md:items-center">
<p className="text-sm font-bold text-dark-text">{lot.product}</p>
<p className="text-sm font-semibold text-dark-muted">{lot.category}</p>
<p className="text-sm font-bold text-dark-text">{formatNumber(lot.quantity)} {lot.unit}</p>
<p className="text-sm font-semibold text-dark-muted">{lot.supplier || 'Sem fornecedor'}</p>
<p className="text-xs font-bold text-dark-muted">#{lot.id} · {lot.invoice || 'sem NF'}</p>
</div>
))}
</div>
</div>
) : (
<div className={emptyStateClassName}>
<Package className="h-8 w-8 text-brand-primary" />
<h3 className="text-base font-bold text-dark-text">{summary.lots.length ? 'Nenhum lote encontrado' : 'Nenhum item em estoque'}</h3>
<p className="text-sm font-semibold text-dark-muted">{summary.lots.length ? 'Ajuste a busca ou os filtros.' : 'Registre entradas para ver o saldo aqui.'}</p>
</div>
)}
</div>
</div>
);
};
const ReceiptList = ({
receipts,
emptyTitle,
onApprove,
onRemove,
}: {
receipts: SupplyReceipt[];
emptyTitle: string;
onApprove: (receiptId: number) => void;
onRemove: (receiptId: number) => void;
}) => (
<div className={`${panelClassName} overflow-hidden`}>
{receipts.length ? (
<div className="divide-y divide-dark-border">
{receipts.map(receipt => (
<div key={receipt.id} className="grid grid-cols-1 gap-3 p-4 lg:grid-cols-[1.2fr_120px_1fr_100px_auto] lg:items-center">
<div>
<p className="font-bold text-dark-text">{receipt.product}</p>
<p className="text-xs font-semibold text-dark-muted">{receipt.category} · {receipt.invoice ? `NF ${receipt.invoice}` : 'sem NF'} · {formatDateTime(receipt.createdAt)}</p>
</div>
<p className="text-sm font-bold text-dark-text">{formatNumber(receipt.quantity)} {receipt.unit}</p>
<p className="text-sm font-semibold text-dark-muted">{receipt.supplier}</p>
<span className={`w-fit rounded-full border px-2.5 py-1 text-xs font-bold ${
receipt.status === 'approved'
? 'border-emerald-400/30 bg-emerald-400/10 text-emerald-300'
: 'border-amber-400/30 bg-amber-400/10 text-amber-300'
}`}>
{receipt.status === 'approved' ? 'Aprovado' : 'Pendente'}
</span>
<div className="flex flex-wrap gap-2 lg:justify-end">
{receipt.status === 'pending' && (
<button type="button" onClick={() => onApprove(receipt.id)} className={buttonClassName}>
Aprovar
</button>
)}
<button type="button" onClick={() => onRemove(receipt.id)} className="inline-flex h-10 items-center justify-center rounded-lg px-3 text-sm font-bold text-red-400 transition-colors hover:bg-red-500/10 cursor-pointer">
Remover
</button>
</div>
</div>
))}
</div>
) : (
<div className={emptyStateClassName}>
<Truck className="h-8 w-8 text-brand-primary" />
<h3 className="text-base font-bold text-dark-text">{emptyTitle}</h3>
<p className="text-sm font-semibold text-dark-muted">Registre um recebimento para preencher esta lista.</p>
</div>
)}
</div>
);
const ReceiptsTab = ({
receipts,
onCreate,
onApprove,
onRemove,
isBusy,
}: {
receipts: SupplyReceipt[];
onCreate: (payload: { category: string; product: string; quantity: number; unit: string; supplier: string; invoice: string; notes: string }) => Promise<void>;
onApprove: (receiptId: number) => Promise<void>;
onRemove: (receiptId: number) => Promise<void>;
isBusy: boolean;
}) => {
const [activeView, setActiveView] = useState<ReceiptView>('new');
const [selectedCategory, setSelectedCategory] = useState(receiptCategories[0].name);
const [form, setForm] = useState({
product: '',
quantity: '',
unit: 'kg',
supplier: '',
invoice: '',
notes: '',
});
const pendingReceipts = receipts.filter(receipt => receipt.status === 'pending');
const visibleReceipts = activeView === 'pending' ? pendingReceipts : receipts;
const handleReceiptSubmit = async (event: FormEvent<HTMLFormElement>) => {
event.preventDefault();
const product = form.product.trim();
const quantity = parseDecimal(form.quantity);
if (!product || quantity <= 0) return;
await onCreate({
category: selectedCategory,
product,
quantity,
unit: form.unit,
supplier: form.supplier.trim() || 'Sem fornecedor',
invoice: form.invoice.trim(),
notes: form.notes.trim(),
});
setForm({ product: '', quantity: '', unit: 'kg', supplier: '', invoice: '', notes: '' });
setActiveView('pending');
};
return (
<div className="space-y-4">
<div className="flex flex-wrap gap-2">
{[
{ id: 'new', label: 'Novo recebimento' },
{ id: 'pending', label: `Pendentes (${pendingReceipts.length})` },
{ id: 'history', label: `Histórico (${receipts.length})` },
].map(item => (
<button
key={item.id}
type="button"
onClick={() => setActiveView(item.id as ReceiptView)}
className={`${buttonClassName} ${activeView === item.id ? 'border-brand-primary bg-brand-primary text-brand-contrast' : ''}`}
>
{item.label}
</button>
))}
</div>
{activeView === 'new' && (
<form onSubmit={handleReceiptSubmit} className={`${panelClassName} p-5`}>
<h2 className="text-base font-bold text-dark-text">Registrar recebimento</h2>
<div className="mt-4 rounded-xl border border-dark-border bg-dark-input px-4 py-3 text-sm font-semibold text-dark-muted">
Preencha o que chegou. O financeiro vincula OC/NF e aprova o lançamento.
</div>
<p className="mt-5 text-xs font-bold uppercase tracking-widest text-dark-muted">1. Qual categoria de produto chegou?</p>
<div className="mt-3 grid grid-cols-1 gap-3 sm:grid-cols-2">
{receiptCategories.map(category => (
<button
key={category.name}
type="button"
onClick={() => setSelectedCategory(category.name)}
className={`flex min-h-24 flex-col items-center justify-center gap-3 rounded-xl border p-4 text-center font-bold transition-colors cursor-pointer ${
selectedCategory === category.name
? 'border-brand-primary bg-brand-primary/12 text-brand-primary'
: 'border-dark-border bg-dark-input/40 text-dark-text hover:border-brand-primary'
}`}
>
<category.icon className="h-6 w-6" />
{category.name}
</button>
))}
</div>
<div className="mt-5 rounded-xl border border-dark-border bg-dark-input/35 p-4">
<p className="text-xs font-bold uppercase tracking-widest text-dark-muted">2. Detalhes do recebimento</p>
<div className="mt-3 grid grid-cols-1 gap-3 md:grid-cols-2">
<label className="text-xs font-bold text-dark-muted">
Produto / material
<input value={form.product} onChange={(event) => setForm(current => ({ ...current, product: event.target.value }))} className={`${inputClassName} mt-1`} placeholder="ex: Meia malha 30.1" />
</label>
<label className="text-xs font-bold text-dark-muted">
Fornecedor
<input value={form.supplier} onChange={(event) => setForm(current => ({ ...current, supplier: event.target.value }))} className={`${inputClassName} mt-1`} placeholder="Opcional" />
</label>
<label className="text-xs font-bold text-dark-muted">
Quantidade
<input inputMode="decimal" value={form.quantity} onChange={(event) => setForm(current => ({ ...current, quantity: event.target.value }))} className={`${inputClassName} mt-1`} placeholder="ex: 120" />
</label>
<label className="text-xs font-bold text-dark-muted">
Unidade
<select value={form.unit} onChange={(event) => setForm(current => ({ ...current, unit: event.target.value }))} className={`${inputClassName} mt-1`}>
<option value="kg">kg</option>
<option value="rolos">rolos</option>
<option value="un.">un.</option>
<option value="caixas">caixas</option>
</select>
</label>
<label className="text-xs font-bold text-dark-muted">
Nota fiscal
<input value={form.invoice} onChange={(event) => setForm(current => ({ ...current, invoice: event.target.value }))} className={`${inputClassName} mt-1`} placeholder="NF-001234" />
</label>
<label className="text-xs font-bold text-dark-muted md:col-span-2">
Observações
<textarea value={form.notes} onChange={(event) => setForm(current => ({ ...current, notes: event.target.value }))} className="mt-1 min-h-20 w-full rounded-lg border border-dark-border bg-dark-input px-3 py-2 text-sm font-semibold text-dark-text outline-none placeholder:text-dark-muted focus:border-brand-primary" placeholder="Condição, divergências, lote..." />
</label>
</div>
<button type="submit" disabled={isBusy} className="mt-4 inline-flex h-11 w-full items-center justify-center gap-2 rounded-lg bg-brand-primary px-4 text-sm font-bold text-brand-contrast transition-opacity hover:opacity-90 disabled:cursor-not-allowed disabled:opacity-60 cursor-pointer">
<Save className="h-4 w-4" />
{isBusy ? 'Salvando...' : 'Registrar recebimento'}
</button>
</div>
</form>
)}
{activeView === 'pending' && (
<ReceiptList
receipts={visibleReceipts}
emptyTitle="Nenhum recebimento pendente"
onApprove={onApprove}
onRemove={onRemove}
/>
)}
{activeView === 'history' && (
<ReceiptList
receipts={visibleReceipts}
emptyTitle="Nenhum recebimento no histórico"
onApprove={onApprove}
onRemove={onRemove}
/>
)}
</div>
);
};
const StockTab = ({
stock,
onRefresh,
}: {
stock: StockData[];
onRefresh: () => void;
}) => {
const [search, setSearch] = useState('');
const [stockFilter, setStockFilter] = useState('all');
const [currentPage, setCurrentPage] = useState(1);
const [itemsPerPage, setItemsPerPage] = useState(20);
const normalizedSearch = normalizeSearch(search);
const totalBalance = stock.reduce((total, item) => total + Number(item.saldo || 0), 0);
const updatedItems = stock.filter(item => Number(item.delta_estoque || 0) !== 0).length;
const latestUpdate = stock
.map(item => item.updated_at ? new Date(item.updated_at).getTime() : 0)
.filter(Boolean)
.sort((a, b) => b - a)[0] || null;
const visibleStock = stock.filter(item => {
const balance = Number(item.saldo || 0);
const delta = Number(item.delta_estoque || 0);
const matchesSearch = !normalizedSearch || normalizeSearch(`${item.produto_id} ${item.nome}`).includes(normalizedSearch);
const matchesFilter =
stockFilter === 'all' ||
(stockFilter === 'positive' && balance > 0) ||
(stockFilter === 'empty' && balance <= 0) ||
(stockFilter === 'changed' && delta !== 0);
return matchesSearch && matchesFilter;
}).sort((a, b) => Number(b.saldo || 0) - Number(a.saldo || 0));
const totalPages = Math.ceil(visibleStock.length / itemsPerPage);
const safeCurrentPage = Math.min(currentPage, totalPages || 1);
const startIndex = (safeCurrentPage - 1) * itemsPerPage;
const paginatedStock = visibleStock.slice(startIndex, startIndex + itemsPerPage);
return (
<div className="space-y-4">
<div className="grid grid-cols-1 gap-3 md:grid-cols-4">
<div className="rounded-xl border border-dark-border bg-dark-card p-4 shadow-sm">
<p className="text-xs font-bold uppercase tracking-widest text-dark-muted">SKUs</p>
<p className="mt-2 text-2xl font-bold text-dark-text">{formatNumber(stock.length, 0)}</p>
</div>
<div className="rounded-xl border border-dark-border bg-dark-card p-4 shadow-sm">
<p className="text-xs font-bold uppercase tracking-widest text-dark-muted">Saldo total</p>
<p className="mt-2 text-2xl font-bold text-dark-text">{formatNumber(totalBalance, 0)} un.</p>
</div>
<div className="rounded-xl border border-dark-border bg-dark-card p-4 shadow-sm">
<p className="text-xs font-bold uppercase tracking-widest text-dark-muted">Com delta</p>
<p className="mt-2 text-2xl font-bold text-dark-text">{formatNumber(updatedItems, 0)}</p>
</div>
<div className="rounded-xl border border-dark-border bg-dark-card p-4 shadow-sm">
<p className="text-xs font-bold uppercase tracking-widest text-dark-muted">Última atualização</p>
<p className="mt-2 text-2xl font-bold text-dark-text">{latestUpdate ? formatDateTime(new Date(latestUpdate).toISOString()) : '-'}</p>
</div>
</div>
<div className={`${panelClassName} p-4`}>
<div className="flex flex-col gap-3 lg:flex-row lg:items-center lg:justify-between">
<div>
<h2 className="text-base font-bold text-dark-text">Estoque</h2>
<p className="mt-1 text-sm font-semibold text-dark-muted">Saldo de produtos sincronizado pelo fluxo de estoque para Graphs.</p>
</div>
<div className="flex flex-col gap-2 sm:flex-row">
<button
type="button"
onClick={() => exportCsv('estoque.csv', visibleStock.map(item => ({
produto_id: item.produto_id,
nome: item.nome,
saldo: item.saldo,
delta_estoque: item.delta_estoque,
atualizado_em: item.updated_at || '',
})))}
className={buttonClassName}
>
<Download className="h-4 w-4" /> CSV
</button>
<button type="button" onClick={onRefresh} className={buttonClassName}>
<RefreshCw className="h-4 w-4" /> Atualizar
</button>
</div>
</div>
<div className="mt-4 grid grid-cols-1 gap-3 lg:grid-cols-[1fr_220px]">
<label className="relative">
<Search className="pointer-events-none absolute left-3 top-3 h-4 w-4 text-dark-muted" />
<input
value={search}
onChange={(event) => {
setSearch(event.target.value);
setCurrentPage(1);
}}
className={`${inputClassName} pl-9`}
placeholder="Buscar por SKU ou produto..."
/>
</label>
<select
value={stockFilter}
onChange={(event) => {
setStockFilter(event.target.value);
setCurrentPage(1);
}}
className={inputClassName}
>
<option value="all">Todos os saldos</option>
<option value="positive">Com saldo</option>
<option value="empty">Sem saldo</option>
<option value="changed">Com delta</option>
</select>
</div>
{visibleStock.length ? (
<div className="mt-4 overflow-hidden rounded-xl border border-dark-border">
<div className="hidden grid-cols-[140px_1fr_120px_120px_160px] border-b border-dark-border bg-dark-input/40 px-4 py-3 text-xs font-bold uppercase tracking-widest text-dark-muted md:grid">
<span>SKU</span>
<span>Produto</span>
<span className="text-right">Saldo</span>
<span className="text-right">Delta</span>
<span className="text-right">Atualizado</span>
</div>
<div className="divide-y divide-dark-border">
{paginatedStock.map(item => {
const delta = Number(item.delta_estoque || 0);
return (
<div key={item.produto_id} className="grid grid-cols-1 gap-2 bg-dark-card px-4 py-3 md:grid-cols-[140px_1fr_120px_120px_160px] md:items-center">
<p className="font-mono text-xs font-bold text-dark-muted">#{item.produto_id}</p>
<p className="text-sm font-bold text-dark-text">{item.nome}</p>
<p className="text-sm font-bold text-dark-text md:text-right">{formatNumber(Number(item.saldo || 0), 0)} un.</p>
<p className={`text-sm font-bold md:text-right ${delta > 0 ? 'text-emerald-300' : delta < 0 ? 'text-red-300' : 'text-dark-muted'}`}>
{delta > 0 ? '+' : ''}{formatNumber(delta, 0)}
</p>
<p className="text-xs font-semibold text-dark-muted md:text-right">{formatDateTime(item.updated_at || null)}</p>
</div>
);
})}
</div>
<PaginationControls
totalItems={visibleStock.length}
currentPage={safeCurrentPage}
totalPages={totalPages}
pageSize={itemsPerPage}
pageSizeOptions={[20, 50, 100]}
itemLabel="SKUs"
pageSizeLabel="por página"
startIndex={startIndex}
endIndex={Math.min(startIndex + itemsPerPage, visibleStock.length)}
onPageChange={setCurrentPage}
onPageSizeChange={(pageSize) => {
setItemsPerPage(pageSize);
setCurrentPage(1);
}}
className="border-t border-dark-border px-4 py-3"
/>
</div>
) : (
<div className={emptyStateClassName}>
<PackageSearch className="h-8 w-8 text-brand-primary" />
<h3 className="text-base font-bold text-dark-text">{stock.length ? 'Nenhum SKU encontrado' : 'Nenhum estoque sincronizado'}</h3>
<p className="text-sm font-semibold text-dark-muted">
{stock.length ? 'Ajuste a busca ou o filtro.' : 'Quando o fluxo de estoque postar em /api/stock, os saldos aparecem aqui.'}
</p>
</div>
)}
</div>
</div>
);
};
const InventoryCountTab = ({
lots,
onAdjust,
onRefresh,
}: {
lots: SupplyLot[];
onAdjust: (lotId: number, countedQuantity: number, reason: string) => Promise<void>;
onRefresh: () => void;
}) => {
const [search, setSearch] = useState('');
const [adjustments, setAdjustments] = useState<Record<number, { countedQuantity: string; reason: string }>>({});
const normalizedSearch = normalizeSearch(search);
const visibleLots = lots.filter(lot => (
!normalizedSearch || normalizeSearch(`${lot.product} ${lot.category} ${lot.supplier} ${lot.invoice} ${lot.id}`).includes(normalizedSearch)
));
const updateAdjustment = (lotId: number, patch: Partial<{ countedQuantity: string; reason: string }>) => {
setAdjustments(current => ({
...current,
[lotId]: {
countedQuantity: current[lotId]?.countedQuantity ?? '',
reason: current[lotId]?.reason ?? '',
...patch,
},
}));
};
return (
<div className="space-y-4">
<button
type="button"
onClick={() => exportCsv('inventario-suprimentos.csv', visibleLots.map(lot => ({
lote: lot.id,
material: lot.product,
categoria: lot.category,
quantidade_sistema: lot.quantity,
unidade: lot.unit,
fornecedor: lot.supplier || 'Sem fornecedor',
})))}
className={buttonClassName}
>
<Download className="h-4 w-4" /> Exportar inventário CSV
</button>
<div className={`${panelClassName} p-5`}>
<h2 className="text-base font-bold text-dark-text">Inventário físico</h2>
<div className="mt-4 rounded-xl border border-dark-border bg-dark-input px-4 py-3 text-sm font-semibold text-dark-muted">
Compare o estoque do sistema com a contagem física. O ajuste gera movimentação com justificativa.
</div>
<div className="mt-4 grid grid-cols-1 gap-3 md:grid-cols-[1fr_auto]">
<input value={search} onChange={(event) => setSearch(event.target.value)} className={inputClassName} placeholder="Buscar item..." />
<button type="button" onClick={onRefresh} className={buttonClassName}><RefreshCw className="h-4 w-4" /> Recarregar</button>
</div>
{visibleLots.length ? (
<div className="mt-4 grid grid-cols-1 gap-3 md:grid-cols-2 xl:grid-cols-3">
{visibleLots.map(lot => (
<div key={lot.id} className="rounded-xl border border-dark-border bg-dark-input/35 p-4">
<p className="text-sm font-bold text-dark-text">{lot.product}</p>
<p className="mt-1 text-xs font-semibold text-dark-muted">{lot.category} · lote #{lot.id}</p>
<p className="mt-3 text-xl font-bold text-dark-text">{formatNumber(lot.quantity)} {lot.unit}</p>
<p className="mt-1 text-xs font-semibold text-dark-muted">{lot.supplier || 'Sem fornecedor'}</p>
<div className="mt-4 grid grid-cols-1 gap-2">
<label className="text-xs font-bold text-dark-muted">
Quantidade contada
<input
inputMode="decimal"
value={adjustments[lot.id]?.countedQuantity ?? ''}
onChange={(event) => updateAdjustment(lot.id, { countedQuantity: event.target.value })}
className={`${inputClassName} mt-1`}
placeholder={`${formatNumber(lot.quantity)} ${lot.unit}`}
/>
</label>
<label className="text-xs font-bold text-dark-muted">
Justificativa
<input
value={adjustments[lot.id]?.reason ?? ''}
onChange={(event) => updateAdjustment(lot.id, { reason: event.target.value })}
className={`${inputClassName} mt-1`}
placeholder="ex: contagem física"
/>
</label>
<button
type="button"
onClick={async () => {
const rawQuantity = adjustments[lot.id]?.countedQuantity ?? '';
if (!rawQuantity.trim()) return;
const nextQuantity = parseDecimal(rawQuantity);
const reason = adjustments[lot.id]?.reason.trim() ?? '';
await onAdjust(lot.id, nextQuantity, reason);
setAdjustments(current => ({ ...current, [lot.id]: { countedQuantity: '', reason: '' } }));
}}
className={buttonClassName}
>
<ClipboardCheck className="h-4 w-4" />
Ajustar lote
</button>
</div>
</div>
))}
</div>
) : (
<div className={emptyStateClassName}>
<ClipboardCheck className="h-8 w-8 text-brand-primary" />
<h3 className="text-base font-bold text-dark-text">{lots.length ? 'Nenhum lote encontrado' : 'Nenhum lote para inventariar'}</h3>
</div>
)}
</div>
</div>
);
};
const movementLabels: Record<string, string> = {
receipt: 'Entrada por recebimento',
inventory_adjustment: 'Ajuste de inventário',
production_exit: 'Saída para produção',
reversal: 'Estorno',
};
const ProductionExitPanel = ({
lots,
onConsume,
}: {
lots: SupplyLot[];
onConsume: (lotId: number, quantity: number, productionOrderNumber: string, reason: string) => Promise<void>;
}) => {
const [form, setForm] = useState({
lotId: '',
quantity: '',
productionOrderNumber: '',
reason: '',
});
const selectedLot = lots.find(lot => `${lot.id}` === form.lotId);
const handleSubmit = async (event: FormEvent<HTMLFormElement>) => {
event.preventDefault();
const lotId = Number(form.lotId);
const quantity = parseDecimal(form.quantity);
if (!lotId || quantity <= 0) return;
await onConsume(lotId, quantity, form.productionOrderNumber.trim(), form.reason.trim());
setForm({ lotId: '', quantity: '', productionOrderNumber: '', reason: '' });
};
return (
<form onSubmit={handleSubmit} className={`${panelClassName} p-5`}>
<div className="flex flex-col gap-2 md:flex-row md:items-start md:justify-between">
<div>
<h2 className="text-base font-bold text-dark-text">Saída para produção</h2>
<p className="mt-1 text-sm font-semibold text-dark-muted">Baixa material de um lote e registra movimentação ligada à OP.</p>
</div>
{selectedLot && (
<span className="w-fit rounded-full border border-dark-border bg-dark-input px-2.5 py-1 text-xs font-bold text-dark-muted">
Saldo: {formatNumber(selectedLot.quantity)} {selectedLot.unit}
</span>
)}
</div>
<div className="mt-4 grid grid-cols-1 gap-3 lg:grid-cols-[1.4fr_120px_160px_1fr_auto]">
<label className="text-xs font-bold text-dark-muted">
Lote
<select value={form.lotId} onChange={(event) => setForm(current => ({ ...current, lotId: event.target.value }))} className={`${inputClassName} mt-1`}>
<option value="">Selecione...</option>
{lots.map(lot => (
<option key={lot.id} value={lot.id}>
#{lot.id} · {lot.product} · {formatNumber(lot.quantity)} {lot.unit}
</option>
))}
</select>
</label>
<label className="text-xs font-bold text-dark-muted">
Quantidade
<input inputMode="decimal" value={form.quantity} onChange={(event) => setForm(current => ({ ...current, quantity: event.target.value }))} className={`${inputClassName} mt-1`} placeholder="kg" />
</label>
<label className="text-xs font-bold text-dark-muted">
OP
<input value={form.productionOrderNumber} onChange={(event) => setForm(current => ({ ...current, productionOrderNumber: event.target.value }))} className={`${inputClassName} mt-1`} placeholder="ex: OP-123" />
</label>
<label className="text-xs font-bold text-dark-muted">
Motivo
<input value={form.reason} onChange={(event) => setForm(current => ({ ...current, reason: event.target.value }))} className={`${inputClassName} mt-1`} placeholder="ex: corte BLCS" />
</label>
<button type="submit" className={`${buttonClassName} mt-5`}>
<Repeat2 className="h-4 w-4" />
Baixar
</button>
</div>
</form>
);
};
const MovementsTab = ({
lots,
movements,
onConsume,
}: {
lots: SupplyLot[];
movements: SupplyMovement[];
onConsume: (lotId: number, quantity: number, productionOrderNumber: string, reason: string) => Promise<void>;
}) => {
const [search, setSearch] = useState('');
const [type, setType] = useState('all');
const movementTypes = Array.from(new Set(movements.map(movement => movement.type))).sort();
const normalizedSearch = normalizeSearch(search);
const visibleMovements = movements.filter(movement => {
const matchesSearch = !normalizedSearch || normalizeSearch(`${movement.product} ${movement.category} ${movement.reason} ${movement.lotId || ''}`).includes(normalizedSearch);
const matchesType = type === 'all' || movement.type === type;
return matchesSearch && matchesType;
});
return (
<div className="space-y-4">
<ProductionExitPanel lots={lots} onConsume={onConsume} />
<button
type="button"
onClick={() => exportCsv('movimentacoes-suprimentos.csv', visibleMovements.map(movement => ({
id: movement.id,
data: movement.createdAt,
tipo: movementLabels[movement.type] || movement.type,
material: movement.product,
categoria: movement.category,
quantidade: movement.quantity,
unidade: movement.unit,
lote: movement.lotId,
motivo: movement.reason,
})))}
className={buttonClassName}
>
<Download className="h-4 w-4" /> Exportar movimentações CSV
</button>
<div className={`${panelClassName} p-4`}>
<div className="grid grid-cols-1 gap-3 md:grid-cols-[1fr_220px]">
<label className="relative">
<Search className="pointer-events-none absolute left-3 top-3 h-4 w-4 text-dark-muted" />
<input value={search} onChange={(event) => setSearch(event.target.value)} className={`${inputClassName} pl-9`} placeholder="Buscar lote, tipo ou fornecedor..." />
</label>
<select value={type} onChange={(event) => setType(event.target.value)} className={inputClassName}>
<option value="all">Todos os tipos</option>
{movementTypes.map(item => <option key={item} value={item}>{movementLabels[item] || item}</option>)}
</select>
</div>
{visibleMovements.length ? (
<div className="mt-4 overflow-hidden rounded-xl border border-dark-border">
<div className="hidden grid-cols-[170px_1fr_140px_140px] border-b border-dark-border bg-dark-input/40 px-4 py-3 text-xs font-bold uppercase tracking-widest text-dark-muted md:grid">
<span>Data</span>
<span>Movimento</span>
<span>Quantidade</span>
<span>Lote</span>
</div>
<div className="divide-y divide-dark-border">
{visibleMovements.map(movement => (
<div key={movement.id} className="grid grid-cols-1 gap-2 bg-dark-card px-4 py-3 md:grid-cols-[170px_1fr_140px_140px] md:items-center">
<p className="text-sm font-semibold text-dark-muted">{formatDateTime(movement.createdAt)}</p>
<div>
<p className="text-sm font-bold text-dark-text">{movementLabels[movement.type] || movement.type}</p>
<p className="text-xs font-semibold text-dark-muted">{movement.product} · {movement.reason}</p>
</div>
<p className="text-sm font-bold text-dark-text">{formatNumber(movement.quantity)} {movement.unit}</p>
<p className="text-xs font-bold text-dark-muted">{movement.lotId ? `#${movement.lotId}` : '-'}</p>
</div>
))}
</div>
</div>
) : (
<div className={emptyStateClassName}>
<Repeat2 className="h-8 w-8 text-brand-primary" />
<h3 className="text-base font-bold text-dark-text">{movements.length ? 'Nenhuma movimentação encontrada' : 'Nenhuma movimentação ainda'}</h3>
</div>
)}
</div>
</div>
);
};
const InventoryScreen = () => {
const [activeTab, setActiveTab] = useState<InventoryTab>('dashboard');
const [summary, setSummary] = useState<SupplySummary>(emptySupplySummary);
const [stockSnapshot, setStockSnapshot] = useState<StockData[]>([]);
const [isLoading, setIsLoading] = useState(true);
const [isBusy, setIsBusy] = useState(false);
const [errorMessage, setErrorMessage] = useState('');
const loadInventoryData = async () => {
setErrorMessage('');
const [nextSummary, nextStockSnapshot] = await Promise.all([
fetchSupplySummary(),
fetchStock(),
]);
setSummary(nextSummary);
setStockSnapshot(nextStockSnapshot);
};
const loadSummary = async () => {
setErrorMessage('');
const nextSummary = await fetchSupplySummary();
setSummary(nextSummary);
};
const loadStockSnapshot = async () => {
setErrorMessage('');
setStockSnapshot(await fetchStock());
};
useEffect(() => {
let isMounted = true;
const load = async () => {
setIsLoading(true);
try {
const [nextSummary, nextStockSnapshot] = await Promise.all([
fetchSupplySummary(),
fetchStock(),
]);
if (isMounted) {
setSummary(nextSummary);
setStockSnapshot(nextStockSnapshot);
}
} finally {
if (isMounted) setIsLoading(false);
}
};
load();
return () => {
isMounted = false;
};
}, []);
const runSupplyAction = async (action: () => Promise<void>) => {
setIsBusy(true);
setErrorMessage('');
try {
await action();
await loadInventoryData();
} catch (error) {
setErrorMessage(error instanceof Error ? error.message : 'Não foi possível atualizar suprimentos.');
} finally {
setIsBusy(false);
}
};
return (
<div className={pageClassName}>
<Header title="Controle de Estoque" subtitle="Saldo, lotes, entradas e inventário." backTo="/supplies" />
<div className="flex flex-wrap gap-2">
{inventoryTabs.map(tab => (
<button
key={tab.id}
type="button"
onClick={() => setActiveTab(tab.id)}
className={`inline-flex h-10 items-center justify-center gap-2 rounded-lg px-3 text-sm font-bold transition-colors cursor-pointer ${
activeTab === tab.id ? 'bg-brand-primary text-brand-contrast' : 'bg-dark-input text-dark-text hover:bg-dark-card'
}`}
>
<tab.icon className="h-4 w-4" />
{tab.name}
</button>
))}
</div>
{errorMessage && (
<div className="rounded-xl border border-red-500/30 bg-red-500/10 px-4 py-3 text-sm font-bold text-red-300">
{errorMessage}
</div>
)}
{isLoading ? (
<div className={`${panelClassName} p-5 text-sm font-bold text-dark-muted`}>Carregando estoque...</div>
) : (
<>
{activeTab === 'dashboard' && <InventoryDashboard summary={summary} onNewReceipt={() => setActiveTab('receipts')} />}
{activeTab === 'stock' && <StockTab stock={stockSnapshot} onRefresh={loadStockSnapshot} />}
{activeTab === 'balance' && <BalanceTab summary={summary} onRefresh={loadSummary} />}
{activeTab === 'receipts' && (
<ReceiptsTab
receipts={summary.receipts}
isBusy={isBusy}
onCreate={(payload) => runSupplyAction(async () => {
await createSupplyReceipt(payload);
})}
onApprove={(receiptId) => runSupplyAction(async () => {
await approveSupplyReceipt(receiptId);
})}
onRemove={(receiptId) => runSupplyAction(async () => {
await deleteSupplyReceipt(receiptId);
})}
/>
)}
{activeTab === 'inventory' && (
<InventoryCountTab
lots={summary.lots}
onRefresh={loadSummary}
onAdjust={(lotId, countedQuantity, reason) => runSupplyAction(async () => {
await adjustSupplyLotInventory(lotId, { countedQuantity, reason });
})}
/>
)}
{activeTab === 'movements' && (
<MovementsTab
lots={summary.lots}
movements={summary.movements}
onConsume={(lotId, quantity, productionOrderNumber, reason) => runSupplyAction(async () => {
await consumeSupplyLotForProduction(lotId, { quantity, productionOrderNumber, reason });
})}
/>
)}
</>
)}
</div>
);
};
const FabricPlanningScreen = () => {
const [plans, setPlans] = useState<SupplyFabricPlan[]>([]);
const [form, setForm] = useState({
material: '',
color: '',
quantityKg: '',
supplier: '',
priority: 'Normal',
});
const [isLoading, setIsLoading] = useState(true);
const [isBusy, setIsBusy] = useState(false);
const [errorMessage, setErrorMessage] = useState('');
const totalKg = plans.reduce((total, plan) => total + plan.quantityKg, 0);
const loadPlans = async () => {
setErrorMessage('');
const summary = await fetchSupplySummary();
setPlans(summary.fabricPlans);
};
useEffect(() => {
let isMounted = true;
const load = async () => {
setIsLoading(true);
try {
const summary = await fetchSupplySummary();
if (isMounted) setPlans(summary.fabricPlans);
} finally {
if (isMounted) setIsLoading(false);
}
};
load();
return () => {
isMounted = false;
};
}, []);
const handleSubmit = async (event: FormEvent<HTMLFormElement>) => {
event.preventDefault();
const material = form.material.trim();
const quantityKg = parseDecimal(form.quantityKg);
if (!material || quantityKg <= 0) return;
setIsBusy(true);
setErrorMessage('');
try {
await createSupplyFabricPlan({
material,
color: form.color.trim() || 'Todas as cores',
quantityKg,
supplier: form.supplier.trim() || 'Sem fornecedor',
priority: form.priority,
});
await loadPlans();
setForm({ material: '', color: '', quantityKg: '', supplier: '', priority: 'Normal' });
} catch (error) {
setErrorMessage(error instanceof Error ? error.message : 'Não foi possível salvar o plano de malha.');
} finally {
setIsBusy(false);
}
};
const removePlan = async (planId: number) => {
setIsBusy(true);
setErrorMessage('');
try {
await deleteSupplyFabricPlan(planId);
await loadPlans();
} catch (error) {
setErrorMessage(error instanceof Error ? error.message : 'Não foi possível remover o plano de malha.');
} finally {
setIsBusy(false);
}
};
return (
<div className={pageClassName}>
<Header title="Planejamento de Malha" subtitle="Fila de matéria-prima para compra, recebimento e abastecimento do corte." backTo="/supplies" />
{errorMessage && (
<div className="rounded-xl border border-red-500/30 bg-red-500/10 px-4 py-3 text-sm font-bold text-red-300">
{errorMessage}
</div>
)}
<div className="grid grid-cols-1 gap-4 md:grid-cols-3">
<div 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">Planos ativos</p>
<p className="mt-2 text-3xl font-bold text-dark-text">{plans.length}</p>
</div>
<div 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">Kg planejado</p>
<p className="mt-2 text-3xl font-bold text-dark-text">{formatNumber(totalKg)} kg</p>
</div>
<div 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">Críticos</p>
<p className="mt-2 text-3xl font-bold text-dark-text">{plans.filter(plan => plan.priority === 'Crítico').length}</p>
</div>
</div>
<div className="grid grid-cols-1 items-stretch gap-6 xl:grid-cols-[1fr_420px]">
<div className={`${panelClassName} flex flex-col overflow-hidden`}>
<div className="border-b border-dark-border p-5">
<h2 className="text-base font-bold text-dark-text">Planos de malha</h2>
<p className="mt-1 text-sm font-semibold text-dark-muted">Itens planejados para compra ou recebimento.</p>
</div>
{isLoading ? (
<div className={`${emptyStateClassName} min-h-[220px] flex-1`}>
<Ruler className="h-8 w-8 text-brand-primary" />
<h3 className="text-base font-bold text-dark-text">Carregando planos...</h3>
</div>
) : plans.length ? (
<div className="divide-y divide-dark-border">
{plans.map(plan => (
<div key={plan.id} className="grid grid-cols-1 gap-3 p-4 md:grid-cols-[1.4fr_1fr_100px_100px_44px] md:items-center">
<div>
<p className="font-bold text-dark-text">{plan.material}</p>
<p className="text-xs font-semibold text-dark-muted">{plan.color}</p>
</div>
<p className="text-sm font-semibold text-dark-muted">{plan.supplier}</p>
<p className="text-sm font-bold text-dark-text">{formatNumber(plan.quantityKg)} kg</p>
<span className="w-fit rounded-full border border-dark-border bg-dark-input px-2.5 py-1 text-xs font-bold text-dark-text">{plan.priority}</span>
<button
type="button"
title="Remover plano"
aria-label={`Remover plano ${plan.material}`}
disabled={isBusy}
onClick={() => removePlan(plan.id)}
className="inline-flex h-10 w-10 items-center justify-center rounded-lg text-red-400 transition-colors hover:bg-red-500/10 hover:text-red-300 disabled:cursor-not-allowed disabled:opacity-60 cursor-pointer"
>
<Trash2 className="h-4 w-4" />
</button>
</div>
))}
</div>
) : (
<div className={`${emptyStateClassName} flex-1`}>
<Ruler className="h-8 w-8 text-brand-primary" />
<h3 className="text-base font-bold text-dark-text">Nenhum plano de malha cadastrado</h3>
<p className="text-sm font-semibold text-dark-muted">Cadastre o primeiro plano no formulário ao lado.</p>
</div>
)}
</div>
<form onSubmit={handleSubmit} className={`${panelClassName} p-5`}>
<h2 className="text-base font-bold text-dark-text">Novo plano de malha</h2>
<div className="mt-4 grid grid-cols-1 gap-3">
<label className="text-xs font-bold text-dark-muted">
Malha / tecido
<input value={form.material} onChange={(event) => setForm(current => ({ ...current, material: event.target.value }))} className={`${inputClassName} mt-1`} placeholder="ex: Meia malha 30.1" />
</label>
<label className="text-xs font-bold text-dark-muted">
Cor
<input value={form.color} onChange={(event) => setForm(current => ({ ...current, color: event.target.value }))} className={`${inputClassName} mt-1`} placeholder="Todas as cores" />
</label>
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
<label className="text-xs font-bold text-dark-muted">
Quantidade kg
<input inputMode="decimal" value={form.quantityKg} onChange={(event) => setForm(current => ({ ...current, quantityKg: event.target.value }))} className={`${inputClassName} mt-1`} placeholder="ex: 180" />
</label>
<label className="text-xs font-bold text-dark-muted">
Prioridade
<select value={form.priority} onChange={(event) => setForm(current => ({ ...current, priority: event.target.value }))} className={`${inputClassName} mt-1`}>
<option>Normal</option>
<option>Atenção</option>
<option>Crítico</option>
</select>
</label>
</div>
<label className="text-xs font-bold text-dark-muted">
Fornecedor
<input value={form.supplier} onChange={(event) => setForm(current => ({ ...current, supplier: event.target.value }))} className={`${inputClassName} mt-1`} placeholder="Opcional" />
</label>
<button type="submit" disabled={isBusy} className="mt-2 inline-flex h-11 items-center justify-center gap-2 rounded-lg bg-brand-primary px-4 text-sm font-bold text-brand-contrast transition-opacity hover:opacity-90 disabled:cursor-not-allowed disabled:opacity-60 cursor-pointer">
<Save className="h-4 w-4" />
{isBusy ? 'Salvando...' : 'Salvar plano'}
</button>
</div>
</form>
</div>
</div>
);
};
const purchaseStatusLabels: Record<SupplyPurchaseNeed['status'], string> = {
critical: 'Sem estoque',
attention: 'Comprar',
ok: 'Coberto',
};
const getNeedUnit = (need: SupplyPurchaseNeed) => need.unit || 'kg';
const formatNeedQuantity = (need: SupplyPurchaseNeed, value: number) => (
`${formatNumber(value)} ${getNeedUnit(need)}`
);
const summarizePurchaseNeeds = (needs: SupplyPurchaseNeed[]) => {
const totalsByUnit = needs.reduce<Record<string, number>>((totals, need) => {
if (need.purchaseKg <= 0) return totals;
const unit = getNeedUnit(need);
totals[unit] = (totals[unit] || 0) + need.purchaseKg;
return totals;
}, {});
const summaries = Object.entries(totalsByUnit).map(([unit, total]) => `${formatNumber(total)} ${unit}`);
return summaries.length ? summaries.join(' + ') : '0 kg';
};
const PurchaseNeedsScreen = () => {
const [summary, setSummary] = useState<SupplySummary>(emptySupplySummary);
const [isLoading, setIsLoading] = useState(true);
const [search, setSearch] = useState('');
const [currentPage, setCurrentPage] = useState(1);
const [itemsPerPage, setItemsPerPage] = useState(10);
const loadSummary = async () => {
const nextSummary = await fetchSupplySummary();
setSummary(nextSummary);
};
useEffect(() => {
let isMounted = true;
const load = async () => {
setIsLoading(true);
try {
const nextSummary = await fetchSupplySummary();
if (isMounted) setSummary(nextSummary);
} finally {
if (isMounted) setIsLoading(false);
}
};
load();
return () => {
isMounted = false;
};
}, []);
const normalizedSearch = normalizeSearch(search);
const visibleNeeds = summary.purchaseNeeds.filter(need => (
!normalizedSearch || normalizeSearch(`${need.material} ${need.suppliers.join(' ')} ${need.colors.join(' ')}`).includes(normalizedSearch)
));
const totalPages = Math.ceil(visibleNeeds.length / itemsPerPage);
const safeCurrentPage = Math.min(currentPage, totalPages || 1);
const startIndex = (safeCurrentPage - 1) * itemsPerPage;
const paginatedNeeds = visibleNeeds.slice(startIndex, startIndex + itemsPerPage);
const purchaseItemCount = summary.purchaseNeeds.filter(need => need.purchaseKg > 0).length;
const suggestedPurchaseSummary = summarizePurchaseNeeds(summary.purchaseNeeds);
const pendingSupplierCount = new Set(summary.purchaseNeeds.flatMap(need => need.suppliers)).size;
const missingReferenceCount = summary.purchaseNeeds.filter(need => need.missingReference).length;
return (
<div className={pageClassName}>
<Header title="Necessidade de Compra" subtitle="Materiais abaixo do mínimo e necessidade projetada para compra." backTo="/supplies" />
<div className="grid grid-cols-1 gap-4 md:grid-cols-4">
{[
{ label: 'Itens a comprar', value: `${purchaseItemCount}` },
{ label: 'Compra sugerida', value: suggestedPurchaseSummary },
{ label: 'Fornecedores', value: `${pendingSupplierCount}` },
{ label: 'Sem referência', value: `${missingReferenceCount}` },
].map(stat => (
<div key={stat.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">{stat.label}</p>
<p className="mt-2 text-3xl font-bold text-dark-text">{stat.value}</p>
</div>
))}
</div>
<div className={`${panelClassName} p-5`}>
<div className="flex flex-col gap-4 md:flex-row md:items-center md:justify-between">
<div>
<h2 className="text-base font-bold text-dark-text">Necessidade por material</h2>
<p className="mt-1 text-sm font-semibold text-dark-muted">Planejado - estoque aprovado - recebimentos pendentes.</p>
</div>
<div className="flex flex-wrap gap-2">
<button type="button" onClick={loadSummary} className={buttonClassName}><RefreshCw className="h-4 w-4" /> Atualizar</button>
<button
type="button"
onClick={() => exportCsv('necessidade-compra.csv', visibleNeeds.map(need => ({
material: need.material,
planejado_kg: need.plannedKg,
estoque_kg: need.stockKg,
pendente_kg: need.pendingKg,
comprar_kg: need.purchaseKg,
unidade: getNeedUnit(need),
prioridade: need.priority,
cobertura: need.missingReference ? 'Sem referência de consumo' : purchaseStatusLabels[need.status],
fornecedores: need.suppliers.join(' | '),
cores: need.colors.join(' | '),
produtos: (need.products || []).map(product => `${product.productId} ${product.name}`).join(' | '),
})))}
className={buttonClassName}
>
<Download className="h-4 w-4" /> Exportar CSV
</button>
</div>
</div>
<label className="relative mt-4 block">
<Search className="pointer-events-none absolute left-3 top-3 h-4 w-4 text-dark-muted" />
<input
value={search}
onChange={(event) => {
setSearch(event.target.value);
setCurrentPage(1);
}}
className={`${inputClassName} pl-9`}
placeholder="Buscar por material, fornecedor ou cor..."
/>
</label>
{isLoading ? (
<div className={emptyStateClassName}>
<BarChart3 className="h-8 w-8 text-brand-primary" />
<h3 className="text-base font-bold text-dark-text">Calculando necessidade...</h3>
</div>
) : visibleNeeds.length ? (
<div className="mt-4 overflow-hidden rounded-xl border border-dark-border">
<div className="overflow-x-auto">
<table className="w-full min-w-[920px] table-fixed border-collapse">
<colgroup>
<col />
<col className="w-[120px]" />
<col className="w-[120px]" />
<col className="w-[120px]" />
<col className="w-[120px]" />
<col className="w-[150px]" />
</colgroup>
<thead className="border-b border-dark-border bg-dark-input/40 text-xs font-bold uppercase tracking-widest text-dark-muted">
<tr>
<th scope="col" className="px-4 py-3 text-left">Material</th>
<th scope="col" className="px-4 py-3 text-right">Planejado</th>
<th scope="col" className="px-4 py-3 text-right">Estoque</th>
<th scope="col" className="px-4 py-3 text-right">Pendente</th>
<th scope="col" className="px-4 py-3 text-right">Comprar</th>
<th scope="col" className="px-4 py-3 text-left">Cobertura</th>
</tr>
</thead>
<tbody className="divide-y divide-dark-border bg-dark-card">
{paginatedNeeds.map(need => {
const referenceProduct = need.products?.[0];
return (
<tr key={need.material}>
<td className="px-4 py-4 align-middle">
<div className="min-w-0">
<p className="text-sm font-bold text-dark-text">{need.material}</p>
<div className="mt-1 flex flex-wrap items-center gap-2">
<p className="text-xs font-semibold text-dark-muted">
{need.missingReference
? 'Cadastre produto/material em Cadastros > Referência de Consumo'
: `${(need.colors.length ? need.colors.join(', ') : 'Todas as cores')} · ${(need.suppliers.length ? need.suppliers.join(', ') : 'Sem fornecedor')}`}
</p>
{need.missingReference && referenceProduct ? (
<RouterLink
to={buildConsumptionReferencePath({ sku: referenceProduct.productId, name: referenceProduct.name })}
className="inline-flex h-7 w-7 items-center justify-center rounded-lg bg-dark-input text-dark-text transition-colors hover:bg-dark-border"
title={`Cadastrar referência do SKU ${referenceProduct.productId}`}
aria-label={`Cadastrar referência do SKU ${referenceProduct.productId}`}
>
<Pencil className="h-3.5 w-3.5" />
</RouterLink>
) : null}
</div>
{need.products?.length ? (
<p className="mt-1 text-xs font-semibold text-dark-muted">
{need.products.slice(0, 2).map(product => product.productId).join(', ')}
{need.products.length > 2 ? ` +${need.products.length - 2}` : ''}
</p>
) : null}
</div>
</td>
<td className="px-4 py-4 text-right align-middle text-sm font-bold text-dark-text">{formatNeedQuantity(need, need.plannedKg)}</td>
<td className="px-4 py-4 text-right align-middle text-sm font-bold text-dark-text">{formatNeedQuantity(need, need.stockKg)}</td>
<td className="px-4 py-4 text-right align-middle text-sm font-bold text-dark-text">{formatNeedQuantity(need, need.pendingKg)}</td>
<td className={`px-4 py-4 text-right align-middle text-sm font-bold ${need.purchaseKg > 0 ? 'text-amber-300' : 'text-emerald-300'}`}>{formatNeedQuantity(need, need.purchaseKg)}</td>
<td className="px-4 py-4 align-middle">
<span className={`inline-flex whitespace-nowrap rounded-full border px-2.5 py-1 text-xs font-bold ${
need.missingReference
? 'border-red-400/30 bg-red-400/10 text-red-300'
: need.status === 'critical'
? 'border-red-400/30 bg-red-400/10 text-red-300'
: need.status === 'attention'
? 'border-amber-400/30 bg-amber-400/10 text-amber-300'
: 'border-emerald-400/30 bg-emerald-400/10 text-emerald-300'
}`}>
{need.missingReference ? 'Sem referência' : purchaseStatusLabels[need.status]}
</span>
</td>
</tr>
);
})}
</tbody>
</table>
</div>
<PaginationControls
totalItems={visibleNeeds.length}
currentPage={safeCurrentPage}
totalPages={totalPages}
pageSize={itemsPerPage}
pageSizeOptions={[10, 20, 50, 100]}
itemLabel="materiais"
pageSizeLabel="itens por página"
startIndex={startIndex}
endIndex={Math.min(startIndex + itemsPerPage, visibleNeeds.length)}
onPageChange={setCurrentPage}
onPageSizeChange={(pageSize) => {
setItemsPerPage(pageSize);
setCurrentPage(1);
}}
className="border-t border-dark-border px-4 py-3"
/>
</div>
) : (
<div className={emptyStateClassName}>
<BarChart3 className="h-8 w-8 text-brand-primary" />
<h3 className="text-base font-bold text-dark-text">{summary.purchaseNeeds.length ? 'Nenhuma necessidade encontrada' : 'Nenhuma necessidade de compra'}</h3>
<p className="text-sm font-semibold text-dark-muted">{summary.purchaseNeeds.length ? 'Ajuste a busca.' : 'Cadastre planos de malha para gerar demanda e aprove recebimentos para abater estoque.'}</p>
</div>
)}
</div>
</div>
);
};
const Supplies = () => {
const { section } = useParams<{ section?: string }>();
if (!section) return <SuppliesHub />;
if (section === 'current-planning') return <DemandPlanningScreen />;
if (section === 'inventory') return <InventoryScreen />;
if (section === 'fabric-planning') return <FabricPlanningScreen />;
if (section === 'purchase-needs') return <PurchaseNeedsScreen />;
return <Navigate to="/supplies" replace />;
};
export default Supplies;