Files
graphs/src/pages/Supplies.tsx
2026-08-10 09:45:59 -03:00

2528 lines
122 KiB
TypeScript

import { type FormEvent, useEffect, useMemo, useState } from 'react';
import { Link as RouterLink, Navigate, useOutletContext, useParams, useSearchParams } from 'react-router-dom';
import {
AlertTriangle,
ArrowLeft,
ArrowRight,
BarChart3,
Boxes,
ClipboardCheck,
ClipboardList,
Download,
Eye,
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, downloadDatabaseDiagnostic, fetchCuttingSettings, fetchProductAnalytics, fetchProductionOrders, fetchStock, fetchSupplySummary, isSuperAdmin } 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 allProductionOrdersRange = { start: new Date(2000, 0, 1), end: new Date(2100, 11, 31) };
const isServicePurchaseNeed = (need: SupplyPurchaseNeed) => (
/\b(?:SERVI[CÇ]O|FRETE|TINTURARIA|TECELAGEM|COSTURA|ESTAMPARIA|LAVANDERIA)\b/i.test(need.material)
);
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: 'Repor produtos',
accessories: 'Repor acessórios',
inputs: 'Insumos a comprar',
review: 'Revisar dados',
dead: 'Estoque parado',
};
const demandQueueDescriptions: Record<DemandQueue, string> = {
apparel: 'Diagnóstico de reposição de produtos acabados; o corte é definido no Plano de Corte.',
accessories: 'Acessórios acabados com demanda ativa e cobertura abaixo da meta.',
inputs: 'Embalagens, matérias-primas e insumos com necessidade comercial 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,
meta,
priority = false,
}: {
title: string;
description: string;
icon: typeof Package;
to: string;
meta?: string;
priority?: boolean;
}) => {
return (
<RouterLink to={to} className={`${panelClassName} flex min-h-36 items-start gap-4 p-5 transition-colors hover:border-brand-primary ${priority ? 'border-brand-primary/30 bg-brand-primary/[0.035]' : ''}`}>
<div className={`flex h-11 w-11 items-center justify-center rounded-xl border ${priority ? 'border-brand-primary/30 bg-brand-primary/10 text-brand-primary' : '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>
{meta && <p className={`mt-4 text-xs font-bold ${priority ? 'text-brand-primary' : 'text-dark-text'}`}>{meta}</p>}
</div>
</RouterLink>
);
};
const FlowStepCard = ({
title,
description,
meta,
icon: Icon,
to,
tone = 'default',
}: {
title: string;
description: string;
meta: string;
icon: typeof Package;
to: string;
tone?: 'default' | 'critical' | 'attention' | 'ready';
}) => {
const tones = {
default: 'border-dark-border bg-dark-card text-brand-primary',
critical: 'border-red-400/30 bg-red-500/[0.055] text-red-300',
attention: 'border-amber-400/30 bg-amber-500/[0.055] text-amber-300',
ready: 'border-emerald-400/30 bg-emerald-500/[0.055] text-emerald-300',
};
return (
<RouterLink to={to} className={`${panelClassName} group flex min-h-36 items-start gap-4 p-5 transition-colors hover:border-brand-primary`}>
<div className={`flex h-11 w-11 shrink-0 items-center justify-center rounded-xl border ${tones[tone]}`}>
<Icon className="h-5 w-5" />
</div>
<div className="min-w-0 flex-1">
<div className="flex items-start justify-between gap-3">
<h3 className="text-base font-bold text-dark-text">{title}</h3>
<ArrowRight className="mt-0.5 h-4 w-4 shrink-0 text-dark-muted transition-transform group-hover:translate-x-0.5 group-hover:text-brand-primary" />
</div>
<p className="mt-2 text-sm font-semibold leading-5 text-dark-muted">{description}</p>
<span className={`mt-4 block text-xs font-bold ${tone === 'critical' ? 'text-red-300' : tone === 'attention' ? 'text-amber-300' : tone === 'ready' ? 'text-emerald-300' : 'text-dark-text'}`}>{meta}</span>
</div>
</RouterLink>
);
};
const FlowBlocker = ({
title,
description,
count,
to,
tone,
}: {
title: string;
description: string;
count: number;
to: string;
tone: 'critical' | 'attention' | 'info';
}) => {
const tones = {
critical: 'border-red-400/25 bg-red-500/[0.045] text-red-300',
attention: 'border-amber-400/25 bg-amber-500/[0.045] text-amber-300',
info: 'border-sky-400/25 bg-sky-500/[0.045] text-sky-300',
};
return (
<RouterLink to={to} className={`flex items-center gap-3 rounded-xl border px-3.5 py-3 transition-colors hover:border-brand-primary/60 ${tones[tone]}`}>
<span className="flex h-7 min-w-7 items-center justify-center rounded-lg bg-dark-card text-xs font-bold text-dark-text">{formatNumber(count, 0)}</span>
<span className="min-w-0 flex-1">
<span className="block text-sm font-bold text-dark-text">{title}</span>
<span className="block truncate text-xs font-semibold text-dark-muted">{description}</span>
</span>
<ArrowRight className="h-4 w-4 shrink-0" />
</RouterLink>
);
};
const SuppliesHub = () => {
const [isExportingDiagnostic, setIsExportingDiagnostic] = useState(false);
const [diagnosticError, setDiagnosticError] = useState('');
const canExportDiagnostic = isSuperAdmin();
const [summary, setSummary] = useState<SupplySummary>(emptySupplySummary);
const [productionCounts, setProductionCounts] = useState({ open: 0, inProgress: 0 });
useEffect(() => {
let isMounted = true;
const loadHub = async () => {
const [supplySummary, productionSummary] = await Promise.all([
fetchSupplySummary(),
fetchProductionOrders(allProductionOrdersRange),
]);
if (!isMounted) return;
setSummary(supplySummary);
setProductionCounts({ open: productionSummary.counts.open || 0, inProgress: productionSummary.counts.in_progress || 0 });
};
void loadHub();
return () => { isMounted = false; };
}, []);
const materialPurchaseNeeds = summary.purchaseNeeds.filter(need => !need.missingReference && !isServicePurchaseNeed(need) && need.purchaseKg > 0);
const noStockMaterialNeeds = materialPurchaseNeeds.filter(need => need.stockKg === 0);
const activeProductionCount = productionCounts.open + productionCounts.inProgress;
const nextAction = materialPurchaseNeeds.length > 0
? {
label: 'Próxima ação',
title: `Comprar ${formatNumber(materialPurchaseNeeds.length, 0)} materiais`,
description: 'A necessidade já desconta saldo e recebimentos para apoiar a decisão de compra.',
meta: 'Ver lista de compra',
icon: BarChart3,
to: '/supplies/purchase-needs?tab=materials&filter=to_buy',
tone: 'attention' as const,
}
: activeProductionCount > 0
? {
label: 'Próxima ação',
title: `Acompanhar ${formatNumber(activeProductionCount, 0)} ordens em execução`,
description: 'Há produção aberta ou em andamento para confirmar antes de planejar uma nova rodada.',
meta: 'Abrir ordens de produção',
icon: ClipboardList,
to: '/production-orders',
tone: 'ready' as const,
}
: {
label: 'Próxima ação',
title: 'Definir o próximo corte',
description: 'Use a demanda e a cobertura para escolher os SKUs que devem entrar em produção.',
meta: 'Abrir plano de corte',
icon: Scissors,
to: '/cutting',
tone: 'default' as const,
};
const NextActionIcon = nextAction.icon;
const handleExportDiagnostic = async () => {
setIsExportingDiagnostic(true);
setDiagnosticError('');
try {
await downloadDatabaseDiagnostic();
} catch (error) {
setDiagnosticError(error instanceof Error ? error.message : 'Não foi possível exportar o diagnóstico do banco.');
} finally {
setIsExportingDiagnostic(false);
}
};
return (
<div className={pageClassName}>
<div className="flex flex-col gap-4 lg:flex-row lg:items-end lg:justify-between">
<Header title="Suprimentos" subtitle="Corte, estoque, malha e compras em um fluxo operacional." />
{canExportDiagnostic && (
<button
type="button"
onClick={handleExportDiagnostic}
disabled={isExportingDiagnostic}
className={`${buttonClassName} w-fit disabled:cursor-not-allowed disabled:opacity-60`}
>
<Download className="h-4 w-4" />
{isExportingDiagnostic ? 'Exportando...' : 'Exportar diagnóstico DB'}
</button>
)}
</div>
{diagnosticError && (
<div className="rounded-xl border border-red-500/30 bg-red-500/10 px-4 py-3 text-sm font-bold text-red-300">
{diagnosticError}
</div>
)}
<section className="grid gap-4 xl:grid-cols-[minmax(0,1.35fr)_minmax(320px,.65fr)]">
<RouterLink to={nextAction.to} className={`group rounded-2xl border p-6 shadow-sm transition-colors hover:border-brand-primary/70 ${nextAction.tone === 'attention' ? 'border-amber-400/30 bg-amber-500/[0.055]' : nextAction.tone === 'ready' ? 'border-emerald-400/30 bg-emerald-500/[0.055]' : 'border-brand-primary/30 bg-brand-primary/[0.045]'}`}>
<div className="flex items-start justify-between gap-5">
<div>
<p className="text-xs font-bold uppercase tracking-[0.14em] text-dark-muted">{nextAction.label}</p>
<h2 className="mt-2 text-xl font-bold text-dark-text">{nextAction.title}</h2>
<p className="mt-2 max-w-2xl text-sm font-semibold leading-6 text-dark-muted">{nextAction.description}</p>
</div>
<div className={`flex h-12 w-12 shrink-0 items-center justify-center rounded-xl border ${nextAction.tone === 'attention' ? 'border-amber-400/30 bg-amber-500/10 text-amber-300' : nextAction.tone === 'ready' ? 'border-emerald-400/30 bg-emerald-500/10 text-emerald-300' : 'border-brand-primary/30 bg-brand-primary/10 text-brand-primary'}`}>
<NextActionIcon className="h-6 w-6" />
</div>
</div>
<div className="mt-6 flex items-center gap-2 text-sm font-bold text-dark-text">
{nextAction.meta}
<ArrowRight className="h-4 w-4 transition-transform group-hover:translate-x-0.5 group-hover:text-brand-primary" />
</div>
</RouterLink>
<div className={`${panelClassName} p-5`}>
<div>
<h2 className="text-base font-bold text-dark-text">Situação operacional</h2>
<p className="mt-1 text-sm font-semibold text-dark-muted">Itens que entram na decisão atual de compra e produção.</p>
</div>
<div className="mt-4 space-y-2">
<FlowBlocker title="Materiais para comprar" description="Faltas reais após saldo e recebimentos" count={materialPurchaseNeeds.length} to="/supplies/purchase-needs?tab=materials&filter=to_buy" tone="attention" />
<FlowBlocker title="Materiais sem saldo" description="Itens necessários sem disponibilidade" count={noStockMaterialNeeds.length} to="/supplies/purchase-needs?tab=materials&filter=no_stock" tone="attention" />
<FlowBlocker title="Produção em execução" description="OPs abertas ou em andamento" count={activeProductionCount} to="/production-orders" tone="info" />
</div>
</div>
</section>
<section>
<div className="mb-3">
<h2 className="text-base font-bold text-dark-text">Fluxo operacional</h2>
<p className="mt-1 text-sm font-semibold text-dark-muted">Da necessidade de material até a produção acompanhada.</p>
</div>
<div className="grid grid-cols-1 gap-4 xl:grid-cols-3">
<FlowStepCard title="Garantir materiais" description="Confira saldo disponível, recebimentos e a compra sugerida." icon={BarChart3} to={materialPurchaseNeeds.length > 0 ? '/supplies/purchase-needs?tab=materials&filter=to_buy' : '/supplies/purchase-needs?tab=materials&filter=all'} tone={materialPurchaseNeeds.length > 0 ? 'attention' : 'ready'} meta={materialPurchaseNeeds.length > 0 ? `${formatNumber(materialPurchaseNeeds.length, 0)} materiais para comprar` : 'Materiais cobertos'} />
<FlowStepCard title="Definir corte" description="Priorize SKUs, cores e tamanhos com necessidade e material disponível." icon={Scissors} to="/cutting" tone="default" meta="Abrir plano de corte" />
<FlowStepCard title="Acompanhar produção" description="Gerencie as OPs criadas a partir do corte até sua finalização." icon={ClipboardList} to="/production-orders" tone={activeProductionCount > 0 ? 'ready' : 'default'} meta={activeProductionCount > 0 ? `${formatNumber(productionCounts.open, 0)} abertas · ${formatNumber(productionCounts.inProgress, 0)} em andamento` : 'Nenhuma OP em execução'} />
</div>
</section>
<section className="border-t border-dark-border pt-6">
<div className="mb-3">
<h2 className="text-base font-bold text-dark-text">Gestão de materiais</h2>
<p className="mt-1 text-sm font-semibold text-dark-muted">Cadastros e controles que sustentam o planejamento operacional.</p>
</div>
<div className="grid grid-cols-1 gap-4 lg:grid-cols-2 xl:grid-cols-4">
<ModuleCard title="Estoque e recebimentos" description="Saldo, lotes, recebimentos, inventário e movimentações de materiais." icon={Package} to="/supplies/inventory" meta={`${formatNumber(summary.stats.pendingReceipts, 0)} recebimentos pendentes · ${formatNumber(summary.stats.activeLots, 0)} lotes ativos`} />
<ModuleCard title="Planejamento de malha" description="Organize a fila de matéria-prima para abastecer compras e corte." icon={Ruler} to="/supplies/fabric-planning" />
<ModuleCard title="Prioridades de estoque" description="Analise reposição, cobertura e itens que precisam de revisão de cadastro." icon={PackageSearch} to="/supplies/current-planning" />
<ModuleCard title="Dados para revisar" description="Consulte exceções de estoque, composição e cadastro fora do cálculo atual." icon={ClipboardList} to="/planning-issues" meta="Abrir pendências" />
</div>
</section>
</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="Prioridades de Estoque" subtitle="Diagnóstico de reposição, compra e dados. O Plano de Corte continua sendo a decisão de produção." 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-[860px] table-fixed border-collapse">
<colgroup>
<col />
<col className="w-[120px]" />
<col className="w-[120px]" />
<col className="w-[120px]" />
<col className="w-[130px]" />
<col className="w-[140px]" />
</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">Produto</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">Próximo passo</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">
<p className="truncate text-sm font-bold text-dark-text" title={row.name}>{row.name}</p>
<div className="mt-1 flex flex-wrap items-center gap-1.5">
<span className="font-mono text-[10px] font-semibold text-dark-muted">#{row.id}</span>
<ProductTypeBadge type={row.productType} />
<span className="text-xs font-semibold text-dark-muted">Cor: {row.color || '-'} · Tam.: {row.size || '-'}</span>
{row.missingData.length ? ` · Falta: ${row.missingData.join(', ')}` : ''}
</div>
</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 === 'apparel' && (
<RouterLink
to="/cutting"
className="inline-flex h-8 items-center gap-1.5 rounded-lg bg-brand-primary/10 px-2.5 text-xs font-bold text-brand-primary transition-opacity hover:opacity-80"
title="Abrir Plano de Corte"
>
<Scissors className="h-3.5 w-3.5" /> Corte
</RouterLink>
)}
{queue === 'inputs' && (
<RouterLink
to="/supplies/purchase-needs?tab=materials&filter=to_buy"
className="inline-flex h-8 items-center gap-1.5 rounded-lg bg-brand-primary/10 px-2.5 text-xs font-bold text-brand-primary transition-opacity hover:opacity-80"
title="Abrir Necessidade de Compra"
>
<BarChart3 className="h-3.5 w-3.5" /> Compra
</RouterLink>
)}
{queue === 'review' && (
<RouterLink
to="/planning-issues"
className="inline-flex h-8 items-center gap-1.5 rounded-lg bg-dark-input px-2.5 text-xs font-bold text-dark-text transition-colors hover:bg-dark-border"
title="Abrir dados pendentes"
>
<Pencil className="h-3.5 w-3.5" /> Corrigir
</RouterLink>
)}
{queue !== 'apparel' && queue !== 'inputs' && queue !== 'review' && (
<RouterLink
to={`/products/${row.id}`}
className="inline-flex h-8 items-center gap-1.5 rounded-lg bg-brand-primary/10 px-2.5 text-xs font-bold text-brand-primary transition-opacity hover:opacity-80"
title={`Ver SKU ${row.id}`}
>
Ver produto <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,
stock,
onNewReceipt,
onOpenStock,
}: {
summary: SupplySummary;
stock: StockData[];
onNewReceipt: () => void;
onOpenStock: () => 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);
const negativeStockCount = stock.filter(item => Number(item.saldo || 0) < 0).length;
const zeroStockCount = stock.filter(item => Number(item.saldo || 0) === 0).length;
const latestStockUpdate = stock
.map(item => item.updated_at ? new Date(item.updated_at).getTime() : 0)
.filter(Boolean)
.sort((a, b) => b - a)[0] || null;
return (
<div className="space-y-4">
<div className="grid grid-cols-1 gap-4 md:grid-cols-4">
<button type="button" onClick={onOpenStock} className="rounded-2xl border border-dark-border bg-dark-card p-5 text-left shadow-sm transition-colors hover:border-brand-primary/35 cursor-pointer">
<p className="text-xs font-bold uppercase tracking-widest text-dark-muted">Estoque sincronizado</p>
<p className="mt-2 text-3xl font-bold text-dark-text">{formatNumber(stock.length, 0)}</p>
<p className="mt-1 text-xs font-semibold text-dark-muted">SKUs recebidos do Tiny</p>
</button>
<button type="button" onClick={onOpenStock} className={`rounded-2xl border p-5 text-left shadow-sm transition-colors cursor-pointer ${negativeStockCount ? 'border-red-400/35 bg-red-400/[0.055] hover:border-red-400/60' : 'border-dark-border bg-dark-card hover:border-brand-primary/35'}`}>
<p className="text-xs font-bold uppercase tracking-widest text-dark-muted">Saldos a conferir</p>
<p className={`mt-2 text-3xl font-bold ${negativeStockCount ? 'text-red-300' : 'text-dark-text'}`}>{formatNumber(negativeStockCount, 0)}</p>
<p className="mt-1 text-xs font-semibold text-dark-muted">SKUs com saldo negativo</p>
</button>
<button type="button" onClick={onNewReceipt} className="rounded-2xl border border-dark-border bg-dark-card p-5 text-left shadow-sm transition-colors hover:border-brand-primary/35 cursor-pointer">
<p className="text-xs font-bold uppercase tracking-widest text-dark-muted">Recebimentos pendentes</p>
<p className="mt-2 text-3xl font-bold text-amber-300">{formatNumber(summary.stats.pendingReceipts, 0)}</p>
<p className="mt-1 text-xs font-semibold text-dark-muted">Entradas aguardando aprovação</p>
</button>
<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">Lotes locais</p>
<p className="mt-2 text-3xl font-bold text-brand-primary">{formatNumber(summary.stats.activeLots, 0)}</p>
<p className="mt-1 text-xs font-semibold text-dark-muted">Controle físico registrado aqui</p>
</div>
</div>
<div className="grid grid-cols-1 gap-4 xl:grid-cols-[minmax(0,1.15fr)_minmax(340px,.85fr)]">
<div className={`${panelClassName} p-5`}>
<div className="flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between">
<div>
<h2 className="text-base font-bold text-dark-text">Estoque físico por categoria</h2>
<p className="mt-1 text-sm font-semibold text-dark-muted">Lotes aprovados por recebimento ou inventário local.</p>
</div>
<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">{formatNumber(summary.stats.rolls, 0)} rolos</span>
</div>
{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">Nenhum lote físico registrado</h3>
<p className="text-sm font-semibold text-dark-muted">O saldo do Tiny aparece na aba Estoque. Registre recebimentos para controlar lotes aqui.</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" /> Conferir antes de planejar</h2>
<div className="mt-4 space-y-3">
<button type="button" onClick={onOpenStock} className="flex w-full items-center justify-between gap-3 rounded-xl border border-red-400/25 bg-red-400/[0.045] px-3.5 py-3 text-left transition-colors hover:border-red-400/55 cursor-pointer">
<span><span className="block text-sm font-bold text-dark-text">Saldos negativos</span><span className="block text-xs font-semibold text-dark-muted">Confirmar ajuste ou entrada no Tiny</span></span>
<span className="text-lg font-bold text-red-300">{formatNumber(negativeStockCount, 0)}</span>
</button>
<button type="button" onClick={onOpenStock} className="flex w-full items-center justify-between gap-3 rounded-xl border border-dark-border bg-dark-input/45 px-3.5 py-3 text-left transition-colors hover:border-brand-primary/45 cursor-pointer">
<span><span className="block text-sm font-bold text-dark-text">Sem saldo no Tiny</span><span className="block text-xs font-semibold text-dark-muted">SKUs sincronizados com zero</span></span>
<span className="text-lg font-bold text-dark-text">{formatNumber(zeroStockCount, 0)}</span>
</button>
<p className="pt-1 text-xs font-semibold text-dark-muted">Última sincronização: {latestStockUpdate ? formatDateTime(new Date(latestStockUpdate).toISOString()) : 'sem atualização registrada'}.</p>
</div>
</div>
</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 [searchParams, setSearchParams] = useSearchParams();
const requestedTab = searchParams.get('tab');
const activeTab = inventoryTabs.some(tab => tab.id === requestedTab) ? requestedTab as InventoryTab : 'dashboard';
const setActiveTab = (tab: InventoryTab) => setSearchParams(tab === 'dashboard' ? {} : { tab });
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} stock={stockSnapshot} onNewReceipt={() => setActiveTab('receipts')} onOpenStock={() => setActiveTab('stock')} />}
{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 [summary, setSummary] = useState<SupplySummary>(emptySupplySummary);
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 fabricNeeds = summary.purchaseNeeds.filter(need => (
!need.missingReference &&
!isServicePurchaseNeed(need) &&
/\b(?:MALHA|TECIDO|RIBANA|MOLETOM)\b/i.test(need.material)
));
const fabricNeedsToBuy = fabricNeeds.filter(need => need.purchaseKg > 0);
const fabricNeedUnits = new Set(fabricNeeds.flatMap(need => (need.products || []).map(product => product.productId).filter(Boolean))).size;
const plannedFabricKg = fabricNeeds.reduce((total, need) => total + Math.max(0, need.plannedKg), 0);
const loadPlans = async () => {
setErrorMessage('');
const summary = await fetchSupplySummary();
setPlans(summary.fabricPlans);
setSummary(summary);
};
useEffect(() => {
let isMounted = true;
const load = async () => {
setIsLoading(true);
try {
const summary = await fetchSupplySummary();
if (isMounted) {
setPlans(summary.fabricPlans);
setSummary(summary);
}
} 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 tecidos que abastece o corte; a compra continua sendo decidida em Necessidade de Compra." 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">Tecidos com necessidade</p>
<p className="mt-2 text-3xl font-bold text-dark-text">{fabricNeedsToBuy.length}</p>
<p className="mt-1 text-xs font-semibold text-dark-muted">{formatNumber(fabricNeedUnits, 0)} SKUs dependem deles</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">Demanda de tecido</p>
<p className="mt-2 text-3xl font-bold text-dark-text">{formatNumber(plannedFabricKg)} kg</p>
<p className="mt-1 text-xs font-semibold text-dark-muted">necessidade planejada para o período</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">Planos locais ativos</p>
<p className="mt-2 text-3xl font-bold text-dark-text">{plans.length}</p>
<p className="mt-1 text-xs font-semibold text-dark-muted">{plans.filter(plan => plan.priority === 'Crítico').length} críticos</p>
</div>
</div>
<div className={`${panelClassName} overflow-hidden`}>
<div className="flex flex-col gap-4 border-b border-dark-border p-5 md:flex-row md:items-start md:justify-between">
<div>
<h2 className="text-base font-bold text-dark-text">Fila de tecidos para abastecer o corte</h2>
<p className="mt-1 text-sm font-semibold text-dark-muted">Necessidade calculada pela composição, saldo sincronizado e recebimentos pendentes.</p>
</div>
<RouterLink to="/supplies/purchase-needs?tab=materials&filter=to_buy" className={buttonClassName}>Abrir compra de materiais <ArrowRight className="h-4 w-4" /></RouterLink>
</div>
{isLoading ? (
<div className={emptyStateClassName}>
<Ruler className="h-8 w-8 text-brand-primary" />
<h3 className="text-base font-bold text-dark-text">Calculando fila de tecidos...</h3>
</div>
) : fabricNeeds.length ? (
<div className="divide-y divide-dark-border">
{fabricNeeds.slice().sort((a, b) => b.purchaseKg - a.purchaseKg).map(need => {
const impactedSkus = new Set((need.products || []).map(product => product.productId).filter(Boolean)).size;
const isCovered = need.purchaseKg <= 0;
return (
<div key={need.material} className="grid grid-cols-1 gap-4 px-5 py-4 lg:grid-cols-[minmax(240px,1.25fr)_minmax(360px,1fr)_150px] lg:items-center">
<div>
<p className="text-sm font-bold text-dark-text">{need.material}</p>
<p className="mt-1 text-xs font-semibold text-dark-muted">{impactedSkus} SKUs impactados · {need.suppliers.length ? need.suppliers.join(' · ') : 'Fornecedor não informado'}</p>
</div>
<div className="grid grid-cols-2 gap-2 sm:grid-cols-4">
{[
['Necessário', need.plannedKg],
['Em estoque', need.stockKg],
['A receber', need.pendingKg],
['Comprar', need.purchaseKg],
].map(([label, value]) => (
<div key={label as string} className="rounded-lg border border-dark-border bg-dark-input/45 px-3 py-2">
<p className="text-[10px] font-bold uppercase tracking-wider text-dark-muted">{label}</p>
<p className={`mt-1 text-sm font-bold ${label === 'Comprar' && !isCovered ? 'text-amber-300' : label === 'Em estoque' && Number(value) < 0 ? 'text-red-300' : 'text-dark-text'}`}>{formatNumber(Number(value))} {need.unit || 'kg'}</p>
</div>
))}
</div>
<div className="lg:text-right">
<span className={`inline-flex rounded-full border px-2.5 py-1 text-xs font-bold ${isCovered ? 'border-emerald-400/30 bg-emerald-400/10 text-emerald-300' : 'border-amber-400/30 bg-amber-400/10 text-amber-300'}`}>{isCovered ? 'Coberto' : 'Comprar tecido'}</span>
</div>
</div>
);
})}
</div>
) : (
<div className={emptyStateClassName}>
<Ruler className="h-8 w-8 text-brand-primary" />
<h3 className="text-base font-bold text-dark-text">Nenhum tecido identificado na composição</h3>
<p className="text-sm font-semibold text-dark-muted">Mapeie consumo de malha, tecido ou ribana para gerar esta fila.</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 locais de recebimento e alocação</h2>
<p className="mt-1 text-sm font-semibold text-dark-muted">Registros operacionais internos; não substituem a necessidade calculada acima.</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 getNeedDisplayName = (need: SupplyPurchaseNeed) => (
need.material.replace(/^Cadastrar (consumo|rendimento):\s*/i, '')
);
const hasNegativeMaterialBalance = (need: SupplyPurchaseNeed) => need.stockKg < 0;
type PurchaseNeedTab = 'materials' | 'services';
type MaterialPurchaseFilter = 'to_buy' | 'negative_balance' | 'no_stock' | 'all';
const sumNeedProducts = (
need: SupplyPurchaseNeed,
field: 'suggestedQuantity'
) => (
(need.products || []).reduce((total, product) => total + Number(product[field] || 0), 0)
);
const AffectedProducts = ({ products }: { products: SupplyPurchaseNeed['products'] }) => {
if (!products?.length) return null;
return (
<div className="mt-3 flex flex-wrap items-center gap-2 border-t border-dark-border/70 pt-3">
<span className="text-[10px] font-bold uppercase tracking-widest text-dark-muted">Produtos afetados</span>
{products.slice(0, 3).map(product => (
<div key={product.productId} className="flex max-w-full items-center overflow-hidden rounded-md border border-dark-border bg-dark-input">
<RouterLink
to={`/products/${product.productId}`}
className="min-w-0 cursor-pointer truncate px-2 py-1 text-[11px] font-bold text-dark-muted transition-colors hover:text-brand-primary"
title={product.name}
>
#{product.productId} · {product.name}
</RouterLink>
<RouterLink
to={buildConsumptionReferencePath({ sku: product.productId, name: product.name })}
className="flex h-7 w-7 shrink-0 items-center justify-center border-l border-dark-border text-sky-300 transition-colors hover:bg-sky-400/10"
title={`Editar composição de ${product.name}`}
aria-label={`Editar composição de ${product.name}`}
>
<Pencil className="h-3.5 w-3.5" />
</RouterLink>
<RouterLink
to={`/products/${product.productId}`}
className="flex h-7 w-7 shrink-0 items-center justify-center border-l border-dark-border text-brand-primary transition-colors hover:bg-brand-primary/10"
title={`Ver produto ${product.name}`}
aria-label={`Ver produto ${product.name}`}
>
<Eye className="h-3.5 w-3.5" />
</RouterLink>
</div>
))}
{products.length > 3 && <span className="text-xs font-bold text-dark-muted">+{products.length - 3} SKUs</span>}
</div>
);
};
const PurchaseMaterialQueue = ({ needs }: { needs: SupplyPurchaseNeed[] }) => (
<>
<div className="border-b border-dark-border bg-dark-input/40 px-4 py-3">
<div className="grid gap-3 text-[10px] font-bold uppercase tracking-widest text-dark-muted xl:grid-cols-[minmax(300px,1.35fr)_minmax(380px,1fr)_150px]">
<span>Material e impacto</span>
<span className="hidden xl:block">Como a compra foi calculada</span>
<span className="hidden text-right xl:block">Decisão</span>
</div>
</div>
<div className="divide-y divide-dark-border">
{needs.map(need => {
const isNegative = hasNegativeMaterialBalance(need);
const isNoStock = need.stockKg === 0;
const isCovered = need.purchaseKg <= 0;
const statusTone = isNegative
? 'border-red-400/35 bg-red-400/10 text-red-300'
: isNoStock
? 'border-red-400/30 bg-red-400/10 text-red-300'
: isCovered
? 'border-emerald-400/30 bg-emerald-400/10 text-emerald-300'
: 'border-amber-400/30 bg-amber-400/10 text-amber-300';
const statusLabel = isNegative
? 'Saldo negativo'
: isNoStock
? 'Sem saldo'
: isCovered
? 'Coberto'
: 'Comprar';
const accent = isNegative || isNoStock ? 'border-l-red-400' : isCovered ? 'border-l-emerald-400' : 'border-l-amber-400';
const products = need.products || [];
return (
<article key={need.material} className={`border-l-2 ${accent} px-4 py-4 transition-colors hover:bg-dark-input/35`}>
<div className="grid gap-4 xl:grid-cols-[minmax(300px,1.35fr)_minmax(380px,1fr)_150px] xl:items-center">
<div className="min-w-0">
<div className="flex flex-wrap items-center gap-2">
<h3 className="text-sm font-bold text-dark-text">{getNeedDisplayName(need)}</h3>
<span className={`inline-flex rounded-full border px-2 py-0.5 text-[10px] font-bold ${statusTone}`}>{statusLabel}</span>
</div>
<p className="mt-1 text-xs font-semibold text-dark-muted">
{need.colors.length ? need.colors.join(', ') : 'Todas as cores'}
<span className="px-1.5 text-dark-border">•</span>
{need.suppliers.length ? need.suppliers.join(', ') : 'Fornecedor não informado'}
</p>
</div>
<div className="grid grid-cols-3 gap-2 sm:grid-cols-4">
<div className="rounded-lg bg-dark-input/75 px-3 py-2">
<p className="text-[10px] font-bold uppercase tracking-wider text-dark-muted">Necessário</p>
<p className="mt-1 text-sm font-bold text-dark-text">{formatNeedQuantity(need, need.plannedKg)}</p>
</div>
<div className="rounded-lg bg-dark-input/75 px-3 py-2">
<p className="text-[10px] font-bold uppercase tracking-wider text-dark-muted">Estoque</p>
<p className={`mt-1 text-sm font-bold ${isNegative ? 'text-red-300' : 'text-dark-text'}`}>{formatNeedQuantity(need, need.stockKg)}</p>
</div>
<div className="rounded-lg bg-dark-input/75 px-3 py-2">
<p className="text-[10px] font-bold uppercase tracking-wider text-dark-muted">A receber</p>
<p className="mt-1 text-sm font-bold text-dark-text">{formatNeedQuantity(need, need.pendingKg)}</p>
</div>
<div className="col-span-3 rounded-lg border border-brand-primary/20 bg-brand-primary/[0.06] px-3 py-2 sm:col-span-1">
<p className="text-[10px] font-bold uppercase tracking-wider text-brand-primary">Comprar</p>
<p className={`mt-1 text-sm font-bold ${isCovered ? 'text-emerald-300' : 'text-brand-primary'}`}>{formatNeedQuantity(need, need.purchaseKg)}</p>
</div>
</div>
<div className="xl:text-right">
<p className="text-[10px] font-bold uppercase tracking-widest text-dark-muted">Impacto</p>
<p className="mt-1 text-sm font-bold text-dark-text">{products.length} {products.length === 1 ? 'SKU' : 'SKUs'} dependem</p>
<p className="mt-1 text-xs font-semibold text-dark-muted">{formatNumber(sumNeedProducts(need, 'suggestedQuantity'))} un. planejadas</p>
</div>
</div>
<AffectedProducts products={products} />
</article>
);
})}
</div>
</>
);
const PurchaseServiceQueue = ({ needs }: { needs: SupplyPurchaseNeed[] }) => (
<>
<div className="border-b border-dark-border bg-dark-input/40 px-4 py-3">
<div className="grid gap-3 text-[10px] font-bold uppercase tracking-widest text-dark-muted xl:grid-cols-[minmax(320px,1.4fr)_minmax(260px,1fr)_180px]">
<span>Serviço e impacto</span>
<span className="hidden xl:block">Volume a programar</span>
<span className="hidden text-right xl:block">Próximo passo</span>
</div>
</div>
<div className="divide-y divide-dark-border">
{needs.map(need => {
const products = need.products || [];
const isCovered = need.purchaseKg <= 0;
return (
<article key={need.material} className={`border-l-2 ${isCovered ? 'border-l-emerald-400' : 'border-l-sky-400'} px-4 py-4 transition-colors hover:bg-dark-input/35`}>
<div className="grid gap-4 xl:grid-cols-[minmax(320px,1.4fr)_minmax(260px,1fr)_180px] xl:items-center">
<div className="min-w-0">
<div className="flex flex-wrap items-center gap-2">
<h3 className="text-sm font-bold text-dark-text">{getNeedDisplayName(need)}</h3>
<span className={`inline-flex rounded-full border px-2 py-0.5 text-[10px] font-bold ${isCovered ? 'border-emerald-400/30 bg-emerald-400/10 text-emerald-300' : 'border-sky-400/30 bg-sky-400/10 text-sky-300'}`}>
{isCovered ? 'Sem programação' : 'Programar serviço'}
</span>
</div>
<p className="mt-1 text-xs font-semibold text-dark-muted">
{need.colors.length ? need.colors.join(', ') : 'Todas as cores'}
<span className="px-1.5 text-dark-border">•</span>
{need.suppliers.length ? need.suppliers.join(', ') : 'Fornecedor não informado'}
</p>
</div>
<div className="grid grid-cols-2 gap-2">
<div className="rounded-lg bg-dark-input/75 px-3 py-2">
<p className="text-[10px] font-bold uppercase tracking-wider text-dark-muted">Volume previsto</p>
<p className="mt-1 text-sm font-bold text-dark-text">{formatNeedQuantity(need, need.plannedKg)}</p>
</div>
<div className="rounded-lg border border-sky-400/20 bg-sky-400/[0.06] px-3 py-2">
<p className="text-[10px] font-bold uppercase tracking-wider text-sky-300">A programar</p>
<p className={`mt-1 text-sm font-bold ${isCovered ? 'text-emerald-300' : 'text-sky-300'}`}>{formatNeedQuantity(need, need.purchaseKg)}</p>
</div>
</div>
<div className="xl:text-right">
<p className="text-[10px] font-bold uppercase tracking-widest text-dark-muted">Impacto</p>
<p className="mt-1 text-sm font-bold text-dark-text">{products.length} {products.length === 1 ? 'SKU' : 'SKUs'} dependem</p>
<p className="mt-1 text-xs font-semibold text-dark-muted">{formatNumber(sumNeedProducts(need, 'suggestedQuantity'))} un. planejadas</p>
</div>
</div>
<AffectedProducts products={products} />
</article>
);
})}
</div>
</>
);
const PurchaseNeedsScreen = () => {
const [searchParams, setSearchParams] = useSearchParams();
const [summary, setSummary] = useState<SupplySummary>(emptySupplySummary);
const [isLoading, setIsLoading] = useState(true);
const [search, setSearch] = useState('');
const requestedTab = searchParams.get('tab');
const requestedFilter = searchParams.get('filter');
const activeTab: PurchaseNeedTab = requestedTab === 'services' ? 'services' : 'materials';
const materialFilter: MaterialPurchaseFilter = requestedFilter === 'negative_balance' || requestedFilter === 'no_stock' || requestedFilter === 'all' ? requestedFilter : 'to_buy';
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 openQueue = (tab: PurchaseNeedTab, filter: MaterialPurchaseFilter = 'to_buy') => {
setSearch('');
setCurrentPage(1);
setSearchParams(tab === 'materials' ? { tab, filter } : { tab });
};
const materialNeeds = summary.purchaseNeeds.filter(need => !need.missingReference && !isServicePurchaseNeed(need));
const serviceNeeds = summary.purchaseNeeds.filter(need => !need.missingReference && isServicePurchaseNeed(need));
const purchaseNeeds = materialNeeds.filter(need => need.purchaseKg > 0);
const negativeBalanceNeeds = materialNeeds.filter(hasNegativeMaterialBalance);
const noStockNeeds = materialNeeds.filter(need => need.stockKg === 0 && need.purchaseKg > 0);
const filteredMaterialNeeds = materialNeeds.filter(need => {
if (materialFilter === 'to_buy') return need.purchaseKg > 0;
if (materialFilter === 'negative_balance') return hasNegativeMaterialBalance(need);
if (materialFilter === 'no_stock') return need.stockKg === 0 && need.purchaseKg > 0;
return true;
});
const activeNeeds = activeTab === 'materials'
? [...filteredMaterialNeeds].sort((a, b) => {
if (hasNegativeMaterialBalance(a) !== hasNegativeMaterialBalance(b)) return hasNegativeMaterialBalance(a) ? -1 : 1;
if (a.status !== b.status) return a.status === 'critical' ? -1 : b.status === 'critical' ? 1 : 0;
return b.purchaseKg - a.purchaseKg;
})
: serviceNeeds;
const normalizedSearch = normalizeSearch(search);
const visibleNeeds = activeNeeds.filter(need => (
!normalizedSearch || normalizeSearch(`${need.material} ${getNeedDisplayName(need)} ${need.suppliers.join(' ')} ${need.colors.join(' ')} ${(need.products || []).map(product => `${product.productId} ${product.name}`).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 = purchaseNeeds.length;
const suggestedPurchaseSummary = summarizePurchaseNeeds(purchaseNeeds);
const pageTitle = 'Necessidade de Compra';
const pageSubtitle = 'Materiais e serviços terceirizados para execução; pendências de cadastro ficam em Dados para revisar.';
const materialFilters: Array<{ id: MaterialPurchaseFilter; label: string; description: string; count: number }> = [
{ id: 'to_buy', label: 'Comprar agora', description: 'Materiais com saldo insuficiente para a demanda.', count: purchaseItemCount },
{ id: 'negative_balance', label: 'Saldo negativo', description: 'Divergências de estoque que precisam de conferência.', count: negativeBalanceNeeds.length },
{ id: 'no_stock', label: 'Sem saldo', description: 'Materiais sem saldo disponível no Tiny.', count: noStockNeeds.length },
{ id: 'all', label: 'Todos os materiais', description: 'Inclui itens cobertos para auditoria da conta.', count: materialNeeds.length },
];
const activeMaterialFilter = materialFilters.find(filter => filter.id === materialFilter);
const panelTitle = activeTab === 'materials' ? activeMaterialFilter?.label || 'Compra de materiais' : 'Serviços terceirizados';
const panelSubtitle = activeTab === 'materials'
? activeMaterialFilter?.description || 'Demanda de produto → material → saldo disponível → quantidade para comprar.'
: 'Necessidades de beneficiamento e serviços. Não entram no total de compra de materiais.';
return (
<div className={pageClassName}>
<Header title={pageTitle} subtitle={pageSubtitle} backTo="/supplies" />
<div className="grid grid-cols-1 gap-4 md:grid-cols-3">
<button
type="button"
onClick={() => openQueue('materials', 'to_buy')}
className={`cursor-pointer rounded-2xl border p-5 text-left shadow-sm transition-colors ${activeTab === 'materials' && materialFilter === 'to_buy' ? 'border-brand-primary/45 bg-brand-primary/[0.07]' : 'border-dark-border bg-dark-card hover:border-brand-primary/35'}`}
>
<p className="text-xs font-bold uppercase tracking-widest text-dark-muted">Materiais para comprar</p>
<p className="mt-2 text-3xl font-bold text-dark-text">{purchaseItemCount}</p>
<p className="mt-1 text-xs font-semibold text-dark-muted">itens com falta real</p>
</button>
<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">Compra sugerida</p>
<p className="mt-2 text-2xl font-bold text-brand-primary">{suggestedPurchaseSummary}</p>
<p className="mt-1 text-xs font-semibold text-dark-muted">necessidade após saldo e recebimentos</p>
</div>
<button
type="button"
onClick={() => openQueue('materials', 'negative_balance')}
className={`cursor-pointer rounded-2xl border p-5 text-left shadow-sm transition-colors ${activeTab === 'materials' && materialFilter === 'negative_balance' ? 'border-red-400/45 bg-red-400/[0.07]' : 'border-dark-border bg-dark-card hover:border-red-400/35'}`}
>
<p className="text-xs font-bold uppercase tracking-widest text-dark-muted">Saldos a conferir</p>
<p className="mt-2 text-3xl font-bold text-red-300">{negativeBalanceNeeds.length}</p>
<p className="mt-1 text-xs font-semibold text-dark-muted">materiais com estoque negativo</p>
</button>
</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">{panelTitle}</h2>
<p className="mt-1 text-sm font-semibold text-dark-muted">{panelSubtitle}</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-${activeTab}.csv`, visibleNeeds.map(need => ({
material: getNeedDisplayName(need),
planejado: need.plannedKg,
estoque: need.stockKg,
pendente: need.pendingKg,
comprar: 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>
<div className="mt-5 flex flex-col gap-3 xl:flex-row xl:items-center xl:justify-between">
<div className="inline-flex w-fit rounded-xl border border-dark-border bg-dark-input p-1">
{[
{ id: 'materials' as const, label: `Compra (${purchaseItemCount})` },
{ id: 'services' as const, label: `Serviços (${serviceNeeds.length})` },
].map(tab => (
<button key={tab.id} type="button" onClick={() => openQueue(tab.id)} className={`rounded-lg px-3 py-2 text-xs font-bold transition-colors cursor-pointer ${activeTab === tab.id ? 'bg-brand-primary text-brand-contrast' : 'text-dark-muted hover:text-dark-text'}`}>
{tab.label}
</button>
))}
</div>
<label className="relative block w-full xl:max-w-md">
<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={activeTab === 'services' ? 'Buscar por serviço ou SKU impactado...' : 'Buscar por material, fornecedor ou cor...'}
/>
</label>
</div>
{activeTab === 'materials' && (
<div className="mt-4 flex flex-wrap gap-2">
{materialFilters.map(filter => (
<button
key={filter.id}
type="button"
onClick={() => openQueue('materials', filter.id)}
className={`cursor-pointer rounded-lg border px-3 py-2 text-left text-xs font-bold transition-colors ${materialFilter === filter.id ? 'border-brand-primary/40 bg-brand-primary/10 text-brand-primary' : 'border-dark-border bg-dark-input text-dark-muted hover:border-brand-primary/35 hover:text-dark-text'}`}
title={filter.description}
>
{filter.label} <span className="ml-1 opacity-75">{filter.count}</span>
</button>
))}
</div>
)}
{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">
{activeTab === 'materials' ? (
<PurchaseMaterialQueue needs={paginatedNeeds} />
) : (
<PurchaseServiceQueue needs={paginatedNeeds} />
)}
</div>
<PaginationControls
totalItems={visibleNeeds.length}
currentPage={safeCurrentPage}
totalPages={totalPages}
pageSize={itemsPerPage}
pageSizeOptions={[10, 20, 50, 100]}
itemLabel={activeTab === 'materials' ? 'materiais' : 'serviços'}
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;