Files
graphs/src/pages/Supplies.tsx
2026-08-28 09:36:05 -03:00

2832 lines
153 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,
Factory,
GitBranch,
Link as LinkIcon,
Package,
PackageSearch,
Pencil,
RefreshCw,
Repeat2,
Ruler,
Save,
Search,
Scissors,
ShieldCheck,
Truck,
Trash2,
Warehouse,
X,
} from 'lucide-react';
import { buildSkuEditPath } from '../catalogLinks';
import DateRangePicker from '../components/DateRangePicker';
import PaginationControls from '../components/PaginationControls';
import ProductTypeBadge from '../components/ProductTypeBadge';
import StockGraphsBalanceModal from '../components/StockGraphsBalanceModal';
import { classifyCutFamily } from '../analytics/cutting';
import { adjustSupplyLotInventory, approveSupplyReceipt, consumeSupplyLotForProduction, createSupplyFabricPlan, createSupplyMaterialTransformation, createSupplyPurchaseOrder, createSupplyReceipt, deleteSupplyFabricPlan, deleteSupplyReceipt, downloadDatabaseDiagnostic, fetchCuttingSettings, fetchProductAnalytics, fetchProductionOrders, fetchStock, fetchSupplyMaterialTransformations, fetchSupplyPurchaseOrders, fetchSupplySummary, fetchSupplySuppliers, isSuperAdmin, saveSupplyMaterialDetails, updateSupplyMaterialTransformationStatus, updateSupplyPurchaseOrderStatus } from '../dataService';
import { parseProductName } from '../productParsing';
import { resolveProductType, type ProductTypeKey } from '../productClassification';
import { getPlanningStock } from '../planningStock';
import type { CuttingSettings, DateRange, MaterialTransformationStage, MaterialTransformationStatus, ProductAnalyticsItem, StockData, SupplyFabricPlan, SupplyLot, SupplyMaterialTransformation, SupplyMovement, SupplyPurchaseNeed, SupplyPurchaseOrder, SupplyPurchaseOrderStatus, 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 normalizeMaterialKey = (value: string) => normalizeSearch(value).replace(/\s+/g, ' ').trim();
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.cutting || 0) + (productionSummary.counts.sewing || 0) + (productionSummary.counts.review || 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-4">
<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="Planejar 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="Planos de produção" description="Organize os cortes em planos, defina o responsável e acompanhe a liberação." icon={ClipboardCheck} to="/production-plans" tone="default" meta="Abrir planos de produção" />
<FlowStepCard title="Ordens de produção" description="Acompanhe as OPs criadas a partir do plano 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="Ordens de compra" description="Formalize pedidos de materiais, fornecedor, prazo e valor antes do recebimento." icon={ClipboardList} to="/supplies/purchase-orders" meta="Criar e acompanhar OCs" />
<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="Transformações e perdas" description="Planeje tecelagem, tinturaria, cores e perda esperada sem alterar o estoque físico." icon={Factory} to="/supplies/material-transformations" />
<ModuleCard title="Qualidade da malha" description="Acompanhe furos, perdas e origem dos defeitos por lote, posto e OP." icon={ShieldCheck} to="/production-quality" />
<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" />
</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>
<div><p className="text-sm font-semibold text-dark-muted">{receipt.supplier}</p>{receipt.purchaseOrderNumber && <RouterLink to="/supplies/purchase-orders" className="mt-1 inline-flex text-xs font-bold text-brand-primary hover:underline">{receipt.purchaseOrderNumber} · {receipt.purchaseOrderStatus === 'pending_financial' ? 'aguarda financeiro' : receipt.purchaseOrderStatus === 'approved' ? 'aprovada' : receipt.purchaseOrderStatus === 'received' ? 'recebida' : receipt.purchaseOrderStatus}</RouterLink>}</div>
<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' && (receipt.purchaseOrderStatus === 'approved' || receipt.purchaseOrderStatus === 'received') && <button type="button" onClick={() => onApprove(receipt.id)} className={buttonClassName}>Liberar estoque</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">
Ao registrar, o Graphs cria uma OC interna pendente para este recebimento. O material só entra no estoque após a aprovação financeira dessa OC.
</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="Informe o fornecedor" />
</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 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';
type ServicePurchaseFilter = 'to_schedule' | 'covered' | 'all';
type PurchaseNeedFilter = MaterialPurchaseFilter | ServicePurchaseFilter;
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 => (
<RouterLink
key={product.productId}
to={`/products/${product.productId}`}
className="max-w-full cursor-pointer truncate rounded-md border border-dark-border bg-dark-input px-2 py-1 text-[11px] font-bold text-dark-muted transition-colors hover:border-brand-primary hover:text-brand-primary"
title={product.name}
>
#{product.productId} · {product.name}
</RouterLink>
))}
{products.length > 3 && <span className="text-xs font-bold text-dark-muted">+{products.length - 3} SKUs</span>}
</div>
);
};
const PurchaseMaterialQueue = ({ needs, onEditMaterial }: { needs: SupplyPurchaseNeed[]; onEditMaterial: (need: SupplyPurchaseNeed) => void }) => (
<>
<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(280px,1.35fr)_minmax(380px,1fr)_150px_88px]">
<span>Material</span>
<span className="hidden xl:block">Como a compra foi calculada</span>
<span className="hidden text-right xl:block">Impacto</span>
<span className="hidden text-right xl:block">Ações</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(280px,1.35fr)_minmax(380px,1fr)_150px_88px] 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.preferredSupplier || (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 className="flex items-center gap-2 xl:justify-self-end">
<button
type="button"
onClick={() => onEditMaterial(need)}
className="inline-flex h-8 w-8 cursor-pointer items-center justify-center rounded-lg bg-dark-input text-dark-text transition-colors hover:bg-dark-border"
title={`Editar dados de ${getNeedDisplayName(need)}`}
aria-label={`Editar dados de ${getNeedDisplayName(need)}`}
>
<Pencil className="h-3.5 w-3.5" />
</button>
<RouterLink
to={getMaterialRegistrationPath(need.material)}
className="inline-flex h-8 w-8 cursor-pointer items-center justify-center rounded-lg bg-brand-primary/10 text-brand-primary transition-opacity hover:opacity-80"
title={`Ver cadastro de ${getNeedDisplayName(need)}`}
aria-label={`Ver cadastro de ${getNeedDisplayName(need)}`}
>
<Eye className="h-3.5 w-3.5" />
</RouterLink>
</div>
</div>
<AffectedProducts products={products} />
</article>
);
})}
</div>
</>
);
const PurchaseServiceQueue = ({ needs, onEditMaterial }: { needs: SupplyPurchaseNeed[]; onEditMaterial: (need: SupplyPurchaseNeed) => void }) => (
<>
<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.4fr)_minmax(260px,1fr)_150px_88px]">
<span>Serviço</span>
<span className="hidden xl:block">Volume a programar</span>
<span className="hidden text-right xl:block">Impacto</span>
<span className="hidden text-right xl:block">Ações</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(300px,1.4fr)_minmax(260px,1fr)_150px_88px] 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.preferredSupplier || (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 className="flex items-center gap-2 xl:justify-self-end">
<button
type="button"
onClick={() => onEditMaterial(need)}
className="inline-flex h-8 w-8 cursor-pointer items-center justify-center rounded-lg bg-dark-input text-dark-text transition-colors hover:bg-dark-border"
title={`Editar dados de ${getNeedDisplayName(need)}`}
aria-label={`Editar dados de ${getNeedDisplayName(need)}`}
>
<Pencil className="h-3.5 w-3.5" />
</button>
<RouterLink
to={getMaterialRegistrationPath(need.material)}
className="inline-flex h-8 w-8 cursor-pointer items-center justify-center rounded-lg bg-brand-primary/10 text-brand-primary transition-opacity hover:opacity-80"
title={`Ver cadastro de ${getNeedDisplayName(need)}`}
aria-label={`Ver cadastro de ${getNeedDisplayName(need)}`}
>
<Eye className="h-3.5 w-3.5" />
</RouterLink>
</div>
</div>
<AffectedProducts products={products} />
</article>
);
})}
</div>
</>
);
const getMaterialRegistrationSku = (material: string) => {
const [candidate] = material.trim().split(/\s+/, 1);
return /^[A-Z0-9_-]{2,}$/i.test(candidate) ? candidate : '';
};
const getMaterialRegistrationPath = (material: string) => buildSkuEditPath({
sku: getMaterialRegistrationSku(material),
name: material,
type: 'raw_material',
});
const MaterialDetailsModal = ({
need,
linkedStock,
supplierOptions,
onClose,
onSave,
onEditGraphsBalance,
}: {
need: SupplyPurchaseNeed;
linkedStock: StockData | null;
supplierOptions: string[];
onClose: () => void;
onSave: (payload: { displayName: string; preferredSupplier: string; notes: string }) => Promise<void>;
onEditGraphsBalance: (stock: StockData) => void;
}) => {
const [form, setForm] = useState({
displayName: need.material,
preferredSupplier: need.preferredSupplier || need.suppliers[0] || '',
notes: need.notes || '',
});
const [isSaving, setIsSaving] = useState(false);
const [error, setError] = useState('');
const registrationPath = getMaterialRegistrationPath(need.material);
const submit = async (event: FormEvent<HTMLFormElement>) => {
event.preventDefault();
setIsSaving(true);
setError('');
try {
await onSave(form);
onClose();
} catch (saveError) {
setError(saveError instanceof Error ? saveError.message : 'Não foi possível salvar os dados do material.');
} finally {
setIsSaving(false);
}
};
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/65 p-4">
<form onSubmit={(event) => void submit(event)} className="w-full max-w-lg overflow-hidden rounded-2xl border border-dark-border bg-dark-card shadow-2xl">
<div className="flex items-start justify-between gap-4 border-b border-dark-border p-5">
<div className="min-w-0">
<p className="text-xs font-bold uppercase tracking-widest text-sky-300">Dados do material</p>
<h2 className="mt-1 truncate text-lg font-bold text-dark-text" title={need.material}>{need.material}</h2>
</div>
<button type="button" onClick={onClose} className="inline-flex h-8 w-8 shrink-0 cursor-pointer items-center justify-center rounded-lg text-dark-muted transition-colors hover:bg-dark-input hover:text-dark-text" aria-label="Fechar edição">
<X className="h-4 w-4" />
</button>
</div>
<div className="space-y-4 p-5">
<label className="block text-xs font-bold text-dark-muted">
Nome para exibição
<input value={form.displayName} onChange={(event) => setForm(current => ({ ...current, displayName: event.target.value }))} className={`${inputClassName} mt-1`} />
</label>
<label className="block text-xs font-bold text-dark-muted">
Fornecedor preferencial
<input list="known-suppliers" value={form.preferredSupplier} onChange={(event) => setForm(current => ({ ...current, preferredSupplier: event.target.value }))} placeholder="Selecione ou informe um fornecedor" className={`${inputClassName} mt-1`} />
<datalist id="known-suppliers">{supplierOptions.map(supplier => <option key={supplier} value={supplier} />)}</datalist>
<span className="mt-1 block text-[11px] font-semibold text-dark-muted">Sugestões carregadas do histórico de fornecedores e recebimentos.</span>
</label>
<div className="rounded-xl border border-dark-border bg-dark-input/50 p-3">
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<div>
<p className="text-xs font-bold uppercase tracking-widest text-dark-muted">Saldo físico no Graphs</p>
{linkedStock ? <><p className="mt-1 text-sm font-bold text-brand-primary">{linkedStock.graphs_balance === null || linkedStock.graphs_balance === undefined ? 'Não informado' : `${new Intl.NumberFormat('pt-BR', { maximumFractionDigits: 2 }).format(Number(linkedStock.graphs_balance))} un.`}</p><p className="mt-1 text-[11px] font-semibold text-dark-muted">Tiny: {new Intl.NumberFormat('pt-BR', { maximumFractionDigits: 2 }).format(Number(linkedStock.saldo))} un. · usado no plano: {new Intl.NumberFormat('pt-BR', { maximumFractionDigits: 2 }).format(Math.max(0, Number(linkedStock.planning_balance ?? linkedStock.saldo)))} un.</p></> : <p className="mt-1 text-xs font-semibold text-dark-muted">Este material ainda não possui saldo importado do Tiny para conferência.</p>}
</div>
{linkedStock && <button type="button" onClick={() => onEditGraphsBalance(linkedStock)} className="inline-flex h-9 shrink-0 items-center justify-center gap-2 rounded-lg border border-dark-border bg-dark-card px-3 text-xs font-bold text-brand-primary transition-colors hover:border-brand-primary cursor-pointer"><Pencil className="h-3.5 w-3.5" /> Ajustar saldo</button>}
</div>
</div>
<label className="block text-xs font-bold text-dark-muted">
Observações operacionais
<textarea value={form.notes} onChange={(event) => setForm(current => ({ ...current, notes: event.target.value }))} placeholder="Opcional" className="mt-1 min-h-24 w-full resize-y 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" />
</label>
{error && <p className="rounded-lg border border-red-400/30 bg-red-400/10 px-3 py-2 text-sm font-semibold text-red-300">{error}</p>}
</div>
<div className="flex flex-col-reverse gap-2 border-t border-dark-border p-5 sm:flex-row sm:items-center sm:justify-between">
<RouterLink to={registrationPath} className="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">
<PackageSearch className="h-4 w-4 text-brand-primary" />
Abrir Cadastros
</RouterLink>
<button type="submit" disabled={isSaving} className="inline-flex h-10 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" />
{isSaving ? 'Salvando...' : 'Salvar'}
</button>
</div>
</form>
</div>
);
};
const PurchaseNeedsScreen = () => {
const [searchParams, setSearchParams] = useSearchParams();
const [summary, setSummary] = useState<SupplySummary>(emptySupplySummary);
const [stockSnapshot, setStockSnapshot] = useState<StockData[]>([]);
const [supplierOptions, setSupplierOptions] = useState<string[]>([]);
const [isLoading, setIsLoading] = useState(true);
const [search, setSearch] = useState('');
const [editingMaterial, setEditingMaterial] = useState<SupplyPurchaseNeed | null>(null);
const [editingGraphsBalance, setEditingGraphsBalance] = useState<StockData | null>(null);
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 serviceFilter: ServicePurchaseFilter = requestedFilter === 'covered' || requestedFilter === 'all' ? requestedFilter : 'to_schedule';
const [currentPage, setCurrentPage] = useState(1);
const [itemsPerPage, setItemsPerPage] = useState(10);
const loadSummary = async () => {
const [nextSummary, nextStockSnapshot, nextSuppliers] = await Promise.all([
fetchSupplySummary(),
fetchStock(),
fetchSupplySuppliers(),
]);
setSummary(nextSummary);
setStockSnapshot(nextStockSnapshot);
setSupplierOptions(nextSuppliers);
};
useEffect(() => {
let isMounted = true;
const load = async () => {
setIsLoading(true);
try {
const [nextSummary, nextStockSnapshot, nextSuppliers] = await Promise.all([
fetchSupplySummary(),
fetchStock(),
fetchSupplySuppliers(),
]);
if (isMounted) {
setSummary(nextSummary);
setStockSnapshot(nextStockSnapshot);
setSupplierOptions(nextSuppliers);
}
} finally {
if (isMounted) setIsLoading(false);
}
};
load();
return () => {
isMounted = false;
};
}, []);
const openQueue = (tab: PurchaseNeedTab, filter?: PurchaseNeedFilter) => {
const nextFilter = filter || (tab === 'materials' ? 'to_buy' : 'to_schedule');
setSearch('');
setCurrentPage(1);
setSearchParams({ tab, filter: nextFilter });
};
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 filteredServiceNeeds = serviceNeeds.filter(need => {
if (serviceFilter === 'to_schedule') return need.purchaseKg > 0;
if (serviceFilter === 'covered') return 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;
})
: filteredServiceNeeds;
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 stocksByMaterialKey = useMemo(() => {
const index = new Map<string, StockData>();
stockSnapshot.forEach(stock => {
index.set(normalizeMaterialKey(stock.nome), stock);
index.set(normalizeMaterialKey(stock.produto_id), stock);
});
return index;
}, [stockSnapshot]);
const linkedEditingStock = editingMaterial
? stocksByMaterialKey.get(normalizeMaterialKey(editingMaterial.materialKey)) || stocksByMaterialKey.get(normalizeMaterialKey(editingMaterial.material)) || null
: null;
const handleGraphsBalanceSaved = (updatedStock: StockData) => {
setStockSnapshot(current => current.map(stock => (
stock.produto_id === updatedStock.produto_id ? updatedStock : stock
)));
void loadSummary();
};
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 materialFilters: Array<{ id: MaterialPurchaseFilter; label: string; count: number }> = [
{ id: 'to_buy', label: 'Comprar agora', count: purchaseItemCount },
{ id: 'negative_balance', label: 'Saldo negativo', count: negativeBalanceNeeds.length },
{ id: 'no_stock', label: 'Sem saldo', count: noStockNeeds.length },
{ id: 'all', label: 'Todos os materiais', count: materialNeeds.length },
];
const serviceFilters: Array<{ id: ServicePurchaseFilter; label: string; count: number }> = [
{ id: 'to_schedule', label: 'A programar', count: serviceNeeds.filter(need => need.purchaseKg > 0).length },
{ id: 'covered', label: 'Sem programação', count: serviceNeeds.filter(need => need.purchaseKg <= 0).length },
{ id: 'all', label: 'Todos os serviços', count: serviceNeeds.length },
];
const activeFilter = activeTab === 'materials' ? materialFilter : serviceFilter;
return (
<div className={pageClassName}>
<div className="flex flex-col gap-4 lg:flex-row lg:items-end lg:justify-between">
<Header title="Necessidade de Compra" subtitle="Materiais e serviços terceirizados para execução; pendências de cadastro ficam em Dados Pendentes." backTo="/supplies" />
<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: 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="grid grid-cols-1 gap-3 rounded-2xl border border-dark-border bg-dark-card p-4 shadow-sm xl:grid-cols-[auto_minmax(0,1fr)_190px]">
<div className="inline-flex w-fit rounded-xl border border-dark-border bg-dark-input p-1">
{[
{ id: 'materials' as const, label: 'Materiais' },
{ id: 'services' as const, label: 'Serviços' },
].map(tab => (
<button key={tab.id} type="button" onClick={() => openQueue(tab.id)} className={`rounded-lg px-4 py-2 text-sm font-bold transition-colors cursor-pointer ${activeTab === tab.id ? 'bg-brand-primary text-brand-contrast' : 'text-dark-muted hover:bg-dark-card hover:text-dark-text'}`}>
{tab.label}
</button>
))}
</div>
<label className="relative block">
<Search className="pointer-events-none absolute left-3 top-1/2 h-5 w-5 -translate-y-1/2 text-dark-muted" />
<input
value={search}
onChange={(event) => {
setSearch(event.target.value);
setCurrentPage(1);
}}
className="h-11 w-full rounded-xl border border-dark-border bg-dark-input py-2.5 pl-10 pr-4 text-sm font-semibold text-dark-text outline-none transition-colors placeholder:text-dark-muted hover:border-brand-primary focus:border-brand-primary"
placeholder={activeTab === 'services' ? 'Buscar por serviço ou SKU impactado...' : 'Buscar por material, fornecedor ou cor...'}
/>
</label>
<select
value={activeFilter}
onChange={(event) => openQueue(activeTab, event.target.value as PurchaseNeedFilter)}
className="h-11 rounded-xl border border-dark-border bg-dark-input px-3 text-sm font-semibold text-dark-text outline-none transition-colors focus:border-brand-primary cursor-pointer"
aria-label={activeTab === 'materials' ? 'Filtro de materiais' : 'Filtro de serviços'}
>
{activeTab === 'materials'
? materialFilters.map(filter => <option key={filter.id} value={filter.id}>{filter.label} ({filter.count})</option>)
: serviceFilters.map(filter => <option key={filter.id} value={filter.id}>{filter.label} ({filter.count})</option>)}
</select>
</div>
<div className={panelClassName}>
{isLoading ? (
<div className={`${emptyStateClassName} min-h-[280px]`}>
<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="overflow-x-auto">
{activeTab === 'materials'
? <PurchaseMaterialQueue needs={paginatedNeeds} onEditMaterial={setEditingMaterial} />
: <PurchaseServiceQueue needs={paginatedNeeds} onEditMaterial={setEditingMaterial} />}
</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 className={`${emptyStateClassName} min-h-[280px]`}>
<BarChart3 className="h-8 w-8 text-brand-primary" />
<h3 className="text-base font-bold text-dark-text">{activeTab === 'services' ? 'Nenhum serviço encontrado' : 'Nenhuma necessidade encontrada'}</h3>
<p className="text-sm font-semibold text-dark-muted">{summary.purchaseNeeds.length ? 'Ajuste a busca ou o filtro.' : 'Cadastre planos de malha para gerar demanda e aprove recebimentos para abater estoque.'}</p>
</div>
)}
</div>
{editingMaterial && (
<MaterialDetailsModal
key={editingMaterial.materialKey}
need={editingMaterial}
linkedStock={linkedEditingStock}
supplierOptions={supplierOptions}
onClose={() => setEditingMaterial(null)}
onEditGraphsBalance={setEditingGraphsBalance}
onSave={async (payload) => {
await saveSupplyMaterialDetails(editingMaterial.materialKey, payload);
await loadSummary();
}}
/>
)}
{editingGraphsBalance && (
<StockGraphsBalanceModal
stock={editingGraphsBalance}
title={editingMaterial?.material || editingGraphsBalance.nome}
onClose={() => setEditingGraphsBalance(null)}
onSaved={handleGraphsBalanceSaved}
/>
)}
</div>
);
};
const PurchaseOrdersScreen = () => {
const [orders, setOrders] = useState<SupplyPurchaseOrder[]>([]);
const [suppliers, setSuppliers] = useState<string[]>([]);
const [isAddingSupplier, setIsAddingSupplier] = useState(false);
const [busy, setBusy] = useState(false);
const [message, setMessage] = useState('');
const [form, setForm] = useState({ supplier: '', material: '', quantity: '', unit: 'kg', unitCost: '', expectedDate: '', notes: '' });
const load = async () => {
const [nextOrders, nextSuppliers] = await Promise.all([fetchSupplyPurchaseOrders(), fetchSupplySuppliers()]);
setOrders(nextOrders); setSuppliers(nextSuppliers);
};
useEffect(() => {
const timer = window.setTimeout(() => {
void load().catch(error => setMessage(error instanceof Error ? error.message : 'Não foi possível carregar as OCs.'));
}, 0);
return () => window.clearTimeout(timer);
}, []);
const submit = async (event: FormEvent<HTMLFormElement>) => {
event.preventDefault(); setBusy(true); setMessage('');
try {
await createSupplyPurchaseOrder({ supplier: form.supplier, expectedDate: form.expectedDate || null, notes: form.notes, items: [{ material: form.material, quantity: parseDecimal(form.quantity), unit: form.unit, unitCost: form.unitCost ? parseDecimal(form.unitCost) : null }] });
setForm({ supplier: '', material: '', quantity: '', unit: 'kg', unitCost: '', expectedDate: '', notes: '' }); setIsAddingSupplier(false); await load(); setMessage('Ordem de compra criada. Envie ao fornecedor e atualize o status conforme o andamento.');
} catch (error) { setMessage(error instanceof Error ? error.message : 'Não foi possível criar a OC.'); } finally { setBusy(false); }
};
const changeStatus = async (id: number, status: SupplyPurchaseOrderStatus) => { setBusy(true); try { await updateSupplyPurchaseOrderStatus(id, status); await load(); } catch (error) { setMessage(error instanceof Error ? error.message : 'Não foi possível atualizar a OC.'); } finally { setBusy(false); } };
return <div className={pageClassName}><Header title="Ordens de Compra" subtitle="Formalize compras a partir das necessidades de material e acompanhe o compromisso com o fornecedor." backTo="/supplies" />
{message && <div className="rounded-xl border border-dark-border bg-dark-card px-4 py-3 text-sm font-bold text-dark-muted">{message}</div>}
<div className="grid grid-cols-1 gap-5 xl:grid-cols-[390px_minmax(0,1fr)]"><form onSubmit={submit} className={`${panelClassName} p-5`}><h2 className="text-base font-bold text-dark-text">Nova ordem de compra</h2><p className="mt-1 text-sm font-semibold text-dark-muted">Uma OC começa com um material. Use uma OC por fornecedor para manter o recebimento rastreável.</p><div className="mt-5 space-y-3"><label className="block text-xs font-bold text-dark-muted">Fornecedor{isAddingSupplier ? <input required autoFocus value={form.supplier} onChange={event => setForm(current => ({ ...current, supplier: event.target.value }))} className={`${inputClassName} mt-1`} placeholder="Nome do fornecedor" /> : <select required value={form.supplier} onChange={event => { if (event.target.value === '__new__') { setForm(current => ({ ...current, supplier: '' })); setIsAddingSupplier(true); return; } setForm(current => ({ ...current, supplier: event.target.value })); }} className={`${inputClassName} mt-1`}><option value="">Selecione um fornecedor</option>{suppliers.map(supplier => <option key={supplier} value={supplier}>{supplier}</option>)}<option value="__new__">+ Informar novo fornecedor</option></select>}</label>{isAddingSupplier && <button type="button" onClick={() => { setForm(current => ({ ...current, supplier: '' })); setIsAddingSupplier(false); }} className="-mt-1 text-xs font-bold text-brand-primary hover:underline cursor-pointer">Voltar à lista de fornecedores</button>}<label className="block text-xs font-bold text-dark-muted">Material<input required value={form.material} onChange={event => setForm(current => ({ ...current, material: event.target.value }))} className={`${inputClassName} mt-1`} placeholder="Ex.: Malha 30.1 preta" /></label><div className="grid grid-cols-2 gap-3"><label className="text-xs font-bold text-dark-muted">Quantidade<input required inputMode="decimal" value={form.quantity} onChange={event => setForm(current => ({ ...current, quantity: event.target.value }))} className={`${inputClassName} mt-1`} placeholder="0" /></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="un.">un.</option><option value="rolos">rolos</option></select></label></div><div className="grid grid-cols-2 gap-3"><label className="text-xs font-bold text-dark-muted">Custo unitário<input inputMode="decimal" value={form.unitCost} onChange={event => setForm(current => ({ ...current, unitCost: event.target.value }))} className={`${inputClassName} mt-1`} placeholder="Opcional" /></label><label className="text-xs font-bold text-dark-muted">Previsão<input type="date" value={form.expectedDate} onChange={event => setForm(current => ({ ...current, expectedDate: event.target.value }))} className={`${inputClassName} mt-1`} /></label></div><label className="block text-xs font-bold text-dark-muted">Observações<textarea value={form.notes} onChange={event => setForm(current => ({ ...current, notes: event.target.value }))} className={`${inputClassName} mt-1 min-h-20`} placeholder="Condição, cor, lote ou referência do fornecedor" /></label><button disabled={busy} className="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 disabled:opacity-60 cursor-pointer"><ClipboardList className="h-4 w-4" />Criar OC</button></div></form><div className={panelClassName}><div className="border-b border-dark-border p-5"><h2 className="text-base font-bold text-dark-text">OCs em andamento</h2><p className="mt-1 text-sm font-semibold text-dark-muted">OCs criadas no recebimento ficam aguardando confirmação financeira antes de liberar o estoque físico.</p></div>{orders.length ? <div className="divide-y divide-dark-border">{orders.map(order => <div key={order.id} className="p-5"><div className="flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between"><div><p className="font-mono text-xs font-bold text-brand-primary">{order.number}</p><h3 className="mt-1 text-sm font-bold text-dark-text">{order.supplier}</h3><p className="mt-1 text-xs font-semibold text-dark-muted">{order.items.map(item => `${item.material} · ${formatNumber(item.quantity)} ${item.unit}`).join(' | ')}</p></div><select value={order.status} disabled={busy} onChange={event => void changeStatus(order.id, event.target.value as SupplyPurchaseOrderStatus)} className={`${inputClassName} h-9 w-auto text-xs`}><option value="draft">Rascunho</option><option value="pending_financial">Aguarda financeiro</option><option value="sent">Enviada</option><option value="approved">Aprovada</option><option value="received">Recebida</option><option value="canceled">Cancelada</option></select></div><div className="mt-3 flex flex-wrap gap-x-5 gap-y-1 text-xs font-semibold text-dark-muted"><span>Previsão: {order.expectedDate || '-'}</span><span>Total estimado: R$ {formatNumber(order.total, 2)}</span>{order.notes && <span>{order.notes}</span>}</div></div>)}</div> : <div className={emptyStateClassName}><ClipboardList className="h-8 w-8 text-brand-primary" /><h3 className="text-base font-bold text-dark-text">Nenhuma OC criada</h3><p className="text-sm font-semibold text-dark-muted">Use a necessidade de compra para decidir o que pedir e registre a OC aqui.</p></div>}</div></div></div>;
};
const transformationStageLabels: Record<MaterialTransformationStage, string> = {
knitting: 'Tecelagem / malharia',
dyeing: 'Tinturaria',
finishing: 'Acabamento',
};
const transformationStatusLabels: Record<MaterialTransformationStatus, string> = {
planned: 'Planejada',
in_progress: 'Em andamento',
completed: 'Concluída',
canceled: 'Cancelada',
};
const TransformationStatusBadge = ({ status }: { status: MaterialTransformationStatus }) => {
const tone = status === 'completed'
? 'border-emerald-400/30 bg-emerald-400/10 text-emerald-300'
: status === 'in_progress'
? 'border-sky-400/30 bg-sky-400/10 text-sky-300'
: status === 'canceled'
? 'border-dark-border bg-dark-input text-dark-muted'
: 'border-amber-400/30 bg-amber-400/10 text-amber-300';
return <span className={`inline-flex rounded-full border px-2.5 py-1 text-xs font-bold ${tone}`}>{transformationStatusLabels[status]}</span>;
};
const MaterialTransformationsScreen = () => {
const [transformations, setTransformations] = useState<SupplyMaterialTransformation[]>([]);
const [isLoading, setIsLoading] = useState(true);
const [isBusy, setIsBusy] = useState(false);
const [message, setMessage] = useState('');
const [form, setForm] = useState({
batchCode: '', stage: 'knitting' as MaterialTransformationStage, inputMaterial: '', inputQuantityKg: '',
outputMaterial: '', outputColor: '', expectedLossPercent: '', supplier: '', priority: 'Normal', notes: '',
});
const load = async () => {
const rows = await fetchSupplyMaterialTransformations();
setTransformations(rows);
};
useEffect(() => {
let mounted = true;
void (async () => {
setIsLoading(true);
try {
const rows = await fetchSupplyMaterialTransformations();
if (mounted) setTransformations(rows);
} catch (error) {
if (mounted) setMessage(error instanceof Error ? error.message : 'Não foi possível carregar as transformações.');
} finally {
if (mounted) setIsLoading(false);
}
})();
return () => { mounted = false; };
}, []);
const plannedInputKg = transformations.filter(item => item.status !== 'canceled').reduce((total, item) => total + item.inputQuantityKg, 0);
const expectedOutputKg = transformations.filter(item => item.status !== 'canceled').reduce((total, item) => total + item.expectedOutputKg, 0);
const expectedLossKg = Math.max(0, plannedInputKg - expectedOutputKg);
const activeByColor = transformations.filter(item => item.status !== 'canceled' && item.outputColor).reduce<Record<string, number>>((acc, item) => {
acc[item.outputColor] = (acc[item.outputColor] || 0) + item.expectedOutputKg;
return acc;
}, {});
const submit = async (event: FormEvent<HTMLFormElement>) => {
event.preventDefault();
const inputQuantityKg = parseDecimal(form.inputQuantityKg);
if (!form.inputMaterial.trim() || !form.outputMaterial.trim() || inputQuantityKg <= 0) return;
setIsBusy(true); setMessage('');
try {
await createSupplyMaterialTransformation({
batchCode: form.batchCode.trim(), stage: form.stage, inputMaterial: form.inputMaterial.trim(), inputQuantityKg,
outputMaterial: form.outputMaterial.trim(), outputColor: form.outputColor.trim(),
expectedLossPercent: form.expectedLossPercent ? parseDecimal(form.expectedLossPercent) : 0,
supplier: form.supplier.trim(), priority: form.priority, notes: form.notes.trim(),
});
setForm({ batchCode: '', stage: 'knitting', inputMaterial: '', inputQuantityKg: '', outputMaterial: '', outputColor: '', expectedLossPercent: '', supplier: '', priority: 'Normal', notes: '' });
await load();
setMessage('Transformação planejada. O estoque físico só será alterado quando houver recebimento aprovado.');
} catch (error) {
setMessage(error instanceof Error ? error.message : 'Não foi possível salvar a transformação.');
} finally { setIsBusy(false); }
};
const changeStatus = async (id: number, status: MaterialTransformationStatus) => {
setIsBusy(true); setMessage('');
try { await updateSupplyMaterialTransformationStatus(id, status); await load(); }
catch (error) { setMessage(error instanceof Error ? error.message : 'Não foi possível atualizar a transformação.'); }
finally { setIsBusy(false); }
};
return <div className={pageClassName}>
<Header title="Transformações e perdas" subtitle="Planeje fios, malha, tinturaria e cores. Planejamento não cria saldo: o estoque só muda no recebimento físico." backTo="/supplies" />
{message && <div className="rounded-xl border border-dark-border bg-dark-card px-4 py-3 text-sm font-bold text-dark-muted">{message}</div>}
<div className="grid grid-cols-1 gap-4 md:grid-cols-3">
<div className={`${panelClassName} p-5`}><p className="text-xs font-bold uppercase tracking-widest text-dark-muted">Entrada planejada</p><p className="mt-2 text-3xl font-bold text-dark-text">{formatNumber(plannedInputKg)} kg</p><p className="mt-1 text-xs font-semibold text-dark-muted">em transformações ativas</p></div>
<div className={`${panelClassName} p-5`}><p className="text-xs font-bold uppercase tracking-widest text-dark-muted">Saída esperada</p><p className="mt-2 text-3xl font-bold text-emerald-300">{formatNumber(expectedOutputKg)} kg</p><p className="mt-1 text-xs font-semibold text-dark-muted">após a perda prevista</p></div>
<div className={`${panelClassName} p-5`}><p className="text-xs font-bold uppercase tracking-widest text-dark-muted">Perda prevista</p><p className="mt-2 text-3xl font-bold text-amber-300">{formatNumber(expectedLossKg)} kg</p><p className="mt-1 text-xs font-semibold text-dark-muted">a confirmar na execução e no recebimento</p></div>
</div>
<div className="grid grid-cols-1 items-start gap-6 xl:grid-cols-[minmax(0,1fr)_460px]">
<section className={`${panelClassName} overflow-hidden`}>
<div className="border-b border-dark-border p-5"><h2 className="text-base font-bold text-dark-text">Fila de transformação</h2><p className="mt-1 text-sm font-semibold text-dark-muted">Cada linha registra uma etapa; use o mesmo lote para encadear tecelagem, tinturaria e acabamento.</p></div>
{isLoading ? <div className={emptyStateClassName}><Factory className="h-8 w-8 text-brand-primary" /><h3 className="text-base font-bold text-dark-text">Carregando transformações...</h3></div>
: transformations.length ? <div className="divide-y divide-dark-border">{transformations.map(item => <div key={item.id} className="grid gap-3 p-5 lg:grid-cols-[minmax(0,1.3fr)_minmax(210px,.8fr)_160px] lg:items-center">
<div><div className="flex flex-wrap items-center gap-2"><p className="font-bold text-dark-text">{item.outputMaterial}</p><TransformationStatusBadge status={item.status} />{item.batchCode && <span className="rounded-md border border-dark-border bg-dark-input px-2 py-0.5 font-mono text-[11px] font-bold text-dark-muted">{item.batchCode}</span>}</div><p className="mt-1 text-xs font-semibold text-dark-muted">{transformationStageLabels[item.stage]} · Entrada: {item.inputMaterial}</p>{item.notes && <p className="mt-1 text-xs font-semibold text-dark-muted">{item.notes}</p>}</div>
<div className="grid grid-cols-2 gap-2"><div className="rounded-lg border border-dark-border bg-dark-input/40 px-3 py-2"><p className="text-[10px] font-bold uppercase text-dark-muted">Entrada</p><p className="mt-1 text-sm font-bold text-dark-text">{formatNumber(item.inputQuantityKg)} kg</p></div><div className="rounded-lg border border-dark-border bg-dark-input/40 px-3 py-2"><p className="text-[10px] font-bold uppercase text-dark-muted">Saída esperada</p><p className="mt-1 text-sm font-bold text-emerald-300">{formatNumber(item.expectedOutputKg)} kg</p></div></div>
<div className="flex flex-col items-start gap-2 lg:items-end"><p className="text-xs font-bold text-dark-muted">Perda {formatNumber(item.expectedLossPercent)}%{item.outputColor ? ` · ${item.outputColor}` : ''}</p><select disabled={isBusy} value={item.status} onChange={event => void changeStatus(item.id, event.target.value as MaterialTransformationStatus)} className={`${inputClassName} h-9 w-auto text-xs`} aria-label={`Status da transformação ${item.id}`}>{(Object.keys(transformationStatusLabels) as MaterialTransformationStatus[]).map(status => <option key={status} value={status}>{transformationStatusLabels[status]}</option>)}</select></div>
</div>)}</div> : <div className={emptyStateClassName}><GitBranch className="h-8 w-8 text-brand-primary" /><h3 className="text-base font-bold text-dark-text">Nenhuma transformação planejada</h3><p className="text-sm font-semibold text-dark-muted">Registre o primeiro lote para acompanhar perdas e a divisão por cor.</p></div>}
</section>
<aside className={`${panelClassName} p-5`}>
<h2 className="text-base font-bold text-dark-text">Nova transformação</h2><p className="mt-1 text-sm font-semibold text-dark-muted">Informe a perda prevista por etapa. A saída é calculada automaticamente.</p>
<form onSubmit={submit} className="mt-5 space-y-3">
<div className="grid grid-cols-2 gap-3"><label className="block text-xs font-bold text-dark-muted">Etapa<select value={form.stage} onChange={event => setForm(current => ({ ...current, stage: event.target.value as MaterialTransformationStage }))} className={`${inputClassName} mt-1`}>{(Object.keys(transformationStageLabels) as MaterialTransformationStage[]).map(stage => <option key={stage} value={stage}>{transformationStageLabels[stage]}</option>)}</select></label><label className="block text-xs font-bold text-dark-muted">Lote / cadeia<input value={form.batchCode} onChange={event => setForm(current => ({ ...current, batchCode: event.target.value }))} className={`${inputClassName} mt-1`} placeholder="Ex.: L-2408" /></label></div>
<label className="block text-xs font-bold text-dark-muted">Material de entrada<input required value={form.inputMaterial} onChange={event => setForm(current => ({ ...current, inputMaterial: event.target.value }))} className={`${inputClassName} mt-1`} placeholder="Ex.: Fio 30/1 cru" /></label>
<div className="grid grid-cols-2 gap-3"><label className="block text-xs font-bold text-dark-muted">Quantidade (kg)<input required inputMode="decimal" value={form.inputQuantityKg} onChange={event => setForm(current => ({ ...current, inputQuantityKg: event.target.value }))} className={`${inputClassName} mt-1`} placeholder="0" /></label><label className="block text-xs font-bold text-dark-muted">Perda esperada (%)<input inputMode="decimal" value={form.expectedLossPercent} onChange={event => setForm(current => ({ ...current, expectedLossPercent: event.target.value }))} className={`${inputClassName} mt-1`} placeholder="0" /></label></div>
<label className="block text-xs font-bold text-dark-muted">Material de saída<input required value={form.outputMaterial} onChange={event => setForm(current => ({ ...current, outputMaterial: event.target.value }))} className={`${inputClassName} mt-1`} placeholder="Ex.: Malha 30.1" /></label>
<div className="grid grid-cols-2 gap-3"><label className="block text-xs font-bold text-dark-muted">Cor de saída<input value={form.outputColor} onChange={event => setForm(current => ({ ...current, outputColor: event.target.value }))} className={`${inputClassName} mt-1`} placeholder="Ex.: Preto" /></label><label className="block 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></div>
<label className="block 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>
<label className="block text-xs font-bold text-dark-muted">Observações<textarea value={form.notes} onChange={event => setForm(current => ({ ...current, notes: event.target.value }))} className={`${inputClassName} mt-1 min-h-20`} placeholder="Referência, prazo ou orientação do lote" /></label>
<button disabled={isBusy} className="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 disabled:opacity-60 cursor-pointer"><Factory className="h-4 w-4" />Planejar transformação</button>
</form>
{Object.keys(activeByColor).length > 0 && <div className="mt-6 border-t border-dark-border pt-5"><p className="text-xs font-bold uppercase tracking-widest text-dark-muted">Alocação esperada por cor</p><div className="mt-3 space-y-2">{Object.entries(activeByColor).sort(([, left], [, right]) => right - left).map(([color, kg]) => <div key={color} className="flex items-center justify-between rounded-lg border border-dark-border bg-dark-input/40 px-3 py-2"><span className="text-sm font-bold text-dark-text">{color}</span><span className="text-sm font-bold text-emerald-300">{formatNumber(kg)} kg</span></div>)}</div></div>}
</aside>
</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 === 'material-transformations') return <MaterialTransformationsScreen />;
if (section === 'purchase-needs') return <PurchaseNeedsScreen />;
if (section === 'purchase-orders') return <PurchaseOrdersScreen />;
return <Navigate to="/supplies" replace />;
};
export default Supplies;