2833 lines
152 KiB
TypeScript
2833 lines
152 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,
|
|
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-3">
|
|
<FlowStepCard title="Garantir materiais" description="Confira saldo disponível, recebimentos e a compra sugerida." icon={BarChart3} to={materialPurchaseNeeds.length > 0 ? '/supplies/purchase-needs?tab=materials&filter=to_buy' : '/supplies/purchase-needs?tab=materials&filter=all'} tone={materialPurchaseNeeds.length > 0 ? 'attention' : 'ready'} meta={materialPurchaseNeeds.length > 0 ? `${formatNumber(materialPurchaseNeeds.length, 0)} materiais para comprar` : 'Materiais cobertos'} />
|
|
<FlowStepCard title="Definir corte" description="Priorize SKUs, cores e tamanhos com necessidade e material disponível." icon={Scissors} to="/cutting" tone="default" meta="Abrir plano de corte" />
|
|
<FlowStepCard title="Acompanhar produção" description="Gerencie as OPs criadas a partir do corte até sua finalização." icon={ClipboardList} to="/production-orders" tone={activeProductionCount > 0 ? 'ready' : 'default'} meta={activeProductionCount > 0 ? `${formatNumber(productionCounts.open, 0)} abertas · ${formatNumber(productionCounts.inProgress, 0)} em andamento` : 'Nenhuma OP em execução'} />
|
|
</div>
|
|
</section>
|
|
<section className="border-t border-dark-border pt-6">
|
|
<div className="mb-3">
|
|
<h2 className="text-base font-bold text-dark-text">Gestão de materiais</h2>
|
|
<p className="mt-1 text-sm font-semibold text-dark-muted">Cadastros e controles que sustentam o planejamento operacional.</p>
|
|
</div>
|
|
<div className="grid grid-cols-1 gap-4 lg:grid-cols-2 xl:grid-cols-4">
|
|
<ModuleCard title="Estoque e recebimentos" description="Saldo, lotes, recebimentos, inventário e movimentações de materiais." icon={Package} to="/supplies/inventory" meta={`${formatNumber(summary.stats.pendingReceipts, 0)} recebimentos pendentes · ${formatNumber(summary.stats.activeLots, 0)} lotes ativos`} />
|
|
<ModuleCard title="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="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>
|
|
<p className="text-sm font-semibold text-dark-muted">{receipt.supplier}</p>
|
|
<span className={`w-fit rounded-full border px-2.5 py-1 text-xs font-bold ${
|
|
receipt.status === 'approved'
|
|
? 'border-emerald-400/30 bg-emerald-400/10 text-emerald-300'
|
|
: 'border-amber-400/30 bg-amber-400/10 text-amber-300'
|
|
}`}>
|
|
{receipt.status === 'approved' ? 'Aprovado' : 'Pendente'}
|
|
</span>
|
|
<div className="flex flex-wrap gap-2 lg:justify-end">
|
|
{receipt.status === 'pending' && (
|
|
<button type="button" onClick={() => onApprove(receipt.id)} className={buttonClassName}>
|
|
Aprovar
|
|
</button>
|
|
)}
|
|
<button type="button" onClick={() => onRemove(receipt.id)} className="inline-flex h-10 items-center justify-center rounded-lg px-3 text-sm font-bold text-red-400 transition-colors hover:bg-red-500/10 cursor-pointer">
|
|
Remover
|
|
</button>
|
|
</div>
|
|
</div>
|
|
))}
|
|
</div>
|
|
) : (
|
|
<div className={emptyStateClassName}>
|
|
<Truck className="h-8 w-8 text-brand-primary" />
|
|
<h3 className="text-base font-bold text-dark-text">{emptyTitle}</h3>
|
|
<p className="text-sm font-semibold text-dark-muted">Registre um recebimento para preencher esta lista.</p>
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
|
|
const ReceiptsTab = ({
|
|
receipts,
|
|
onCreate,
|
|
onApprove,
|
|
onRemove,
|
|
isBusy,
|
|
}: {
|
|
receipts: SupplyReceipt[];
|
|
onCreate: (payload: { category: string; product: string; quantity: number; unit: string; supplier: string; invoice: string; notes: string }) => Promise<void>;
|
|
onApprove: (receiptId: number) => Promise<void>;
|
|
onRemove: (receiptId: number) => Promise<void>;
|
|
isBusy: boolean;
|
|
}) => {
|
|
const [activeView, setActiveView] = useState<ReceiptView>('new');
|
|
const [selectedCategory, setSelectedCategory] = useState(receiptCategories[0].name);
|
|
const [form, setForm] = useState({
|
|
product: '',
|
|
quantity: '',
|
|
unit: 'kg',
|
|
supplier: '',
|
|
invoice: '',
|
|
notes: '',
|
|
});
|
|
|
|
const pendingReceipts = receipts.filter(receipt => receipt.status === 'pending');
|
|
const visibleReceipts = activeView === 'pending' ? pendingReceipts : receipts;
|
|
|
|
const handleReceiptSubmit = async (event: FormEvent<HTMLFormElement>) => {
|
|
event.preventDefault();
|
|
const product = form.product.trim();
|
|
const quantity = parseDecimal(form.quantity);
|
|
if (!product || quantity <= 0) return;
|
|
|
|
await onCreate({
|
|
category: selectedCategory,
|
|
product,
|
|
quantity,
|
|
unit: form.unit,
|
|
supplier: form.supplier.trim() || 'Sem fornecedor',
|
|
invoice: form.invoice.trim(),
|
|
notes: form.notes.trim(),
|
|
});
|
|
setForm({ product: '', quantity: '', unit: 'kg', supplier: '', invoice: '', notes: '' });
|
|
setActiveView('pending');
|
|
};
|
|
|
|
return (
|
|
<div className="space-y-4">
|
|
<div className="flex flex-wrap gap-2">
|
|
{[
|
|
{ id: 'new', label: 'Novo recebimento' },
|
|
{ id: 'pending', label: `Pendentes (${pendingReceipts.length})` },
|
|
{ id: 'history', label: `Histórico (${receipts.length})` },
|
|
].map(item => (
|
|
<button
|
|
key={item.id}
|
|
type="button"
|
|
onClick={() => setActiveView(item.id as ReceiptView)}
|
|
className={`${buttonClassName} ${activeView === item.id ? 'border-brand-primary bg-brand-primary text-brand-contrast' : ''}`}
|
|
>
|
|
{item.label}
|
|
</button>
|
|
))}
|
|
</div>
|
|
|
|
{activeView === 'new' && (
|
|
<form onSubmit={handleReceiptSubmit} className={`${panelClassName} p-5`}>
|
|
<h2 className="text-base font-bold text-dark-text">Registrar recebimento</h2>
|
|
<div className="mt-4 rounded-xl border border-dark-border bg-dark-input px-4 py-3 text-sm font-semibold text-dark-muted">
|
|
Preencha o que chegou. O financeiro vincula OC/NF e aprova o lançamento.
|
|
</div>
|
|
<p className="mt-5 text-xs font-bold uppercase tracking-widest text-dark-muted">1. Qual categoria de produto chegou?</p>
|
|
<div className="mt-3 grid grid-cols-1 gap-3 sm:grid-cols-2">
|
|
{receiptCategories.map(category => (
|
|
<button
|
|
key={category.name}
|
|
type="button"
|
|
onClick={() => setSelectedCategory(category.name)}
|
|
className={`flex min-h-24 flex-col items-center justify-center gap-3 rounded-xl border p-4 text-center font-bold transition-colors cursor-pointer ${
|
|
selectedCategory === category.name
|
|
? 'border-brand-primary bg-brand-primary/12 text-brand-primary'
|
|
: 'border-dark-border bg-dark-input/40 text-dark-text hover:border-brand-primary'
|
|
}`}
|
|
>
|
|
<category.icon className="h-6 w-6" />
|
|
{category.name}
|
|
</button>
|
|
))}
|
|
</div>
|
|
|
|
<div className="mt-5 rounded-xl border border-dark-border bg-dark-input/35 p-4">
|
|
<p className="text-xs font-bold uppercase tracking-widest text-dark-muted">2. Detalhes do recebimento</p>
|
|
<div className="mt-3 grid grid-cols-1 gap-3 md:grid-cols-2">
|
|
<label className="text-xs font-bold text-dark-muted">
|
|
Produto / material
|
|
<input value={form.product} onChange={(event) => setForm(current => ({ ...current, product: event.target.value }))} className={`${inputClassName} mt-1`} placeholder="ex: Meia malha 30.1" />
|
|
</label>
|
|
<label className="text-xs font-bold text-dark-muted">
|
|
Fornecedor
|
|
<input value={form.supplier} onChange={(event) => setForm(current => ({ ...current, supplier: event.target.value }))} className={`${inputClassName} mt-1`} placeholder="Opcional" />
|
|
</label>
|
|
<label className="text-xs font-bold text-dark-muted">
|
|
Quantidade
|
|
<input inputMode="decimal" value={form.quantity} onChange={(event) => setForm(current => ({ ...current, quantity: event.target.value }))} className={`${inputClassName} mt-1`} placeholder="ex: 120" />
|
|
</label>
|
|
<label className="text-xs font-bold text-dark-muted">
|
|
Unidade
|
|
<select value={form.unit} onChange={(event) => setForm(current => ({ ...current, unit: event.target.value }))} className={`${inputClassName} mt-1`}>
|
|
<option value="kg">kg</option>
|
|
<option value="rolos">rolos</option>
|
|
<option value="un.">un.</option>
|
|
<option value="caixas">caixas</option>
|
|
</select>
|
|
</label>
|
|
<label className="text-xs font-bold text-dark-muted">
|
|
Nota fiscal
|
|
<input value={form.invoice} onChange={(event) => setForm(current => ({ ...current, invoice: event.target.value }))} className={`${inputClassName} mt-1`} placeholder="NF-001234" />
|
|
</label>
|
|
<label className="text-xs font-bold text-dark-muted md:col-span-2">
|
|
Observações
|
|
<textarea value={form.notes} onChange={(event) => setForm(current => ({ ...current, notes: event.target.value }))} className="mt-1 min-h-20 w-full rounded-lg border border-dark-border bg-dark-input px-3 py-2 text-sm font-semibold text-dark-text outline-none placeholder:text-dark-muted focus:border-brand-primary" placeholder="Condição, divergências, lote..." />
|
|
</label>
|
|
</div>
|
|
<button type="submit" disabled={isBusy} className="mt-4 inline-flex h-11 w-full items-center justify-center gap-2 rounded-lg bg-brand-primary px-4 text-sm font-bold text-brand-contrast transition-opacity hover:opacity-90 disabled:cursor-not-allowed disabled:opacity-60 cursor-pointer">
|
|
<Save className="h-4 w-4" />
|
|
{isBusy ? 'Salvando...' : 'Registrar recebimento'}
|
|
</button>
|
|
</div>
|
|
</form>
|
|
)}
|
|
|
|
{activeView === 'pending' && (
|
|
<ReceiptList
|
|
receipts={visibleReceipts}
|
|
emptyTitle="Nenhum recebimento pendente"
|
|
onApprove={onApprove}
|
|
onRemove={onRemove}
|
|
/>
|
|
)}
|
|
{activeView === 'history' && (
|
|
<ReceiptList
|
|
receipts={visibleReceipts}
|
|
emptyTitle="Nenhum recebimento no histórico"
|
|
onApprove={onApprove}
|
|
onRemove={onRemove}
|
|
/>
|
|
)}
|
|
</div>
|
|
);
|
|
};
|
|
|
|
const StockTab = ({
|
|
stock,
|
|
onRefresh,
|
|
}: {
|
|
stock: StockData[];
|
|
onRefresh: () => void;
|
|
}) => {
|
|
const [search, setSearch] = useState('');
|
|
const [stockFilter, setStockFilter] = useState('all');
|
|
const [currentPage, setCurrentPage] = useState(1);
|
|
const [itemsPerPage, setItemsPerPage] = useState(20);
|
|
const normalizedSearch = normalizeSearch(search);
|
|
const totalBalance = stock.reduce((total, item) => total + Number(item.saldo || 0), 0);
|
|
const updatedItems = stock.filter(item => Number(item.delta_estoque || 0) !== 0).length;
|
|
const latestUpdate = stock
|
|
.map(item => item.updated_at ? new Date(item.updated_at).getTime() : 0)
|
|
.filter(Boolean)
|
|
.sort((a, b) => b - a)[0] || null;
|
|
|
|
const visibleStock = stock.filter(item => {
|
|
const balance = Number(item.saldo || 0);
|
|
const delta = Number(item.delta_estoque || 0);
|
|
const matchesSearch = !normalizedSearch || normalizeSearch(`${item.produto_id} ${item.nome}`).includes(normalizedSearch);
|
|
const matchesFilter =
|
|
stockFilter === 'all' ||
|
|
(stockFilter === 'positive' && balance > 0) ||
|
|
(stockFilter === 'empty' && balance <= 0) ||
|
|
(stockFilter === 'changed' && delta !== 0);
|
|
return matchesSearch && matchesFilter;
|
|
}).sort((a, b) => Number(b.saldo || 0) - Number(a.saldo || 0));
|
|
const totalPages = Math.ceil(visibleStock.length / itemsPerPage);
|
|
const safeCurrentPage = Math.min(currentPage, totalPages || 1);
|
|
const startIndex = (safeCurrentPage - 1) * itemsPerPage;
|
|
const paginatedStock = visibleStock.slice(startIndex, startIndex + itemsPerPage);
|
|
|
|
return (
|
|
<div className="space-y-4">
|
|
<div className="grid grid-cols-1 gap-3 md:grid-cols-4">
|
|
<div className="rounded-xl border border-dark-border bg-dark-card p-4 shadow-sm">
|
|
<p className="text-xs font-bold uppercase tracking-widest text-dark-muted">SKUs</p>
|
|
<p className="mt-2 text-2xl font-bold text-dark-text">{formatNumber(stock.length, 0)}</p>
|
|
</div>
|
|
<div className="rounded-xl border border-dark-border bg-dark-card p-4 shadow-sm">
|
|
<p className="text-xs font-bold uppercase tracking-widest text-dark-muted">Saldo total</p>
|
|
<p className="mt-2 text-2xl font-bold text-dark-text">{formatNumber(totalBalance, 0)} un.</p>
|
|
</div>
|
|
<div className="rounded-xl border border-dark-border bg-dark-card p-4 shadow-sm">
|
|
<p className="text-xs font-bold uppercase tracking-widest text-dark-muted">Com delta</p>
|
|
<p className="mt-2 text-2xl font-bold text-dark-text">{formatNumber(updatedItems, 0)}</p>
|
|
</div>
|
|
<div className="rounded-xl border border-dark-border bg-dark-card p-4 shadow-sm">
|
|
<p className="text-xs font-bold uppercase tracking-widest text-dark-muted">Última atualização</p>
|
|
<p className="mt-2 text-2xl font-bold text-dark-text">{latestUpdate ? formatDateTime(new Date(latestUpdate).toISOString()) : '-'}</p>
|
|
</div>
|
|
</div>
|
|
|
|
<div className={`${panelClassName} p-4`}>
|
|
<div className="flex flex-col gap-3 lg:flex-row lg:items-center lg:justify-between">
|
|
<div>
|
|
<h2 className="text-base font-bold text-dark-text">Estoque</h2>
|
|
<p className="mt-1 text-sm font-semibold text-dark-muted">Saldo de produtos sincronizado pelo fluxo de estoque para Graphs.</p>
|
|
</div>
|
|
<div className="flex flex-col gap-2 sm:flex-row">
|
|
<button
|
|
type="button"
|
|
onClick={() => exportCsv('estoque.csv', visibleStock.map(item => ({
|
|
produto_id: item.produto_id,
|
|
nome: item.nome,
|
|
saldo: item.saldo,
|
|
delta_estoque: item.delta_estoque,
|
|
atualizado_em: item.updated_at || '',
|
|
})))}
|
|
className={buttonClassName}
|
|
>
|
|
<Download className="h-4 w-4" /> CSV
|
|
</button>
|
|
<button type="button" onClick={onRefresh} className={buttonClassName}>
|
|
<RefreshCw className="h-4 w-4" /> Atualizar
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="mt-4 grid grid-cols-1 gap-3 lg:grid-cols-[1fr_220px]">
|
|
<label className="relative">
|
|
<Search className="pointer-events-none absolute left-3 top-3 h-4 w-4 text-dark-muted" />
|
|
<input
|
|
value={search}
|
|
onChange={(event) => {
|
|
setSearch(event.target.value);
|
|
setCurrentPage(1);
|
|
}}
|
|
className={`${inputClassName} pl-9`}
|
|
placeholder="Buscar por SKU ou produto..."
|
|
/>
|
|
</label>
|
|
<select
|
|
value={stockFilter}
|
|
onChange={(event) => {
|
|
setStockFilter(event.target.value);
|
|
setCurrentPage(1);
|
|
}}
|
|
className={inputClassName}
|
|
>
|
|
<option value="all">Todos os saldos</option>
|
|
<option value="positive">Com saldo</option>
|
|
<option value="empty">Sem saldo</option>
|
|
<option value="changed">Com delta</option>
|
|
</select>
|
|
</div>
|
|
|
|
{visibleStock.length ? (
|
|
<div className="mt-4 overflow-hidden rounded-xl border border-dark-border">
|
|
<div className="hidden grid-cols-[140px_1fr_120px_120px_160px] border-b border-dark-border bg-dark-input/40 px-4 py-3 text-xs font-bold uppercase tracking-widest text-dark-muted md:grid">
|
|
<span>SKU</span>
|
|
<span>Produto</span>
|
|
<span className="text-right">Saldo</span>
|
|
<span className="text-right">Delta</span>
|
|
<span className="text-right">Atualizado</span>
|
|
</div>
|
|
<div className="divide-y divide-dark-border">
|
|
{paginatedStock.map(item => {
|
|
const delta = Number(item.delta_estoque || 0);
|
|
return (
|
|
<div key={item.produto_id} className="grid grid-cols-1 gap-2 bg-dark-card px-4 py-3 md:grid-cols-[140px_1fr_120px_120px_160px] md:items-center">
|
|
<p className="font-mono text-xs font-bold text-dark-muted">#{item.produto_id}</p>
|
|
<p className="text-sm font-bold text-dark-text">{item.nome}</p>
|
|
<p className="text-sm font-bold text-dark-text md:text-right">{formatNumber(Number(item.saldo || 0), 0)} un.</p>
|
|
<p className={`text-sm font-bold md:text-right ${delta > 0 ? 'text-emerald-300' : delta < 0 ? 'text-red-300' : 'text-dark-muted'}`}>
|
|
{delta > 0 ? '+' : ''}{formatNumber(delta, 0)}
|
|
</p>
|
|
<p className="text-xs font-semibold text-dark-muted md:text-right">{formatDateTime(item.updated_at || null)}</p>
|
|
</div>
|
|
);
|
|
})}
|
|
</div>
|
|
<PaginationControls
|
|
totalItems={visibleStock.length}
|
|
currentPage={safeCurrentPage}
|
|
totalPages={totalPages}
|
|
pageSize={itemsPerPage}
|
|
pageSizeOptions={[20, 50, 100]}
|
|
itemLabel="SKUs"
|
|
pageSizeLabel="por página"
|
|
startIndex={startIndex}
|
|
endIndex={Math.min(startIndex + itemsPerPage, visibleStock.length)}
|
|
onPageChange={setCurrentPage}
|
|
onPageSizeChange={(pageSize) => {
|
|
setItemsPerPage(pageSize);
|
|
setCurrentPage(1);
|
|
}}
|
|
className="border-t border-dark-border px-4 py-3"
|
|
/>
|
|
</div>
|
|
) : (
|
|
<div className={emptyStateClassName}>
|
|
<PackageSearch className="h-8 w-8 text-brand-primary" />
|
|
<h3 className="text-base font-bold text-dark-text">{stock.length ? 'Nenhum SKU encontrado' : 'Nenhum estoque sincronizado'}</h3>
|
|
<p className="text-sm font-semibold text-dark-muted">
|
|
{stock.length ? 'Ajuste a busca ou o filtro.' : 'Quando o fluxo de estoque postar em /api/stock, os saldos aparecem aqui.'}
|
|
</p>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
);
|
|
};
|
|
|
|
const InventoryCountTab = ({
|
|
lots,
|
|
onAdjust,
|
|
onRefresh,
|
|
}: {
|
|
lots: SupplyLot[];
|
|
onAdjust: (lotId: number, countedQuantity: number, reason: string) => Promise<void>;
|
|
onRefresh: () => void;
|
|
}) => {
|
|
const [search, setSearch] = useState('');
|
|
const [adjustments, setAdjustments] = useState<Record<number, { countedQuantity: string; reason: string }>>({});
|
|
const normalizedSearch = normalizeSearch(search);
|
|
const visibleLots = lots.filter(lot => (
|
|
!normalizedSearch || normalizeSearch(`${lot.product} ${lot.category} ${lot.supplier} ${lot.invoice} ${lot.id}`).includes(normalizedSearch)
|
|
));
|
|
|
|
const updateAdjustment = (lotId: number, patch: Partial<{ countedQuantity: string; reason: string }>) => {
|
|
setAdjustments(current => ({
|
|
...current,
|
|
[lotId]: {
|
|
countedQuantity: current[lotId]?.countedQuantity ?? '',
|
|
reason: current[lotId]?.reason ?? '',
|
|
...patch,
|
|
},
|
|
}));
|
|
};
|
|
|
|
return (
|
|
<div className="space-y-4">
|
|
<button
|
|
type="button"
|
|
onClick={() => exportCsv('inventario-suprimentos.csv', visibleLots.map(lot => ({
|
|
lote: lot.id,
|
|
material: lot.product,
|
|
categoria: lot.category,
|
|
quantidade_sistema: lot.quantity,
|
|
unidade: lot.unit,
|
|
fornecedor: lot.supplier || 'Sem fornecedor',
|
|
})))}
|
|
className={buttonClassName}
|
|
>
|
|
<Download className="h-4 w-4" /> Exportar inventário CSV
|
|
</button>
|
|
<div className={`${panelClassName} p-5`}>
|
|
<h2 className="text-base font-bold text-dark-text">Inventário físico</h2>
|
|
<div className="mt-4 rounded-xl border border-dark-border bg-dark-input px-4 py-3 text-sm font-semibold text-dark-muted">
|
|
Compare o estoque do sistema com a contagem física. O ajuste gera movimentação com justificativa.
|
|
</div>
|
|
<div className="mt-4 grid grid-cols-1 gap-3 md:grid-cols-[1fr_auto]">
|
|
<input value={search} onChange={(event) => setSearch(event.target.value)} className={inputClassName} placeholder="Buscar item..." />
|
|
<button type="button" onClick={onRefresh} className={buttonClassName}><RefreshCw className="h-4 w-4" /> Recarregar</button>
|
|
</div>
|
|
{visibleLots.length ? (
|
|
<div className="mt-4 grid grid-cols-1 gap-3 md:grid-cols-2 xl:grid-cols-3">
|
|
{visibleLots.map(lot => (
|
|
<div key={lot.id} className="rounded-xl border border-dark-border bg-dark-input/35 p-4">
|
|
<p className="text-sm font-bold text-dark-text">{lot.product}</p>
|
|
<p className="mt-1 text-xs font-semibold text-dark-muted">{lot.category} · lote #{lot.id}</p>
|
|
<p className="mt-3 text-xl font-bold text-dark-text">{formatNumber(lot.quantity)} {lot.unit}</p>
|
|
<p className="mt-1 text-xs font-semibold text-dark-muted">{lot.supplier || 'Sem fornecedor'}</p>
|
|
<div className="mt-4 grid grid-cols-1 gap-2">
|
|
<label className="text-xs font-bold text-dark-muted">
|
|
Quantidade contada
|
|
<input
|
|
inputMode="decimal"
|
|
value={adjustments[lot.id]?.countedQuantity ?? ''}
|
|
onChange={(event) => updateAdjustment(lot.id, { countedQuantity: event.target.value })}
|
|
className={`${inputClassName} mt-1`}
|
|
placeholder={`${formatNumber(lot.quantity)} ${lot.unit}`}
|
|
/>
|
|
</label>
|
|
<label className="text-xs font-bold text-dark-muted">
|
|
Justificativa
|
|
<input
|
|
value={adjustments[lot.id]?.reason ?? ''}
|
|
onChange={(event) => updateAdjustment(lot.id, { reason: event.target.value })}
|
|
className={`${inputClassName} mt-1`}
|
|
placeholder="ex: contagem física"
|
|
/>
|
|
</label>
|
|
<button
|
|
type="button"
|
|
onClick={async () => {
|
|
const rawQuantity = adjustments[lot.id]?.countedQuantity ?? '';
|
|
if (!rawQuantity.trim()) return;
|
|
const nextQuantity = parseDecimal(rawQuantity);
|
|
const reason = adjustments[lot.id]?.reason.trim() ?? '';
|
|
await onAdjust(lot.id, nextQuantity, reason);
|
|
setAdjustments(current => ({ ...current, [lot.id]: { countedQuantity: '', reason: '' } }));
|
|
}}
|
|
className={buttonClassName}
|
|
>
|
|
<ClipboardCheck className="h-4 w-4" />
|
|
Ajustar lote
|
|
</button>
|
|
</div>
|
|
</div>
|
|
))}
|
|
</div>
|
|
) : (
|
|
<div className={emptyStateClassName}>
|
|
<ClipboardCheck className="h-8 w-8 text-brand-primary" />
|
|
<h3 className="text-base font-bold text-dark-text">{lots.length ? 'Nenhum lote encontrado' : 'Nenhum lote para inventariar'}</h3>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
);
|
|
};
|
|
|
|
const movementLabels: Record<string, string> = {
|
|
receipt: 'Entrada por recebimento',
|
|
inventory_adjustment: 'Ajuste de inventário',
|
|
production_exit: 'Saída para produção',
|
|
reversal: 'Estorno',
|
|
};
|
|
|
|
const ProductionExitPanel = ({
|
|
lots,
|
|
onConsume,
|
|
}: {
|
|
lots: SupplyLot[];
|
|
onConsume: (lotId: number, quantity: number, productionOrderNumber: string, reason: string) => Promise<void>;
|
|
}) => {
|
|
const [form, setForm] = useState({
|
|
lotId: '',
|
|
quantity: '',
|
|
productionOrderNumber: '',
|
|
reason: '',
|
|
});
|
|
|
|
const selectedLot = lots.find(lot => `${lot.id}` === form.lotId);
|
|
|
|
const handleSubmit = async (event: FormEvent<HTMLFormElement>) => {
|
|
event.preventDefault();
|
|
const lotId = Number(form.lotId);
|
|
const quantity = parseDecimal(form.quantity);
|
|
if (!lotId || quantity <= 0) return;
|
|
|
|
await onConsume(lotId, quantity, form.productionOrderNumber.trim(), form.reason.trim());
|
|
setForm({ lotId: '', quantity: '', productionOrderNumber: '', reason: '' });
|
|
};
|
|
|
|
return (
|
|
<form onSubmit={handleSubmit} className={`${panelClassName} p-5`}>
|
|
<div className="flex flex-col gap-2 md:flex-row md:items-start md:justify-between">
|
|
<div>
|
|
<h2 className="text-base font-bold text-dark-text">Saída para produção</h2>
|
|
<p className="mt-1 text-sm font-semibold text-dark-muted">Baixa material de um lote e registra movimentação ligada à OP.</p>
|
|
</div>
|
|
{selectedLot && (
|
|
<span className="w-fit rounded-full border border-dark-border bg-dark-input px-2.5 py-1 text-xs font-bold text-dark-muted">
|
|
Saldo: {formatNumber(selectedLot.quantity)} {selectedLot.unit}
|
|
</span>
|
|
)}
|
|
</div>
|
|
<div className="mt-4 grid grid-cols-1 gap-3 lg:grid-cols-[1.4fr_120px_160px_1fr_auto]">
|
|
<label className="text-xs font-bold text-dark-muted">
|
|
Lote
|
|
<select value={form.lotId} onChange={(event) => setForm(current => ({ ...current, lotId: event.target.value }))} className={`${inputClassName} mt-1`}>
|
|
<option value="">Selecione...</option>
|
|
{lots.map(lot => (
|
|
<option key={lot.id} value={lot.id}>
|
|
#{lot.id} · {lot.product} · {formatNumber(lot.quantity)} {lot.unit}
|
|
</option>
|
|
))}
|
|
</select>
|
|
</label>
|
|
<label className="text-xs font-bold text-dark-muted">
|
|
Quantidade
|
|
<input inputMode="decimal" value={form.quantity} onChange={(event) => setForm(current => ({ ...current, quantity: event.target.value }))} className={`${inputClassName} mt-1`} placeholder="kg" />
|
|
</label>
|
|
<label className="text-xs font-bold text-dark-muted">
|
|
OP
|
|
<input value={form.productionOrderNumber} onChange={(event) => setForm(current => ({ ...current, productionOrderNumber: event.target.value }))} className={`${inputClassName} mt-1`} placeholder="ex: OP-123" />
|
|
</label>
|
|
<label className="text-xs font-bold text-dark-muted">
|
|
Motivo
|
|
<input value={form.reason} onChange={(event) => setForm(current => ({ ...current, reason: event.target.value }))} className={`${inputClassName} mt-1`} placeholder="ex: corte BLCS" />
|
|
</label>
|
|
<button type="submit" className={`${buttonClassName} mt-5`}>
|
|
<Repeat2 className="h-4 w-4" />
|
|
Baixar
|
|
</button>
|
|
</div>
|
|
</form>
|
|
);
|
|
};
|
|
|
|
const MovementsTab = ({
|
|
lots,
|
|
movements,
|
|
onConsume,
|
|
}: {
|
|
lots: SupplyLot[];
|
|
movements: SupplyMovement[];
|
|
onConsume: (lotId: number, quantity: number, productionOrderNumber: string, reason: string) => Promise<void>;
|
|
}) => {
|
|
const [search, setSearch] = useState('');
|
|
const [type, setType] = useState('all');
|
|
const movementTypes = Array.from(new Set(movements.map(movement => movement.type))).sort();
|
|
const normalizedSearch = normalizeSearch(search);
|
|
const visibleMovements = movements.filter(movement => {
|
|
const matchesSearch = !normalizedSearch || normalizeSearch(`${movement.product} ${movement.category} ${movement.reason} ${movement.lotId || ''}`).includes(normalizedSearch);
|
|
const matchesType = type === 'all' || movement.type === type;
|
|
return matchesSearch && matchesType;
|
|
});
|
|
|
|
return (
|
|
<div className="space-y-4">
|
|
<ProductionExitPanel lots={lots} onConsume={onConsume} />
|
|
<button
|
|
type="button"
|
|
onClick={() => exportCsv('movimentacoes-suprimentos.csv', visibleMovements.map(movement => ({
|
|
id: movement.id,
|
|
data: movement.createdAt,
|
|
tipo: movementLabels[movement.type] || movement.type,
|
|
material: movement.product,
|
|
categoria: movement.category,
|
|
quantidade: movement.quantity,
|
|
unidade: movement.unit,
|
|
lote: movement.lotId,
|
|
motivo: movement.reason,
|
|
})))}
|
|
className={buttonClassName}
|
|
>
|
|
<Download className="h-4 w-4" /> Exportar movimentações CSV
|
|
</button>
|
|
<div className={`${panelClassName} p-4`}>
|
|
<div className="grid grid-cols-1 gap-3 md:grid-cols-[1fr_220px]">
|
|
<label className="relative">
|
|
<Search className="pointer-events-none absolute left-3 top-3 h-4 w-4 text-dark-muted" />
|
|
<input value={search} onChange={(event) => setSearch(event.target.value)} className={`${inputClassName} pl-9`} placeholder="Buscar lote, tipo ou fornecedor..." />
|
|
</label>
|
|
<select value={type} onChange={(event) => setType(event.target.value)} className={inputClassName}>
|
|
<option value="all">Todos os tipos</option>
|
|
{movementTypes.map(item => <option key={item} value={item}>{movementLabels[item] || item}</option>)}
|
|
</select>
|
|
</div>
|
|
{visibleMovements.length ? (
|
|
<div className="mt-4 overflow-hidden rounded-xl border border-dark-border">
|
|
<div className="hidden grid-cols-[170px_1fr_140px_140px] border-b border-dark-border bg-dark-input/40 px-4 py-3 text-xs font-bold uppercase tracking-widest text-dark-muted md:grid">
|
|
<span>Data</span>
|
|
<span>Movimento</span>
|
|
<span>Quantidade</span>
|
|
<span>Lote</span>
|
|
</div>
|
|
<div className="divide-y divide-dark-border">
|
|
{visibleMovements.map(movement => (
|
|
<div key={movement.id} className="grid grid-cols-1 gap-2 bg-dark-card px-4 py-3 md:grid-cols-[170px_1fr_140px_140px] md:items-center">
|
|
<p className="text-sm font-semibold text-dark-muted">{formatDateTime(movement.createdAt)}</p>
|
|
<div>
|
|
<p className="text-sm font-bold text-dark-text">{movementLabels[movement.type] || movement.type}</p>
|
|
<p className="text-xs font-semibold text-dark-muted">{movement.product} · {movement.reason}</p>
|
|
</div>
|
|
<p className="text-sm font-bold text-dark-text">{formatNumber(movement.quantity)} {movement.unit}</p>
|
|
<p className="text-xs font-bold text-dark-muted">{movement.lotId ? `#${movement.lotId}` : '-'}</p>
|
|
</div>
|
|
))}
|
|
</div>
|
|
</div>
|
|
) : (
|
|
<div className={emptyStateClassName}>
|
|
<Repeat2 className="h-8 w-8 text-brand-primary" />
|
|
<h3 className="text-base font-bold text-dark-text">{movements.length ? 'Nenhuma movimentação encontrada' : 'Nenhuma movimentação ainda'}</h3>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
);
|
|
};
|
|
|
|
const InventoryScreen = () => {
|
|
const [searchParams, setSearchParams] = useSearchParams();
|
|
const requestedTab = searchParams.get('tab');
|
|
const activeTab = inventoryTabs.some(tab => tab.id === requestedTab) ? requestedTab as InventoryTab : 'dashboard';
|
|
const setActiveTab = (tab: InventoryTab) => setSearchParams(tab === 'dashboard' ? {} : { tab });
|
|
const [summary, setSummary] = useState<SupplySummary>(emptySupplySummary);
|
|
const [stockSnapshot, setStockSnapshot] = useState<StockData[]>([]);
|
|
const [isLoading, setIsLoading] = useState(true);
|
|
const [isBusy, setIsBusy] = useState(false);
|
|
const [errorMessage, setErrorMessage] = useState('');
|
|
|
|
const loadInventoryData = async () => {
|
|
setErrorMessage('');
|
|
const [nextSummary, nextStockSnapshot] = await Promise.all([
|
|
fetchSupplySummary(),
|
|
fetchStock(),
|
|
]);
|
|
setSummary(nextSummary);
|
|
setStockSnapshot(nextStockSnapshot);
|
|
};
|
|
|
|
const loadSummary = async () => {
|
|
setErrorMessage('');
|
|
const nextSummary = await fetchSupplySummary();
|
|
setSummary(nextSummary);
|
|
};
|
|
|
|
const loadStockSnapshot = async () => {
|
|
setErrorMessage('');
|
|
setStockSnapshot(await fetchStock());
|
|
};
|
|
|
|
useEffect(() => {
|
|
let isMounted = true;
|
|
|
|
const load = async () => {
|
|
setIsLoading(true);
|
|
try {
|
|
const [nextSummary, nextStockSnapshot] = await Promise.all([
|
|
fetchSupplySummary(),
|
|
fetchStock(),
|
|
]);
|
|
if (isMounted) {
|
|
setSummary(nextSummary);
|
|
setStockSnapshot(nextStockSnapshot);
|
|
}
|
|
} finally {
|
|
if (isMounted) setIsLoading(false);
|
|
}
|
|
};
|
|
|
|
load();
|
|
|
|
return () => {
|
|
isMounted = false;
|
|
};
|
|
}, []);
|
|
|
|
const runSupplyAction = async (action: () => Promise<void>) => {
|
|
setIsBusy(true);
|
|
setErrorMessage('');
|
|
try {
|
|
await action();
|
|
await loadInventoryData();
|
|
} catch (error) {
|
|
setErrorMessage(error instanceof Error ? error.message : 'Não foi possível atualizar suprimentos.');
|
|
} finally {
|
|
setIsBusy(false);
|
|
}
|
|
};
|
|
|
|
return (
|
|
<div className={pageClassName}>
|
|
<Header title="Controle de Estoque" subtitle="Saldo, lotes, entradas e inventário." backTo="/supplies" />
|
|
<div className="flex flex-wrap gap-2">
|
|
{inventoryTabs.map(tab => (
|
|
<button
|
|
key={tab.id}
|
|
type="button"
|
|
onClick={() => setActiveTab(tab.id)}
|
|
className={`inline-flex h-10 items-center justify-center gap-2 rounded-lg px-3 text-sm font-bold transition-colors cursor-pointer ${
|
|
activeTab === tab.id ? 'bg-brand-primary text-brand-contrast' : 'bg-dark-input text-dark-text hover:bg-dark-card'
|
|
}`}
|
|
>
|
|
<tab.icon className="h-4 w-4" />
|
|
{tab.name}
|
|
</button>
|
|
))}
|
|
</div>
|
|
{errorMessage && (
|
|
<div className="rounded-xl border border-red-500/30 bg-red-500/10 px-4 py-3 text-sm font-bold text-red-300">
|
|
{errorMessage}
|
|
</div>
|
|
)}
|
|
{isLoading ? (
|
|
<div className={`${panelClassName} p-5 text-sm font-bold text-dark-muted`}>Carregando estoque...</div>
|
|
) : (
|
|
<>
|
|
{activeTab === 'dashboard' && <InventoryDashboard summary={summary} stock={stockSnapshot} onNewReceipt={() => setActiveTab('receipts')} onOpenStock={() => setActiveTab('stock')} />}
|
|
{activeTab === 'stock' && <StockTab stock={stockSnapshot} onRefresh={loadStockSnapshot} />}
|
|
{activeTab === 'balance' && <BalanceTab summary={summary} onRefresh={loadSummary} />}
|
|
{activeTab === 'receipts' && (
|
|
<ReceiptsTab
|
|
receipts={summary.receipts}
|
|
isBusy={isBusy}
|
|
onCreate={(payload) => runSupplyAction(async () => {
|
|
await createSupplyReceipt(payload);
|
|
})}
|
|
onApprove={(receiptId) => runSupplyAction(async () => {
|
|
await approveSupplyReceipt(receiptId);
|
|
})}
|
|
onRemove={(receiptId) => runSupplyAction(async () => {
|
|
await deleteSupplyReceipt(receiptId);
|
|
})}
|
|
/>
|
|
)}
|
|
{activeTab === 'inventory' && (
|
|
<InventoryCountTab
|
|
lots={summary.lots}
|
|
onRefresh={loadSummary}
|
|
onAdjust={(lotId, countedQuantity, reason) => runSupplyAction(async () => {
|
|
await adjustSupplyLotInventory(lotId, { countedQuantity, reason });
|
|
})}
|
|
/>
|
|
)}
|
|
{activeTab === 'movements' && (
|
|
<MovementsTab
|
|
lots={summary.lots}
|
|
movements={summary.movements}
|
|
onConsume={(lotId, quantity, productionOrderNumber, reason) => runSupplyAction(async () => {
|
|
await consumeSupplyLotForProduction(lotId, { quantity, productionOrderNumber, reason });
|
|
})}
|
|
/>
|
|
)}
|
|
</>
|
|
)}
|
|
</div>
|
|
);
|
|
};
|
|
|
|
const FabricPlanningScreen = () => {
|
|
const [plans, setPlans] = useState<SupplyFabricPlan[]>([]);
|
|
const [summary, setSummary] = useState<SupplySummary>(emptySupplySummary);
|
|
const [form, setForm] = useState({
|
|
material: '',
|
|
color: '',
|
|
quantityKg: '',
|
|
supplier: '',
|
|
priority: 'Normal',
|
|
});
|
|
const [isLoading, setIsLoading] = useState(true);
|
|
const [isBusy, setIsBusy] = useState(false);
|
|
const [errorMessage, setErrorMessage] = useState('');
|
|
|
|
const fabricNeeds = summary.purchaseNeeds.filter(need => (
|
|
!need.missingReference &&
|
|
!isServicePurchaseNeed(need) &&
|
|
/\b(?:MALHA|TECIDO|RIBANA|MOLETOM)\b/i.test(need.material)
|
|
));
|
|
const fabricNeedsToBuy = fabricNeeds.filter(need => need.purchaseKg > 0);
|
|
const fabricNeedUnits = new Set(fabricNeeds.flatMap(need => (need.products || []).map(product => product.productId).filter(Boolean))).size;
|
|
const plannedFabricKg = fabricNeeds.reduce((total, need) => total + Math.max(0, need.plannedKg), 0);
|
|
|
|
const loadPlans = async () => {
|
|
setErrorMessage('');
|
|
const summary = await fetchSupplySummary();
|
|
setPlans(summary.fabricPlans);
|
|
setSummary(summary);
|
|
};
|
|
|
|
useEffect(() => {
|
|
let isMounted = true;
|
|
|
|
const load = async () => {
|
|
setIsLoading(true);
|
|
try {
|
|
const summary = await fetchSupplySummary();
|
|
if (isMounted) {
|
|
setPlans(summary.fabricPlans);
|
|
setSummary(summary);
|
|
}
|
|
} finally {
|
|
if (isMounted) setIsLoading(false);
|
|
}
|
|
};
|
|
|
|
load();
|
|
|
|
return () => {
|
|
isMounted = false;
|
|
};
|
|
}, []);
|
|
|
|
const handleSubmit = async (event: FormEvent<HTMLFormElement>) => {
|
|
event.preventDefault();
|
|
const material = form.material.trim();
|
|
const quantityKg = parseDecimal(form.quantityKg);
|
|
if (!material || quantityKg <= 0) return;
|
|
|
|
setIsBusy(true);
|
|
setErrorMessage('');
|
|
try {
|
|
await createSupplyFabricPlan({
|
|
material,
|
|
color: form.color.trim() || 'Todas as cores',
|
|
quantityKg,
|
|
supplier: form.supplier.trim() || 'Sem fornecedor',
|
|
priority: form.priority,
|
|
});
|
|
await loadPlans();
|
|
setForm({ material: '', color: '', quantityKg: '', supplier: '', priority: 'Normal' });
|
|
} catch (error) {
|
|
setErrorMessage(error instanceof Error ? error.message : 'Não foi possível salvar o plano de malha.');
|
|
} finally {
|
|
setIsBusy(false);
|
|
}
|
|
};
|
|
|
|
const removePlan = async (planId: number) => {
|
|
setIsBusy(true);
|
|
setErrorMessage('');
|
|
try {
|
|
await deleteSupplyFabricPlan(planId);
|
|
await loadPlans();
|
|
} catch (error) {
|
|
setErrorMessage(error instanceof Error ? error.message : 'Não foi possível remover o plano de malha.');
|
|
} finally {
|
|
setIsBusy(false);
|
|
}
|
|
};
|
|
|
|
return (
|
|
<div className={pageClassName}>
|
|
<Header title="Planejamento de Malha" subtitle="Fila de tecidos que abastece o corte; a compra continua sendo decidida em Necessidade de Compra." backTo="/supplies" />
|
|
{errorMessage && (
|
|
<div className="rounded-xl border border-red-500/30 bg-red-500/10 px-4 py-3 text-sm font-bold text-red-300">
|
|
{errorMessage}
|
|
</div>
|
|
)}
|
|
<div className="grid grid-cols-1 gap-4 md:grid-cols-3">
|
|
<div className="rounded-2xl border border-dark-border bg-dark-card p-5 shadow-sm">
|
|
<p className="text-xs font-bold uppercase tracking-widest text-dark-muted">Tecidos com necessidade</p>
|
|
<p className="mt-2 text-3xl font-bold text-dark-text">{fabricNeedsToBuy.length}</p>
|
|
<p className="mt-1 text-xs font-semibold text-dark-muted">{formatNumber(fabricNeedUnits, 0)} SKUs dependem deles</p>
|
|
</div>
|
|
<div className="rounded-2xl border border-dark-border bg-dark-card p-5 shadow-sm">
|
|
<p className="text-xs font-bold uppercase tracking-widest text-dark-muted">Demanda de tecido</p>
|
|
<p className="mt-2 text-3xl font-bold text-dark-text">{formatNumber(plannedFabricKg)} kg</p>
|
|
<p className="mt-1 text-xs font-semibold text-dark-muted">necessidade planejada para o período</p>
|
|
</div>
|
|
<div className="rounded-2xl border border-dark-border bg-dark-card p-5 shadow-sm">
|
|
<p className="text-xs font-bold uppercase tracking-widest text-dark-muted">Planos locais ativos</p>
|
|
<p className="mt-2 text-3xl font-bold text-dark-text">{plans.length}</p>
|
|
<p className="mt-1 text-xs font-semibold text-dark-muted">{plans.filter(plan => plan.priority === 'Crítico').length} críticos</p>
|
|
</div>
|
|
</div>
|
|
|
|
<div className={`${panelClassName} overflow-hidden`}>
|
|
<div className="flex flex-col gap-4 border-b border-dark-border p-5 md:flex-row md:items-start md:justify-between">
|
|
<div>
|
|
<h2 className="text-base font-bold text-dark-text">Fila de tecidos para abastecer o corte</h2>
|
|
<p className="mt-1 text-sm font-semibold text-dark-muted">Necessidade calculada pela composição, saldo sincronizado e recebimentos pendentes.</p>
|
|
</div>
|
|
<RouterLink to="/supplies/purchase-needs?tab=materials&filter=to_buy" className={buttonClassName}>Abrir compra de materiais <ArrowRight className="h-4 w-4" /></RouterLink>
|
|
</div>
|
|
{isLoading ? (
|
|
<div className={emptyStateClassName}>
|
|
<Ruler className="h-8 w-8 text-brand-primary" />
|
|
<h3 className="text-base font-bold text-dark-text">Calculando fila de tecidos...</h3>
|
|
</div>
|
|
) : fabricNeeds.length ? (
|
|
<div className="divide-y divide-dark-border">
|
|
{fabricNeeds.slice().sort((a, b) => b.purchaseKg - a.purchaseKg).map(need => {
|
|
const impactedSkus = new Set((need.products || []).map(product => product.productId).filter(Boolean)).size;
|
|
const isCovered = need.purchaseKg <= 0;
|
|
return (
|
|
<div key={need.material} className="grid grid-cols-1 gap-4 px-5 py-4 lg:grid-cols-[minmax(240px,1.25fr)_minmax(360px,1fr)_150px] lg:items-center">
|
|
<div>
|
|
<p className="text-sm font-bold text-dark-text">{need.material}</p>
|
|
<p className="mt-1 text-xs font-semibold text-dark-muted">{impactedSkus} SKUs impactados · {need.suppliers.length ? need.suppliers.join(' · ') : 'Fornecedor não informado'}</p>
|
|
</div>
|
|
<div className="grid grid-cols-2 gap-2 sm:grid-cols-4">
|
|
{[
|
|
['Necessário', need.plannedKg],
|
|
['Em estoque', need.stockKg],
|
|
['A receber', need.pendingKg],
|
|
['Comprar', need.purchaseKg],
|
|
].map(([label, value]) => (
|
|
<div key={label as string} className="rounded-lg border border-dark-border bg-dark-input/45 px-3 py-2">
|
|
<p className="text-[10px] font-bold uppercase tracking-wider text-dark-muted">{label}</p>
|
|
<p className={`mt-1 text-sm font-bold ${label === 'Comprar' && !isCovered ? 'text-amber-300' : label === 'Em estoque' && Number(value) < 0 ? 'text-red-300' : 'text-dark-text'}`}>{formatNumber(Number(value))} {need.unit || 'kg'}</p>
|
|
</div>
|
|
))}
|
|
</div>
|
|
<div className="lg:text-right">
|
|
<span className={`inline-flex rounded-full border px-2.5 py-1 text-xs font-bold ${isCovered ? 'border-emerald-400/30 bg-emerald-400/10 text-emerald-300' : 'border-amber-400/30 bg-amber-400/10 text-amber-300'}`}>{isCovered ? 'Coberto' : 'Comprar tecido'}</span>
|
|
</div>
|
|
</div>
|
|
);
|
|
})}
|
|
</div>
|
|
) : (
|
|
<div className={emptyStateClassName}>
|
|
<Ruler className="h-8 w-8 text-brand-primary" />
|
|
<h3 className="text-base font-bold text-dark-text">Nenhum tecido identificado na composição</h3>
|
|
<p className="text-sm font-semibold text-dark-muted">Mapeie consumo de malha, tecido ou ribana para gerar esta fila.</p>
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
<div className="grid grid-cols-1 items-stretch gap-6 xl:grid-cols-[1fr_420px]">
|
|
<div className={`${panelClassName} flex flex-col overflow-hidden`}>
|
|
<div className="border-b border-dark-border p-5">
|
|
<h2 className="text-base font-bold text-dark-text">Planos locais de recebimento e alocação</h2>
|
|
<p className="mt-1 text-sm font-semibold text-dark-muted">Registros operacionais internos; não substituem a necessidade calculada acima.</p>
|
|
</div>
|
|
{isLoading ? (
|
|
<div className={`${emptyStateClassName} min-h-[220px] flex-1`}>
|
|
<Ruler className="h-8 w-8 text-brand-primary" />
|
|
<h3 className="text-base font-bold text-dark-text">Carregando planos...</h3>
|
|
</div>
|
|
) : plans.length ? (
|
|
<div className="divide-y divide-dark-border">
|
|
{plans.map(plan => (
|
|
<div key={plan.id} className="grid grid-cols-1 gap-3 p-4 md:grid-cols-[1.4fr_1fr_100px_100px_44px] md:items-center">
|
|
<div>
|
|
<p className="font-bold text-dark-text">{plan.material}</p>
|
|
<p className="text-xs font-semibold text-dark-muted">{plan.color}</p>
|
|
</div>
|
|
<p className="text-sm font-semibold text-dark-muted">{plan.supplier}</p>
|
|
<p className="text-sm font-bold text-dark-text">{formatNumber(plan.quantityKg)} kg</p>
|
|
<span className="w-fit rounded-full border border-dark-border bg-dark-input px-2.5 py-1 text-xs font-bold text-dark-text">{plan.priority}</span>
|
|
<button
|
|
type="button"
|
|
title="Remover plano"
|
|
aria-label={`Remover plano ${plan.material}`}
|
|
disabled={isBusy}
|
|
onClick={() => removePlan(plan.id)}
|
|
className="inline-flex h-10 w-10 items-center justify-center rounded-lg text-red-400 transition-colors hover:bg-red-500/10 hover:text-red-300 disabled:cursor-not-allowed disabled:opacity-60 cursor-pointer"
|
|
>
|
|
<Trash2 className="h-4 w-4" />
|
|
</button>
|
|
</div>
|
|
))}
|
|
</div>
|
|
) : (
|
|
<div className={`${emptyStateClassName} flex-1`}>
|
|
<Ruler className="h-8 w-8 text-brand-primary" />
|
|
<h3 className="text-base font-bold text-dark-text">Nenhum plano de malha cadastrado</h3>
|
|
<p className="text-sm font-semibold text-dark-muted">Cadastre o primeiro plano no formulário ao lado.</p>
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
<form onSubmit={handleSubmit} className={`${panelClassName} p-5`}>
|
|
<h2 className="text-base font-bold text-dark-text">Novo plano de malha</h2>
|
|
<div className="mt-4 grid grid-cols-1 gap-3">
|
|
<label className="text-xs font-bold text-dark-muted">
|
|
Malha / tecido
|
|
<input value={form.material} onChange={(event) => setForm(current => ({ ...current, material: event.target.value }))} className={`${inputClassName} mt-1`} placeholder="ex: Meia malha 30.1" />
|
|
</label>
|
|
<label className="text-xs font-bold text-dark-muted">
|
|
Cor
|
|
<input value={form.color} onChange={(event) => setForm(current => ({ ...current, color: event.target.value }))} className={`${inputClassName} mt-1`} placeholder="Todas as cores" />
|
|
</label>
|
|
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
|
|
<label className="text-xs font-bold text-dark-muted">
|
|
Quantidade kg
|
|
<input inputMode="decimal" value={form.quantityKg} onChange={(event) => setForm(current => ({ ...current, quantityKg: event.target.value }))} className={`${inputClassName} mt-1`} placeholder="ex: 180" />
|
|
</label>
|
|
<label className="text-xs font-bold text-dark-muted">
|
|
Prioridade
|
|
<select value={form.priority} onChange={(event) => setForm(current => ({ ...current, priority: event.target.value }))} className={`${inputClassName} mt-1`}>
|
|
<option>Normal</option>
|
|
<option>Atenção</option>
|
|
<option>Crítico</option>
|
|
</select>
|
|
</label>
|
|
</div>
|
|
<label className="text-xs font-bold text-dark-muted">
|
|
Fornecedor
|
|
<input value={form.supplier} onChange={(event) => setForm(current => ({ ...current, supplier: event.target.value }))} className={`${inputClassName} mt-1`} placeholder="Opcional" />
|
|
</label>
|
|
<button type="submit" disabled={isBusy} className="mt-2 inline-flex h-11 items-center justify-center gap-2 rounded-lg bg-brand-primary px-4 text-sm font-bold text-brand-contrast transition-opacity hover:opacity-90 disabled:cursor-not-allowed disabled:opacity-60 cursor-pointer">
|
|
<Save className="h-4 w-4" />
|
|
{isBusy ? 'Salvando...' : 'Salvar plano'}
|
|
</button>
|
|
</div>
|
|
</form>
|
|
</div>
|
|
</div>
|
|
);
|
|
};
|
|
|
|
const purchaseStatusLabels: Record<SupplyPurchaseNeed['status'], string> = {
|
|
critical: 'Sem estoque',
|
|
attention: 'Comprar',
|
|
ok: 'Coberto',
|
|
};
|
|
|
|
const getNeedUnit = (need: SupplyPurchaseNeed) => need.unit || 'kg';
|
|
|
|
const formatNeedQuantity = (need: SupplyPurchaseNeed, value: number) => (
|
|
`${formatNumber(value)} ${getNeedUnit(need)}`
|
|
);
|
|
|
|
const 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">A aprovação financeira e o recebimento físico continuam sendo controlados em Estoque e recebimentos.</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="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)_390px]">
|
|
<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;
|