1312 lines
59 KiB
TypeScript
1312 lines
59 KiB
TypeScript
import { type FormEvent, useEffect, useState } from 'react';
|
|
import { Link as RouterLink, Navigate, useParams } from 'react-router-dom';
|
|
import {
|
|
AlertTriangle,
|
|
ArrowLeft,
|
|
ArrowRight,
|
|
BarChart3,
|
|
Boxes,
|
|
ClipboardCheck,
|
|
ClipboardList,
|
|
Download,
|
|
Link as LinkIcon,
|
|
Package,
|
|
RefreshCw,
|
|
Repeat2,
|
|
Ruler,
|
|
Save,
|
|
Search,
|
|
Scissors,
|
|
Truck,
|
|
Trash2,
|
|
Warehouse,
|
|
} from 'lucide-react';
|
|
import { adjustSupplyLotInventory, approveSupplyReceipt, consumeSupplyLotForProduction, createSupplyFabricPlan, createSupplyReceipt, deleteSupplyFabricPlan, deleteSupplyReceipt, fetchSupplySummary } from '../dataService';
|
|
import type { SupplyFabricPlan, SupplyLot, SupplyMovement, SupplyPurchaseNeed, SupplyReceipt, SupplySummary } from '../types';
|
|
|
|
type InventoryTab = 'dashboard' | 'balance' | 'receipts' | 'inventory' | 'movements';
|
|
type ReceiptView = 'new' | 'pending' | 'history';
|
|
|
|
const pageClassName = 'mx-auto flex w-full max-w-7xl flex-col gap-6';
|
|
const panelClassName = 'rounded-2xl border border-dark-border bg-dark-card shadow-sm';
|
|
const buttonClassName = 'inline-flex h-10 items-center justify-center gap-2 rounded-lg border border-dark-border bg-dark-input px-3 text-sm font-bold text-dark-text transition-colors hover:border-brand-primary cursor-pointer';
|
|
const inputClassName = 'h-10 w-full rounded-lg border border-dark-border bg-dark-input px-3 text-sm font-semibold text-dark-text outline-none placeholder:text-dark-muted focus:border-brand-primary';
|
|
const emptyStateClassName = 'flex min-h-[190px] flex-col items-center justify-center gap-2 px-4 py-10 text-center';
|
|
|
|
const emptySupplySummary: SupplySummary = {
|
|
receipts: [],
|
|
lots: [],
|
|
movements: [],
|
|
fabricPlans: [],
|
|
purchaseNeeds: [],
|
|
stats: {
|
|
totalQuantityKg: 0,
|
|
activeLots: 0,
|
|
rolls: 0,
|
|
alerts: 0,
|
|
pendingReceipts: 0,
|
|
approvedReceipts: 0,
|
|
},
|
|
};
|
|
|
|
const formatNumber = (value: number, maximumFractionDigits = 2) => (
|
|
new Intl.NumberFormat('pt-BR', { maximumFractionDigits }).format(value)
|
|
);
|
|
|
|
const parseDecimal = (value: string) => {
|
|
const number = Number(value.replace(',', '.'));
|
|
return Number.isFinite(number) ? number : 0;
|
|
};
|
|
|
|
const formatDateTime = (value: string | null) => {
|
|
if (!value) return '-';
|
|
return new Intl.DateTimeFormat('pt-BR', {
|
|
day: '2-digit',
|
|
month: '2-digit',
|
|
year: '2-digit',
|
|
hour: '2-digit',
|
|
minute: '2-digit',
|
|
}).format(new Date(value));
|
|
};
|
|
|
|
const normalizeSearch = (value: string) => value.normalize('NFD').replace(/[\u0300-\u036f]/g, '').toLowerCase();
|
|
|
|
const exportCsv = (filename: string, rows: Array<Record<string, string | number | null>>) => {
|
|
if (!rows.length) return;
|
|
const headers = Object.keys(rows[0]);
|
|
const escapeCell = (value: string | number | null) => `"${String(value ?? '').replace(/"/g, '""')}"`;
|
|
const csv = [
|
|
headers.join(','),
|
|
...rows.map(row => headers.map(header => escapeCell(row[header])).join(',')),
|
|
].join('\n');
|
|
const blob = new Blob([csv], { type: 'text/csv;charset=utf-8;' });
|
|
const url = URL.createObjectURL(blob);
|
|
const anchor = document.createElement('a');
|
|
anchor.href = url;
|
|
anchor.download = filename;
|
|
anchor.click();
|
|
URL.revokeObjectURL(url);
|
|
};
|
|
|
|
const inventoryTabs: Array<{ id: InventoryTab; name: string; icon: typeof BarChart3 }> = [
|
|
{ id: 'dashboard', name: 'Dashboard', icon: Warehouse },
|
|
{ id: '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 },
|
|
];
|
|
|
|
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="text-2xl font-bold text-dark-text">{title}</h1>
|
|
<p className="mt-1 text-sm font-semibold text-dark-muted">{subtitle}</p>
|
|
</div>
|
|
</div>
|
|
);
|
|
|
|
const ModuleCard = ({
|
|
title,
|
|
description,
|
|
icon: Icon,
|
|
to,
|
|
}: {
|
|
title: string;
|
|
description: string;
|
|
icon: typeof Package;
|
|
to: string;
|
|
}) => {
|
|
return (
|
|
<RouterLink to={to} className={`${panelClassName} flex min-h-32 items-start gap-4 p-5 transition-colors hover:border-brand-primary`}>
|
|
<div className="flex h-11 w-11 items-center justify-center rounded-xl border border-dark-border bg-dark-input text-brand-primary">
|
|
<Icon className="h-5 w-5" />
|
|
</div>
|
|
<div className="min-w-0 flex-1">
|
|
<div className="flex items-start justify-between gap-3">
|
|
<h2 className="text-base font-bold text-dark-text">{title}</h2>
|
|
<ArrowRight className="mt-0.5 h-4 w-4 shrink-0 text-dark-muted" />
|
|
</div>
|
|
<p className="mt-2 text-sm font-semibold text-dark-muted">{description}</p>
|
|
</div>
|
|
</RouterLink>
|
|
);
|
|
};
|
|
|
|
const SuppliesHub = () => (
|
|
<div className={pageClassName}>
|
|
<Header title="Suprimentos" subtitle="Corte, estoque, malha e compras em um fluxo operacional." />
|
|
<div className="grid grid-cols-1 gap-4 lg:grid-cols-2">
|
|
<ModuleCard title="Planejamento de Corte" description="Ruptura por cor e tamanho, montagem do corte e prioridade por cobertura." icon={Scissors} to="/cutting" />
|
|
<ModuleCard title="Controle de Estoque" description="Saldo, lotes, recebimentos, inventário e movimentações." icon={Package} to="/supplies/inventory" />
|
|
<ModuleCard title="Ordens de Produção" description="Ordens geradas pelo corte e acompanhamento de produção." icon={ClipboardList} to="/production-orders" />
|
|
<ModuleCard title="Planejamento de Malha" description="Fila de matéria-prima para compra, recebimento e abastecimento do corte." icon={Ruler} to="/supplies/fabric-planning" />
|
|
<ModuleCard title="Necessidade de Compra" description="Itens abaixo do mínimo e necessidade projetada para compra." icon={BarChart3} to="/supplies/purchase-needs" />
|
|
</div>
|
|
</div>
|
|
);
|
|
|
|
const StatGrid = ({ summary }: { summary: SupplySummary }) => {
|
|
const stats = [
|
|
{ label: 'Total em estoque', value: `${formatNumber(summary.stats.totalQuantityKg)} kg` },
|
|
{ label: 'Lotes ativos', value: `${summary.stats.activeLots}` },
|
|
{ label: 'Rolos', value: `${formatNumber(summary.stats.rolls, 0)}` },
|
|
{ label: 'Alertas', value: `${summary.stats.alerts}` },
|
|
];
|
|
|
|
return (
|
|
<div className="grid grid-cols-2 gap-3 lg:grid-cols-4">
|
|
{stats.map(stat => (
|
|
<div key={stat.label} className="rounded-xl border border-dark-border bg-dark-input/40 px-4 py-4 text-center">
|
|
<p className="text-2xl font-bold text-dark-text">{stat.value}</p>
|
|
<p className="mt-1 text-xs font-semibold text-dark-muted">{stat.label}</p>
|
|
</div>
|
|
))}
|
|
</div>
|
|
);
|
|
};
|
|
|
|
const InventoryDashboard = ({ summary, onNewReceipt }: { summary: SupplySummary; onNewReceipt: () => void }) => {
|
|
const categories = summary.lots.reduce<Record<string, { quantity: number; unit: string; lots: number }>>((acc, lot) => {
|
|
const current = acc[lot.category] || { quantity: 0, unit: lot.unit, lots: 0 };
|
|
acc[lot.category] = {
|
|
quantity: current.quantity + lot.quantity,
|
|
unit: current.unit === lot.unit ? lot.unit : 'mix',
|
|
lots: current.lots + 1,
|
|
};
|
|
return acc;
|
|
}, {});
|
|
const latestReceipts = summary.receipts.slice(0, 4);
|
|
|
|
return (
|
|
<div className="space-y-4">
|
|
<StatGrid summary={summary} />
|
|
<div className={`${panelClassName} p-5`}>
|
|
<h2 className="text-base font-bold text-dark-text">Por categoria de material</h2>
|
|
{Object.keys(categories).length ? (
|
|
<div className="mt-4 grid grid-cols-1 gap-3 md:grid-cols-2">
|
|
{Object.entries(categories).map(([category, data]) => (
|
|
<div key={category} className="rounded-xl border border-dark-border bg-dark-input/35 p-4">
|
|
<p className="text-sm font-bold text-dark-text">{category}</p>
|
|
<p className="mt-2 text-2xl font-bold text-dark-text">{formatNumber(data.quantity)} {data.unit}</p>
|
|
<p className="mt-1 text-xs font-semibold text-dark-muted">{data.lots} lote(s) ativo(s)</p>
|
|
</div>
|
|
))}
|
|
</div>
|
|
) : (
|
|
<div className={emptyStateClassName}>
|
|
<Warehouse className="h-8 w-8 text-brand-primary" />
|
|
<h3 className="text-base font-bold text-dark-text">Nenhuma categoria com saldo</h3>
|
|
<p className="text-sm font-semibold text-dark-muted">Registre entradas para agrupar o estoque por tipo de material.</p>
|
|
</div>
|
|
)}
|
|
</div>
|
|
<div className={`${panelClassName} p-5`}>
|
|
<h2 className="flex items-center gap-2 text-base font-bold text-dark-text"><AlertTriangle className="h-4 w-4 text-yellow-400" /> Alertas de estoque</h2>
|
|
<p className="mt-4 text-sm font-semibold text-dark-muted">Nenhum alerta no momento.</p>
|
|
</div>
|
|
<div className={`${panelClassName} p-5`}>
|
|
<div className="flex items-center justify-between gap-3">
|
|
<h2 className="text-base font-bold text-dark-text">Últimas entradas</h2>
|
|
<button type="button" onClick={onNewReceipt} className={buttonClassName}>Nova entrada</button>
|
|
</div>
|
|
{latestReceipts.length ? (
|
|
<div className="mt-4 divide-y divide-dark-border overflow-hidden rounded-xl border border-dark-border">
|
|
{latestReceipts.map(receipt => (
|
|
<div key={receipt.id} className="grid grid-cols-1 gap-2 bg-dark-input/25 p-3 md:grid-cols-[1fr_auto_auto] md:items-center">
|
|
<div>
|
|
<p className="text-sm font-bold text-dark-text">{receipt.product}</p>
|
|
<p className="text-xs font-semibold text-dark-muted">{receipt.category} · {formatDateTime(receipt.createdAt)}</p>
|
|
</div>
|
|
<p className="text-sm font-bold text-dark-text">{formatNumber(receipt.quantity)} {receipt.unit}</p>
|
|
<span className={`w-fit rounded-full border px-2.5 py-1 text-xs font-bold ${
|
|
receipt.status === 'approved'
|
|
? 'border-emerald-400/30 bg-emerald-400/10 text-emerald-300'
|
|
: 'border-amber-400/30 bg-amber-400/10 text-amber-300'
|
|
}`}>
|
|
{receipt.status === 'approved' ? 'Aprovado' : 'Pendente'}
|
|
</span>
|
|
</div>
|
|
))}
|
|
</div>
|
|
) : (
|
|
<p className="mt-5 text-sm font-semibold text-dark-muted">Nenhuma entrada registrada.</p>
|
|
)}
|
|
</div>
|
|
</div>
|
|
);
|
|
};
|
|
|
|
const BalanceTab = ({ summary, onRefresh }: { summary: SupplySummary; onRefresh: () => void }) => {
|
|
const [search, setSearch] = useState('');
|
|
const [category, setCategory] = useState('all');
|
|
const [supplier, setSupplier] = useState('all');
|
|
const categories = Array.from(new Set(summary.lots.map(lot => lot.category))).sort();
|
|
const suppliers = Array.from(new Set(summary.lots.map(lot => lot.supplier || 'Sem fornecedor'))).sort();
|
|
const normalizedSearch = normalizeSearch(search);
|
|
const visibleLots = summary.lots.filter(lot => {
|
|
const lotSupplier = lot.supplier || 'Sem fornecedor';
|
|
const matchesSearch = !normalizedSearch || normalizeSearch(`${lot.product} ${lot.category} ${lotSupplier} ${lot.invoice} ${lot.id}`).includes(normalizedSearch);
|
|
const matchesCategory = category === 'all' || lot.category === category;
|
|
const matchesSupplier = supplier === 'all' || lotSupplier === supplier;
|
|
return matchesSearch && matchesCategory && matchesSupplier;
|
|
});
|
|
|
|
return (
|
|
<div className="space-y-4">
|
|
<div className="flex flex-wrap gap-2">
|
|
<button
|
|
type="button"
|
|
onClick={() => exportCsv('saldo-suprimentos.csv', visibleLots.map(lot => ({
|
|
lote: lot.id,
|
|
material: lot.product,
|
|
categoria: lot.category,
|
|
quantidade: lot.quantity,
|
|
unidade: lot.unit,
|
|
fornecedor: lot.supplier || 'Sem fornecedor',
|
|
nota_fiscal: lot.invoice || '',
|
|
criado_em: lot.createdAt,
|
|
})))}
|
|
className={buttonClassName}
|
|
>
|
|
<Download className="h-4 w-4" /> Exportar saldo CSV
|
|
</button>
|
|
<button type="button" onClick={onRefresh} className={buttonClassName}><RefreshCw className="h-4 w-4" /> Atualizar saldo</button>
|
|
</div>
|
|
<div className={`${panelClassName} p-4`}>
|
|
<div className="grid grid-cols-1 gap-3 lg:grid-cols-[1fr_220px_220px]">
|
|
<label className="relative">
|
|
<Search className="pointer-events-none absolute left-3 top-3 h-4 w-4 text-dark-muted" />
|
|
<input value={search} onChange={(event) => setSearch(event.target.value)} className={`${inputClassName} pl-9`} placeholder="Buscar por SKU, lote, tipo ou fornecedor..." />
|
|
</label>
|
|
<select value={category} onChange={(event) => setCategory(event.target.value)} className={inputClassName}>
|
|
<option value="all">Todos os tipos</option>
|
|
{categories.map(item => <option key={item} value={item}>{item}</option>)}
|
|
</select>
|
|
<select value={supplier} onChange={(event) => setSupplier(event.target.value)} className={inputClassName}>
|
|
<option value="all">Todos os fornecedores</option>
|
|
{suppliers.map(item => <option key={item} value={item}>{item}</option>)}
|
|
</select>
|
|
</div>
|
|
</div>
|
|
<div className={`${panelClassName} p-5`}>
|
|
<StatGrid summary={summary} />
|
|
</div>
|
|
<div className={`${panelClassName} p-5`}>
|
|
<div className="flex items-center justify-between gap-3">
|
|
<h2 className="text-base font-bold text-dark-text">Saldo por tipo</h2>
|
|
<span className="text-xs font-semibold text-dark-muted">Clique para expandir lotes</span>
|
|
</div>
|
|
{visibleLots.length ? (
|
|
<div className="mt-4 overflow-hidden rounded-xl border border-dark-border">
|
|
<div className="hidden grid-cols-[1.2fr_1fr_120px_1fr_140px] border-b border-dark-border bg-dark-input/40 px-4 py-3 text-xs font-bold uppercase tracking-widest text-dark-muted md:grid">
|
|
<span>Material</span>
|
|
<span>Categoria</span>
|
|
<span>Saldo</span>
|
|
<span>Fornecedor</span>
|
|
<span>Lote</span>
|
|
</div>
|
|
<div className="divide-y divide-dark-border">
|
|
{visibleLots.map(lot => (
|
|
<div key={lot.id} className="grid grid-cols-1 gap-2 bg-dark-card px-4 py-3 md:grid-cols-[1.2fr_1fr_120px_1fr_140px] md:items-center">
|
|
<p className="text-sm font-bold text-dark-text">{lot.product}</p>
|
|
<p className="text-sm font-semibold text-dark-muted">{lot.category}</p>
|
|
<p className="text-sm font-bold text-dark-text">{formatNumber(lot.quantity)} {lot.unit}</p>
|
|
<p className="text-sm font-semibold text-dark-muted">{lot.supplier || 'Sem fornecedor'}</p>
|
|
<p className="text-xs font-bold text-dark-muted">#{lot.id} · {lot.invoice || 'sem NF'}</p>
|
|
</div>
|
|
))}
|
|
</div>
|
|
</div>
|
|
) : (
|
|
<div className={emptyStateClassName}>
|
|
<Package className="h-8 w-8 text-brand-primary" />
|
|
<h3 className="text-base font-bold text-dark-text">{summary.lots.length ? 'Nenhum lote encontrado' : 'Nenhum item em estoque'}</h3>
|
|
<p className="text-sm font-semibold text-dark-muted">{summary.lots.length ? 'Ajuste a busca ou os filtros.' : 'Registre entradas para ver o saldo aqui.'}</p>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
);
|
|
};
|
|
|
|
const ReceiptList = ({
|
|
receipts,
|
|
emptyTitle,
|
|
onApprove,
|
|
onRemove,
|
|
}: {
|
|
receipts: SupplyReceipt[];
|
|
emptyTitle: string;
|
|
onApprove: (receiptId: number) => void;
|
|
onRemove: (receiptId: number) => void;
|
|
}) => (
|
|
<div className={`${panelClassName} overflow-hidden`}>
|
|
{receipts.length ? (
|
|
<div className="divide-y divide-dark-border">
|
|
{receipts.map(receipt => (
|
|
<div key={receipt.id} className="grid grid-cols-1 gap-3 p-4 lg:grid-cols-[1.2fr_120px_1fr_100px_auto] lg:items-center">
|
|
<div>
|
|
<p className="font-bold text-dark-text">{receipt.product}</p>
|
|
<p className="text-xs font-semibold text-dark-muted">{receipt.category} · {receipt.invoice ? `NF ${receipt.invoice}` : 'sem NF'} · {formatDateTime(receipt.createdAt)}</p>
|
|
</div>
|
|
<p className="text-sm font-bold text-dark-text">{formatNumber(receipt.quantity)} {receipt.unit}</p>
|
|
<p className="text-sm font-semibold text-dark-muted">{receipt.supplier}</p>
|
|
<span className={`w-fit rounded-full border px-2.5 py-1 text-xs font-bold ${
|
|
receipt.status === 'approved'
|
|
? 'border-emerald-400/30 bg-emerald-400/10 text-emerald-300'
|
|
: 'border-amber-400/30 bg-amber-400/10 text-amber-300'
|
|
}`}>
|
|
{receipt.status === 'approved' ? 'Aprovado' : 'Pendente'}
|
|
</span>
|
|
<div className="flex flex-wrap gap-2 lg:justify-end">
|
|
{receipt.status === 'pending' && (
|
|
<button type="button" onClick={() => onApprove(receipt.id)} className={buttonClassName}>
|
|
Aprovar
|
|
</button>
|
|
)}
|
|
<button type="button" onClick={() => onRemove(receipt.id)} className="inline-flex h-10 items-center justify-center rounded-lg px-3 text-sm font-bold text-red-400 transition-colors hover:bg-red-500/10 cursor-pointer">
|
|
Remover
|
|
</button>
|
|
</div>
|
|
</div>
|
|
))}
|
|
</div>
|
|
) : (
|
|
<div className={emptyStateClassName}>
|
|
<Truck className="h-8 w-8 text-brand-primary" />
|
|
<h3 className="text-base font-bold text-dark-text">{emptyTitle}</h3>
|
|
<p className="text-sm font-semibold text-dark-muted">Registre um recebimento para preencher esta lista.</p>
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
|
|
const ReceiptsTab = ({
|
|
receipts,
|
|
onCreate,
|
|
onApprove,
|
|
onRemove,
|
|
isBusy,
|
|
}: {
|
|
receipts: SupplyReceipt[];
|
|
onCreate: (payload: { category: string; product: string; quantity: number; unit: string; supplier: string; invoice: string; notes: string }) => Promise<void>;
|
|
onApprove: (receiptId: number) => Promise<void>;
|
|
onRemove: (receiptId: number) => Promise<void>;
|
|
isBusy: boolean;
|
|
}) => {
|
|
const [activeView, setActiveView] = useState<ReceiptView>('new');
|
|
const [selectedCategory, setSelectedCategory] = useState(receiptCategories[0].name);
|
|
const [form, setForm] = useState({
|
|
product: '',
|
|
quantity: '',
|
|
unit: 'kg',
|
|
supplier: '',
|
|
invoice: '',
|
|
notes: '',
|
|
});
|
|
|
|
const pendingReceipts = receipts.filter(receipt => receipt.status === 'pending');
|
|
const visibleReceipts = activeView === 'pending' ? pendingReceipts : receipts;
|
|
|
|
const handleReceiptSubmit = async (event: FormEvent<HTMLFormElement>) => {
|
|
event.preventDefault();
|
|
const product = form.product.trim();
|
|
const quantity = parseDecimal(form.quantity);
|
|
if (!product || quantity <= 0) return;
|
|
|
|
await onCreate({
|
|
category: selectedCategory,
|
|
product,
|
|
quantity,
|
|
unit: form.unit,
|
|
supplier: form.supplier.trim() || 'Sem fornecedor',
|
|
invoice: form.invoice.trim(),
|
|
notes: form.notes.trim(),
|
|
});
|
|
setForm({ product: '', quantity: '', unit: 'kg', supplier: '', invoice: '', notes: '' });
|
|
setActiveView('pending');
|
|
};
|
|
|
|
return (
|
|
<div className="space-y-4">
|
|
<div className="flex flex-wrap gap-2">
|
|
{[
|
|
{ id: 'new', label: 'Novo recebimento' },
|
|
{ id: 'pending', label: `Pendentes (${pendingReceipts.length})` },
|
|
{ id: 'history', label: `Histórico (${receipts.length})` },
|
|
].map(item => (
|
|
<button
|
|
key={item.id}
|
|
type="button"
|
|
onClick={() => setActiveView(item.id as ReceiptView)}
|
|
className={`${buttonClassName} ${activeView === item.id ? 'border-brand-primary bg-brand-primary text-brand-contrast' : ''}`}
|
|
>
|
|
{item.label}
|
|
</button>
|
|
))}
|
|
</div>
|
|
|
|
{activeView === 'new' && (
|
|
<form onSubmit={handleReceiptSubmit} className={`${panelClassName} p-5`}>
|
|
<h2 className="text-base font-bold text-dark-text">Registrar recebimento</h2>
|
|
<div className="mt-4 rounded-xl border border-dark-border bg-dark-input px-4 py-3 text-sm font-semibold text-dark-muted">
|
|
Preencha o que chegou. O financeiro vincula OC/NF e aprova o lançamento.
|
|
</div>
|
|
<p className="mt-5 text-xs font-bold uppercase tracking-widest text-dark-muted">1. Qual categoria de produto chegou?</p>
|
|
<div className="mt-3 grid grid-cols-1 gap-3 sm:grid-cols-2">
|
|
{receiptCategories.map(category => (
|
|
<button
|
|
key={category.name}
|
|
type="button"
|
|
onClick={() => setSelectedCategory(category.name)}
|
|
className={`flex min-h-24 flex-col items-center justify-center gap-3 rounded-xl border p-4 text-center font-bold transition-colors cursor-pointer ${
|
|
selectedCategory === category.name
|
|
? 'border-brand-primary bg-brand-primary/12 text-brand-primary'
|
|
: 'border-dark-border bg-dark-input/40 text-dark-text hover:border-brand-primary'
|
|
}`}
|
|
>
|
|
<category.icon className="h-6 w-6" />
|
|
{category.name}
|
|
</button>
|
|
))}
|
|
</div>
|
|
|
|
<div className="mt-5 rounded-xl border border-dark-border bg-dark-input/35 p-4">
|
|
<p className="text-xs font-bold uppercase tracking-widest text-dark-muted">2. Detalhes do recebimento</p>
|
|
<div className="mt-3 grid grid-cols-1 gap-3 md:grid-cols-2">
|
|
<label className="text-xs font-bold text-dark-muted">
|
|
Produto / material
|
|
<input value={form.product} onChange={(event) => setForm(current => ({ ...current, product: event.target.value }))} className={`${inputClassName} mt-1`} placeholder="ex: Meia malha 30.1" />
|
|
</label>
|
|
<label className="text-xs font-bold text-dark-muted">
|
|
Fornecedor
|
|
<input value={form.supplier} onChange={(event) => setForm(current => ({ ...current, supplier: event.target.value }))} className={`${inputClassName} mt-1`} placeholder="Opcional" />
|
|
</label>
|
|
<label className="text-xs font-bold text-dark-muted">
|
|
Quantidade
|
|
<input inputMode="decimal" value={form.quantity} onChange={(event) => setForm(current => ({ ...current, quantity: event.target.value }))} className={`${inputClassName} mt-1`} placeholder="ex: 120" />
|
|
</label>
|
|
<label className="text-xs font-bold text-dark-muted">
|
|
Unidade
|
|
<select value={form.unit} onChange={(event) => setForm(current => ({ ...current, unit: event.target.value }))} className={`${inputClassName} mt-1`}>
|
|
<option value="kg">kg</option>
|
|
<option value="rolos">rolos</option>
|
|
<option value="un.">un.</option>
|
|
<option value="caixas">caixas</option>
|
|
</select>
|
|
</label>
|
|
<label className="text-xs font-bold text-dark-muted">
|
|
Nota fiscal
|
|
<input value={form.invoice} onChange={(event) => setForm(current => ({ ...current, invoice: event.target.value }))} className={`${inputClassName} mt-1`} placeholder="NF-001234" />
|
|
</label>
|
|
<label className="text-xs font-bold text-dark-muted md:col-span-2">
|
|
Observações
|
|
<textarea value={form.notes} onChange={(event) => setForm(current => ({ ...current, notes: event.target.value }))} className="mt-1 min-h-20 w-full rounded-lg border border-dark-border bg-dark-input px-3 py-2 text-sm font-semibold text-dark-text outline-none placeholder:text-dark-muted focus:border-brand-primary" placeholder="Condição, divergências, lote..." />
|
|
</label>
|
|
</div>
|
|
<button type="submit" disabled={isBusy} className="mt-4 inline-flex h-11 w-full items-center justify-center gap-2 rounded-lg bg-brand-primary px-4 text-sm font-bold text-brand-contrast transition-opacity hover:opacity-90 disabled:cursor-not-allowed disabled:opacity-60 cursor-pointer">
|
|
<Save className="h-4 w-4" />
|
|
{isBusy ? 'Salvando...' : 'Registrar recebimento'}
|
|
</button>
|
|
</div>
|
|
</form>
|
|
)}
|
|
|
|
{activeView === 'pending' && (
|
|
<ReceiptList
|
|
receipts={visibleReceipts}
|
|
emptyTitle="Nenhum recebimento pendente"
|
|
onApprove={onApprove}
|
|
onRemove={onRemove}
|
|
/>
|
|
)}
|
|
{activeView === 'history' && (
|
|
<ReceiptList
|
|
receipts={visibleReceipts}
|
|
emptyTitle="Nenhum recebimento no histórico"
|
|
onApprove={onApprove}
|
|
onRemove={onRemove}
|
|
/>
|
|
)}
|
|
</div>
|
|
);
|
|
};
|
|
|
|
const InventoryCountTab = ({
|
|
lots,
|
|
onAdjust,
|
|
onRefresh,
|
|
}: {
|
|
lots: SupplyLot[];
|
|
onAdjust: (lotId: number, countedQuantity: number, reason: string) => Promise<void>;
|
|
onRefresh: () => void;
|
|
}) => {
|
|
const [search, setSearch] = useState('');
|
|
const [adjustments, setAdjustments] = useState<Record<number, { countedQuantity: string; reason: string }>>({});
|
|
const normalizedSearch = normalizeSearch(search);
|
|
const visibleLots = lots.filter(lot => (
|
|
!normalizedSearch || normalizeSearch(`${lot.product} ${lot.category} ${lot.supplier} ${lot.invoice} ${lot.id}`).includes(normalizedSearch)
|
|
));
|
|
|
|
const updateAdjustment = (lotId: number, patch: Partial<{ countedQuantity: string; reason: string }>) => {
|
|
setAdjustments(current => ({
|
|
...current,
|
|
[lotId]: {
|
|
countedQuantity: current[lotId]?.countedQuantity ?? '',
|
|
reason: current[lotId]?.reason ?? '',
|
|
...patch,
|
|
},
|
|
}));
|
|
};
|
|
|
|
return (
|
|
<div className="space-y-4">
|
|
<button
|
|
type="button"
|
|
onClick={() => exportCsv('inventario-suprimentos.csv', visibleLots.map(lot => ({
|
|
lote: lot.id,
|
|
material: lot.product,
|
|
categoria: lot.category,
|
|
quantidade_sistema: lot.quantity,
|
|
unidade: lot.unit,
|
|
fornecedor: lot.supplier || 'Sem fornecedor',
|
|
})))}
|
|
className={buttonClassName}
|
|
>
|
|
<Download className="h-4 w-4" /> Exportar inventário CSV
|
|
</button>
|
|
<div className={`${panelClassName} p-5`}>
|
|
<h2 className="text-base font-bold text-dark-text">Inventário físico</h2>
|
|
<div className="mt-4 rounded-xl border border-dark-border bg-dark-input px-4 py-3 text-sm font-semibold text-dark-muted">
|
|
Compare o estoque do sistema com a contagem física. O ajuste gera movimentação com justificativa.
|
|
</div>
|
|
<div className="mt-4 grid grid-cols-1 gap-3 md:grid-cols-[1fr_auto]">
|
|
<input value={search} onChange={(event) => setSearch(event.target.value)} className={inputClassName} placeholder="Buscar item..." />
|
|
<button type="button" onClick={onRefresh} className={buttonClassName}><RefreshCw className="h-4 w-4" /> Recarregar</button>
|
|
</div>
|
|
{visibleLots.length ? (
|
|
<div className="mt-4 grid grid-cols-1 gap-3 md:grid-cols-2 xl:grid-cols-3">
|
|
{visibleLots.map(lot => (
|
|
<div key={lot.id} className="rounded-xl border border-dark-border bg-dark-input/35 p-4">
|
|
<p className="text-sm font-bold text-dark-text">{lot.product}</p>
|
|
<p className="mt-1 text-xs font-semibold text-dark-muted">{lot.category} · lote #{lot.id}</p>
|
|
<p className="mt-3 text-xl font-bold text-dark-text">{formatNumber(lot.quantity)} {lot.unit}</p>
|
|
<p className="mt-1 text-xs font-semibold text-dark-muted">{lot.supplier || 'Sem fornecedor'}</p>
|
|
<div className="mt-4 grid grid-cols-1 gap-2">
|
|
<label className="text-xs font-bold text-dark-muted">
|
|
Quantidade contada
|
|
<input
|
|
inputMode="decimal"
|
|
value={adjustments[lot.id]?.countedQuantity ?? ''}
|
|
onChange={(event) => updateAdjustment(lot.id, { countedQuantity: event.target.value })}
|
|
className={`${inputClassName} mt-1`}
|
|
placeholder={`${formatNumber(lot.quantity)} ${lot.unit}`}
|
|
/>
|
|
</label>
|
|
<label className="text-xs font-bold text-dark-muted">
|
|
Justificativa
|
|
<input
|
|
value={adjustments[lot.id]?.reason ?? ''}
|
|
onChange={(event) => updateAdjustment(lot.id, { reason: event.target.value })}
|
|
className={`${inputClassName} mt-1`}
|
|
placeholder="ex: contagem física"
|
|
/>
|
|
</label>
|
|
<button
|
|
type="button"
|
|
onClick={async () => {
|
|
const rawQuantity = adjustments[lot.id]?.countedQuantity ?? '';
|
|
if (!rawQuantity.trim()) return;
|
|
const nextQuantity = parseDecimal(rawQuantity);
|
|
const reason = adjustments[lot.id]?.reason.trim() ?? '';
|
|
await onAdjust(lot.id, nextQuantity, reason);
|
|
setAdjustments(current => ({ ...current, [lot.id]: { countedQuantity: '', reason: '' } }));
|
|
}}
|
|
className={buttonClassName}
|
|
>
|
|
<ClipboardCheck className="h-4 w-4" />
|
|
Ajustar lote
|
|
</button>
|
|
</div>
|
|
</div>
|
|
))}
|
|
</div>
|
|
) : (
|
|
<div className={emptyStateClassName}>
|
|
<ClipboardCheck className="h-8 w-8 text-brand-primary" />
|
|
<h3 className="text-base font-bold text-dark-text">{lots.length ? 'Nenhum lote encontrado' : 'Nenhum lote para inventariar'}</h3>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
);
|
|
};
|
|
|
|
const movementLabels: Record<string, string> = {
|
|
receipt: 'Entrada por recebimento',
|
|
inventory_adjustment: 'Ajuste de inventário',
|
|
production_exit: 'Saída para produção',
|
|
reversal: 'Estorno',
|
|
};
|
|
|
|
const ProductionExitPanel = ({
|
|
lots,
|
|
onConsume,
|
|
}: {
|
|
lots: SupplyLot[];
|
|
onConsume: (lotId: number, quantity: number, productionOrderNumber: string, reason: string) => Promise<void>;
|
|
}) => {
|
|
const [form, setForm] = useState({
|
|
lotId: '',
|
|
quantity: '',
|
|
productionOrderNumber: '',
|
|
reason: '',
|
|
});
|
|
|
|
const selectedLot = lots.find(lot => `${lot.id}` === form.lotId);
|
|
|
|
const handleSubmit = async (event: FormEvent<HTMLFormElement>) => {
|
|
event.preventDefault();
|
|
const lotId = Number(form.lotId);
|
|
const quantity = parseDecimal(form.quantity);
|
|
if (!lotId || quantity <= 0) return;
|
|
|
|
await onConsume(lotId, quantity, form.productionOrderNumber.trim(), form.reason.trim());
|
|
setForm({ lotId: '', quantity: '', productionOrderNumber: '', reason: '' });
|
|
};
|
|
|
|
return (
|
|
<form onSubmit={handleSubmit} className={`${panelClassName} p-5`}>
|
|
<div className="flex flex-col gap-2 md:flex-row md:items-start md:justify-between">
|
|
<div>
|
|
<h2 className="text-base font-bold text-dark-text">Saída para produção</h2>
|
|
<p className="mt-1 text-sm font-semibold text-dark-muted">Baixa material de um lote e registra movimentação ligada à OP.</p>
|
|
</div>
|
|
{selectedLot && (
|
|
<span className="w-fit rounded-full border border-dark-border bg-dark-input px-2.5 py-1 text-xs font-bold text-dark-muted">
|
|
Saldo: {formatNumber(selectedLot.quantity)} {selectedLot.unit}
|
|
</span>
|
|
)}
|
|
</div>
|
|
<div className="mt-4 grid grid-cols-1 gap-3 lg:grid-cols-[1.4fr_120px_160px_1fr_auto]">
|
|
<label className="text-xs font-bold text-dark-muted">
|
|
Lote
|
|
<select value={form.lotId} onChange={(event) => setForm(current => ({ ...current, lotId: event.target.value }))} className={`${inputClassName} mt-1`}>
|
|
<option value="">Selecione...</option>
|
|
{lots.map(lot => (
|
|
<option key={lot.id} value={lot.id}>
|
|
#{lot.id} · {lot.product} · {formatNumber(lot.quantity)} {lot.unit}
|
|
</option>
|
|
))}
|
|
</select>
|
|
</label>
|
|
<label className="text-xs font-bold text-dark-muted">
|
|
Quantidade
|
|
<input inputMode="decimal" value={form.quantity} onChange={(event) => setForm(current => ({ ...current, quantity: event.target.value }))} className={`${inputClassName} mt-1`} placeholder="kg" />
|
|
</label>
|
|
<label className="text-xs font-bold text-dark-muted">
|
|
OP
|
|
<input value={form.productionOrderNumber} onChange={(event) => setForm(current => ({ ...current, productionOrderNumber: event.target.value }))} className={`${inputClassName} mt-1`} placeholder="ex: OP-123" />
|
|
</label>
|
|
<label className="text-xs font-bold text-dark-muted">
|
|
Motivo
|
|
<input value={form.reason} onChange={(event) => setForm(current => ({ ...current, reason: event.target.value }))} className={`${inputClassName} mt-1`} placeholder="ex: corte BLCS" />
|
|
</label>
|
|
<button type="submit" className={`${buttonClassName} mt-5`}>
|
|
<Repeat2 className="h-4 w-4" />
|
|
Baixar
|
|
</button>
|
|
</div>
|
|
</form>
|
|
);
|
|
};
|
|
|
|
const MovementsTab = ({
|
|
lots,
|
|
movements,
|
|
onConsume,
|
|
}: {
|
|
lots: SupplyLot[];
|
|
movements: SupplyMovement[];
|
|
onConsume: (lotId: number, quantity: number, productionOrderNumber: string, reason: string) => Promise<void>;
|
|
}) => {
|
|
const [search, setSearch] = useState('');
|
|
const [type, setType] = useState('all');
|
|
const movementTypes = Array.from(new Set(movements.map(movement => movement.type))).sort();
|
|
const normalizedSearch = normalizeSearch(search);
|
|
const visibleMovements = movements.filter(movement => {
|
|
const matchesSearch = !normalizedSearch || normalizeSearch(`${movement.product} ${movement.category} ${movement.reason} ${movement.lotId || ''}`).includes(normalizedSearch);
|
|
const matchesType = type === 'all' || movement.type === type;
|
|
return matchesSearch && matchesType;
|
|
});
|
|
|
|
return (
|
|
<div className="space-y-4">
|
|
<ProductionExitPanel lots={lots} onConsume={onConsume} />
|
|
<button
|
|
type="button"
|
|
onClick={() => exportCsv('movimentacoes-suprimentos.csv', visibleMovements.map(movement => ({
|
|
id: movement.id,
|
|
data: movement.createdAt,
|
|
tipo: movementLabels[movement.type] || movement.type,
|
|
material: movement.product,
|
|
categoria: movement.category,
|
|
quantidade: movement.quantity,
|
|
unidade: movement.unit,
|
|
lote: movement.lotId,
|
|
motivo: movement.reason,
|
|
})))}
|
|
className={buttonClassName}
|
|
>
|
|
<Download className="h-4 w-4" /> Exportar movimentações CSV
|
|
</button>
|
|
<div className={`${panelClassName} p-4`}>
|
|
<div className="grid grid-cols-1 gap-3 md:grid-cols-[1fr_220px]">
|
|
<label className="relative">
|
|
<Search className="pointer-events-none absolute left-3 top-3 h-4 w-4 text-dark-muted" />
|
|
<input value={search} onChange={(event) => setSearch(event.target.value)} className={`${inputClassName} pl-9`} placeholder="Buscar lote, tipo ou fornecedor..." />
|
|
</label>
|
|
<select value={type} onChange={(event) => setType(event.target.value)} className={inputClassName}>
|
|
<option value="all">Todos os tipos</option>
|
|
{movementTypes.map(item => <option key={item} value={item}>{movementLabels[item] || item}</option>)}
|
|
</select>
|
|
</div>
|
|
{visibleMovements.length ? (
|
|
<div className="mt-4 overflow-hidden rounded-xl border border-dark-border">
|
|
<div className="hidden grid-cols-[170px_1fr_140px_140px] border-b border-dark-border bg-dark-input/40 px-4 py-3 text-xs font-bold uppercase tracking-widest text-dark-muted md:grid">
|
|
<span>Data</span>
|
|
<span>Movimento</span>
|
|
<span>Quantidade</span>
|
|
<span>Lote</span>
|
|
</div>
|
|
<div className="divide-y divide-dark-border">
|
|
{visibleMovements.map(movement => (
|
|
<div key={movement.id} className="grid grid-cols-1 gap-2 bg-dark-card px-4 py-3 md:grid-cols-[170px_1fr_140px_140px] md:items-center">
|
|
<p className="text-sm font-semibold text-dark-muted">{formatDateTime(movement.createdAt)}</p>
|
|
<div>
|
|
<p className="text-sm font-bold text-dark-text">{movementLabels[movement.type] || movement.type}</p>
|
|
<p className="text-xs font-semibold text-dark-muted">{movement.product} · {movement.reason}</p>
|
|
</div>
|
|
<p className="text-sm font-bold text-dark-text">{formatNumber(movement.quantity)} {movement.unit}</p>
|
|
<p className="text-xs font-bold text-dark-muted">{movement.lotId ? `#${movement.lotId}` : '-'}</p>
|
|
</div>
|
|
))}
|
|
</div>
|
|
</div>
|
|
) : (
|
|
<div className={emptyStateClassName}>
|
|
<Repeat2 className="h-8 w-8 text-brand-primary" />
|
|
<h3 className="text-base font-bold text-dark-text">{movements.length ? 'Nenhuma movimentação encontrada' : 'Nenhuma movimentação ainda'}</h3>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
);
|
|
};
|
|
|
|
const InventoryScreen = () => {
|
|
const [activeTab, setActiveTab] = useState<InventoryTab>('dashboard');
|
|
const [summary, setSummary] = useState<SupplySummary>(emptySupplySummary);
|
|
const [isLoading, setIsLoading] = useState(true);
|
|
const [isBusy, setIsBusy] = useState(false);
|
|
const [errorMessage, setErrorMessage] = useState('');
|
|
|
|
const loadSummary = async () => {
|
|
setErrorMessage('');
|
|
const nextSummary = await fetchSupplySummary();
|
|
setSummary(nextSummary);
|
|
};
|
|
|
|
useEffect(() => {
|
|
let isMounted = true;
|
|
|
|
const load = async () => {
|
|
setIsLoading(true);
|
|
try {
|
|
const nextSummary = await fetchSupplySummary();
|
|
if (isMounted) setSummary(nextSummary);
|
|
} finally {
|
|
if (isMounted) setIsLoading(false);
|
|
}
|
|
};
|
|
|
|
load();
|
|
|
|
return () => {
|
|
isMounted = false;
|
|
};
|
|
}, []);
|
|
|
|
const runSupplyAction = async (action: () => Promise<void>) => {
|
|
setIsBusy(true);
|
|
setErrorMessage('');
|
|
try {
|
|
await action();
|
|
await loadSummary();
|
|
} catch (error) {
|
|
setErrorMessage(error instanceof Error ? error.message : 'Não foi possível atualizar suprimentos.');
|
|
} finally {
|
|
setIsBusy(false);
|
|
}
|
|
};
|
|
|
|
return (
|
|
<div className={pageClassName}>
|
|
<Header title="Controle de Estoque" subtitle="Saldo, lotes, entradas e inventário." backTo="/supplies" />
|
|
<div className="flex flex-wrap gap-2">
|
|
{inventoryTabs.map(tab => (
|
|
<button
|
|
key={tab.id}
|
|
type="button"
|
|
onClick={() => setActiveTab(tab.id)}
|
|
className={`inline-flex h-10 items-center justify-center gap-2 rounded-lg px-3 text-sm font-bold transition-colors cursor-pointer ${
|
|
activeTab === tab.id ? 'bg-brand-primary text-brand-contrast' : 'bg-dark-input text-dark-text hover:bg-dark-card'
|
|
}`}
|
|
>
|
|
<tab.icon className="h-4 w-4" />
|
|
{tab.name}
|
|
</button>
|
|
))}
|
|
</div>
|
|
{errorMessage && (
|
|
<div className="rounded-xl border border-red-500/30 bg-red-500/10 px-4 py-3 text-sm font-bold text-red-300">
|
|
{errorMessage}
|
|
</div>
|
|
)}
|
|
{isLoading ? (
|
|
<div className={`${panelClassName} p-5 text-sm font-bold text-dark-muted`}>Carregando estoque...</div>
|
|
) : (
|
|
<>
|
|
{activeTab === 'dashboard' && <InventoryDashboard summary={summary} onNewReceipt={() => setActiveTab('receipts')} />}
|
|
{activeTab === 'balance' && <BalanceTab summary={summary} onRefresh={loadSummary} />}
|
|
{activeTab === 'receipts' && (
|
|
<ReceiptsTab
|
|
receipts={summary.receipts}
|
|
isBusy={isBusy}
|
|
onCreate={(payload) => runSupplyAction(async () => {
|
|
await createSupplyReceipt(payload);
|
|
})}
|
|
onApprove={(receiptId) => runSupplyAction(async () => {
|
|
await approveSupplyReceipt(receiptId);
|
|
})}
|
|
onRemove={(receiptId) => runSupplyAction(async () => {
|
|
await deleteSupplyReceipt(receiptId);
|
|
})}
|
|
/>
|
|
)}
|
|
{activeTab === 'inventory' && (
|
|
<InventoryCountTab
|
|
lots={summary.lots}
|
|
onRefresh={loadSummary}
|
|
onAdjust={(lotId, countedQuantity, reason) => runSupplyAction(async () => {
|
|
await adjustSupplyLotInventory(lotId, { countedQuantity, reason });
|
|
})}
|
|
/>
|
|
)}
|
|
{activeTab === 'movements' && (
|
|
<MovementsTab
|
|
lots={summary.lots}
|
|
movements={summary.movements}
|
|
onConsume={(lotId, quantity, productionOrderNumber, reason) => runSupplyAction(async () => {
|
|
await consumeSupplyLotForProduction(lotId, { quantity, productionOrderNumber, reason });
|
|
})}
|
|
/>
|
|
)}
|
|
</>
|
|
)}
|
|
</div>
|
|
);
|
|
};
|
|
|
|
const FabricPlanningScreen = () => {
|
|
const [plans, setPlans] = useState<SupplyFabricPlan[]>([]);
|
|
const [form, setForm] = useState({
|
|
material: '',
|
|
color: '',
|
|
quantityKg: '',
|
|
supplier: '',
|
|
priority: 'Normal',
|
|
});
|
|
const [isLoading, setIsLoading] = useState(true);
|
|
const [isBusy, setIsBusy] = useState(false);
|
|
const [errorMessage, setErrorMessage] = useState('');
|
|
|
|
const totalKg = plans.reduce((total, plan) => total + plan.quantityKg, 0);
|
|
|
|
const loadPlans = async () => {
|
|
setErrorMessage('');
|
|
const summary = await fetchSupplySummary();
|
|
setPlans(summary.fabricPlans);
|
|
};
|
|
|
|
useEffect(() => {
|
|
let isMounted = true;
|
|
|
|
const load = async () => {
|
|
setIsLoading(true);
|
|
try {
|
|
const summary = await fetchSupplySummary();
|
|
if (isMounted) setPlans(summary.fabricPlans);
|
|
} finally {
|
|
if (isMounted) setIsLoading(false);
|
|
}
|
|
};
|
|
|
|
load();
|
|
|
|
return () => {
|
|
isMounted = false;
|
|
};
|
|
}, []);
|
|
|
|
const handleSubmit = async (event: FormEvent<HTMLFormElement>) => {
|
|
event.preventDefault();
|
|
const material = form.material.trim();
|
|
const quantityKg = parseDecimal(form.quantityKg);
|
|
if (!material || quantityKg <= 0) return;
|
|
|
|
setIsBusy(true);
|
|
setErrorMessage('');
|
|
try {
|
|
await createSupplyFabricPlan({
|
|
material,
|
|
color: form.color.trim() || 'Todas as cores',
|
|
quantityKg,
|
|
supplier: form.supplier.trim() || 'Sem fornecedor',
|
|
priority: form.priority,
|
|
});
|
|
await loadPlans();
|
|
setForm({ material: '', color: '', quantityKg: '', supplier: '', priority: 'Normal' });
|
|
} catch (error) {
|
|
setErrorMessage(error instanceof Error ? error.message : 'Não foi possível salvar o plano de malha.');
|
|
} finally {
|
|
setIsBusy(false);
|
|
}
|
|
};
|
|
|
|
const removePlan = async (planId: number) => {
|
|
setIsBusy(true);
|
|
setErrorMessage('');
|
|
try {
|
|
await deleteSupplyFabricPlan(planId);
|
|
await loadPlans();
|
|
} catch (error) {
|
|
setErrorMessage(error instanceof Error ? error.message : 'Não foi possível remover o plano de malha.');
|
|
} finally {
|
|
setIsBusy(false);
|
|
}
|
|
};
|
|
|
|
return (
|
|
<div className={pageClassName}>
|
|
<Header title="Planejamento de Malha" subtitle="Fila de matéria-prima para compra, recebimento e abastecimento do corte." backTo="/supplies" />
|
|
{errorMessage && (
|
|
<div className="rounded-xl border border-red-500/30 bg-red-500/10 px-4 py-3 text-sm font-bold text-red-300">
|
|
{errorMessage}
|
|
</div>
|
|
)}
|
|
<div className="grid grid-cols-1 gap-4 md:grid-cols-3">
|
|
<div className="rounded-2xl border border-dark-border bg-dark-card p-5 shadow-sm">
|
|
<p className="text-xs font-bold uppercase tracking-widest text-dark-muted">Planos ativos</p>
|
|
<p className="mt-2 text-3xl font-bold text-dark-text">{plans.length}</p>
|
|
</div>
|
|
<div className="rounded-2xl border border-dark-border bg-dark-card p-5 shadow-sm">
|
|
<p className="text-xs font-bold uppercase tracking-widest text-dark-muted">Kg planejado</p>
|
|
<p className="mt-2 text-3xl font-bold text-dark-text">{formatNumber(totalKg)} kg</p>
|
|
</div>
|
|
<div className="rounded-2xl border border-dark-border bg-dark-card p-5 shadow-sm">
|
|
<p className="text-xs font-bold uppercase tracking-widest text-dark-muted">Críticos</p>
|
|
<p className="mt-2 text-3xl font-bold text-dark-text">{plans.filter(plan => plan.priority === 'Crítico').length}</p>
|
|
</div>
|
|
</div>
|
|
<div className="grid grid-cols-1 gap-6 xl:grid-cols-[420px_1fr]">
|
|
<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 className={`${panelClassName} overflow-hidden`}>
|
|
<div className="border-b border-dark-border p-5">
|
|
<h2 className="text-base font-bold text-dark-text">Planos de malha</h2>
|
|
<p className="mt-1 text-sm font-semibold text-dark-muted">Itens planejados para compra ou recebimento.</p>
|
|
</div>
|
|
{isLoading ? (
|
|
<div className={`${emptyStateClassName} min-h-[220px]`}>
|
|
<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}>
|
|
<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>
|
|
</div>
|
|
</div>
|
|
);
|
|
};
|
|
|
|
const purchaseStatusLabels: Record<SupplyPurchaseNeed['status'], string> = {
|
|
critical: 'Sem estoque',
|
|
attention: 'Comprar',
|
|
ok: 'Coberto',
|
|
};
|
|
|
|
const getNeedUnit = (need: SupplyPurchaseNeed) => need.unit || 'kg';
|
|
|
|
const formatNeedQuantity = (need: SupplyPurchaseNeed, value: number) => (
|
|
`${formatNumber(value)} ${getNeedUnit(need)}`
|
|
);
|
|
|
|
const summarizePurchaseNeeds = (needs: SupplyPurchaseNeed[]) => {
|
|
const totalsByUnit = needs.reduce<Record<string, number>>((totals, need) => {
|
|
if (need.purchaseKg <= 0) return totals;
|
|
const unit = getNeedUnit(need);
|
|
totals[unit] = (totals[unit] || 0) + need.purchaseKg;
|
|
return totals;
|
|
}, {});
|
|
|
|
const summaries = Object.entries(totalsByUnit).map(([unit, total]) => `${formatNumber(total)} ${unit}`);
|
|
return summaries.length ? summaries.join(' + ') : '0 kg';
|
|
};
|
|
|
|
const PurchaseNeedsScreen = () => {
|
|
const [summary, setSummary] = useState<SupplySummary>(emptySupplySummary);
|
|
const [isLoading, setIsLoading] = useState(true);
|
|
const [search, setSearch] = useState('');
|
|
|
|
const loadSummary = async () => {
|
|
const nextSummary = await fetchSupplySummary();
|
|
setSummary(nextSummary);
|
|
};
|
|
|
|
useEffect(() => {
|
|
let isMounted = true;
|
|
|
|
const load = async () => {
|
|
setIsLoading(true);
|
|
try {
|
|
const nextSummary = await fetchSupplySummary();
|
|
if (isMounted) setSummary(nextSummary);
|
|
} finally {
|
|
if (isMounted) setIsLoading(false);
|
|
}
|
|
};
|
|
|
|
load();
|
|
|
|
return () => {
|
|
isMounted = false;
|
|
};
|
|
}, []);
|
|
|
|
const normalizedSearch = normalizeSearch(search);
|
|
const visibleNeeds = summary.purchaseNeeds.filter(need => (
|
|
!normalizedSearch || normalizeSearch(`${need.material} ${need.suppliers.join(' ')} ${need.colors.join(' ')}`).includes(normalizedSearch)
|
|
));
|
|
const purchaseItemCount = summary.purchaseNeeds.filter(need => need.purchaseKg > 0).length;
|
|
const suggestedPurchaseSummary = summarizePurchaseNeeds(summary.purchaseNeeds);
|
|
const pendingSupplierCount = new Set(summary.purchaseNeeds.flatMap(need => need.suppliers)).size;
|
|
const missingReferenceCount = summary.purchaseNeeds.filter(need => need.missingReference).length;
|
|
|
|
return (
|
|
<div className={pageClassName}>
|
|
<Header title="Necessidade de Compra" subtitle="Materiais abaixo do mínimo e necessidade projetada para compra." backTo="/supplies" />
|
|
<div className="grid grid-cols-1 gap-4 md:grid-cols-4">
|
|
{[
|
|
{ label: 'Itens a comprar', value: `${purchaseItemCount}` },
|
|
{ label: 'Compra sugerida', value: suggestedPurchaseSummary },
|
|
{ label: 'Fornecedores', value: `${pendingSupplierCount}` },
|
|
{ label: 'Sem referência', value: `${missingReferenceCount}` },
|
|
].map(stat => (
|
|
<div key={stat.label} className="rounded-2xl border border-dark-border bg-dark-card p-5 shadow-sm">
|
|
<p className="text-xs font-bold uppercase tracking-widest text-dark-muted">{stat.label}</p>
|
|
<p className="mt-2 text-3xl font-bold text-dark-text">{stat.value}</p>
|
|
</div>
|
|
))}
|
|
</div>
|
|
<div className={`${panelClassName} p-5`}>
|
|
<div className="flex flex-col gap-4 md:flex-row md:items-center md:justify-between">
|
|
<div>
|
|
<h2 className="text-base font-bold text-dark-text">Necessidade por material</h2>
|
|
<p className="mt-1 text-sm font-semibold text-dark-muted">Planejado - estoque aprovado - recebimentos pendentes.</p>
|
|
</div>
|
|
<div className="flex flex-wrap gap-2">
|
|
<button type="button" onClick={loadSummary} className={buttonClassName}><RefreshCw className="h-4 w-4" /> Atualizar</button>
|
|
<button
|
|
type="button"
|
|
onClick={() => exportCsv('necessidade-compra.csv', visibleNeeds.map(need => ({
|
|
material: need.material,
|
|
planejado_kg: need.plannedKg,
|
|
estoque_kg: need.stockKg,
|
|
pendente_kg: need.pendingKg,
|
|
comprar_kg: need.purchaseKg,
|
|
unidade: getNeedUnit(need),
|
|
prioridade: need.priority,
|
|
cobertura: need.missingReference ? 'Sem referência de consumo' : purchaseStatusLabels[need.status],
|
|
fornecedores: need.suppliers.join(' | '),
|
|
cores: need.colors.join(' | '),
|
|
produtos: (need.products || []).map(product => `${product.productId} ${product.name}`).join(' | '),
|
|
})))}
|
|
className={buttonClassName}
|
|
>
|
|
<Download className="h-4 w-4" /> Exportar CSV
|
|
</button>
|
|
</div>
|
|
</div>
|
|
<label className="relative mt-4 block">
|
|
<Search className="pointer-events-none absolute left-3 top-3 h-4 w-4 text-dark-muted" />
|
|
<input value={search} onChange={(event) => setSearch(event.target.value)} className={`${inputClassName} pl-9`} placeholder="Buscar por material, fornecedor ou cor..." />
|
|
</label>
|
|
{isLoading ? (
|
|
<div className={emptyStateClassName}>
|
|
<BarChart3 className="h-8 w-8 text-brand-primary" />
|
|
<h3 className="text-base font-bold text-dark-text">Calculando necessidade...</h3>
|
|
</div>
|
|
) : visibleNeeds.length ? (
|
|
<div className="mt-4 overflow-hidden rounded-xl border border-dark-border">
|
|
<div className="hidden grid-cols-[1.3fr_110px_110px_110px_110px_110px] border-b border-dark-border bg-dark-input/40 px-4 py-3 text-xs font-bold uppercase tracking-widest text-dark-muted lg:grid">
|
|
<span>Material</span>
|
|
<span>Planejado</span>
|
|
<span>Estoque</span>
|
|
<span>Pendente</span>
|
|
<span>Comprar</span>
|
|
<span>Cobertura</span>
|
|
</div>
|
|
<div className="divide-y divide-dark-border">
|
|
{visibleNeeds.map(need => (
|
|
<div key={need.material} className="grid grid-cols-1 gap-3 bg-dark-card px-4 py-4 lg:grid-cols-[1.3fr_110px_110px_110px_110px_110px] 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">
|
|
{need.missingReference
|
|
? 'Cadastre produto/material em Cadastros > Referência de Consumo'
|
|
: `${(need.colors.length ? need.colors.join(', ') : 'Todas as cores')} · ${(need.suppliers.length ? need.suppliers.join(', ') : 'Sem fornecedor')}`}
|
|
</p>
|
|
{need.products?.length ? (
|
|
<p className="mt-1 text-xs font-semibold text-dark-muted">
|
|
{need.products.slice(0, 2).map(product => product.productId).join(', ')}
|
|
{need.products.length > 2 ? ` +${need.products.length - 2}` : ''}
|
|
</p>
|
|
) : null}
|
|
</div>
|
|
<p className="text-sm font-bold text-dark-text">{formatNeedQuantity(need, need.plannedKg)}</p>
|
|
<p className="text-sm font-bold text-dark-text">{formatNeedQuantity(need, need.stockKg)}</p>
|
|
<p className="text-sm font-bold text-dark-text">{formatNeedQuantity(need, need.pendingKg)}</p>
|
|
<p className={`text-sm font-bold ${need.purchaseKg > 0 ? 'text-red-300' : 'text-emerald-300'}`}>{formatNeedQuantity(need, need.purchaseKg)}</p>
|
|
<span className={`w-fit rounded-full border px-2.5 py-1 text-xs font-bold ${
|
|
need.missingReference
|
|
? 'border-red-400/30 bg-red-400/10 text-red-300'
|
|
: need.status === 'critical'
|
|
? 'border-red-400/30 bg-red-400/10 text-red-300'
|
|
: need.status === 'attention'
|
|
? 'border-amber-400/30 bg-amber-400/10 text-amber-300'
|
|
: 'border-emerald-400/30 bg-emerald-400/10 text-emerald-300'
|
|
}`}>
|
|
{need.missingReference ? 'Sem referência' : purchaseStatusLabels[need.status]}
|
|
</span>
|
|
</div>
|
|
))}
|
|
</div>
|
|
</div>
|
|
) : (
|
|
<div className={emptyStateClassName}>
|
|
<BarChart3 className="h-8 w-8 text-brand-primary" />
|
|
<h3 className="text-base font-bold text-dark-text">{summary.purchaseNeeds.length ? 'Nenhuma necessidade encontrada' : 'Nenhuma necessidade de compra'}</h3>
|
|
<p className="text-sm font-semibold text-dark-muted">{summary.purchaseNeeds.length ? 'Ajuste a busca.' : 'Cadastre planos de malha para gerar demanda e aprove recebimentos para abater estoque.'}</p>
|
|
</div>
|
|
)}
|
|
</div>
|
|
<div className={`${panelClassName} p-5`}>
|
|
<h2 className="text-base font-bold text-dark-text">Como o fluxo está conectado</h2>
|
|
<p className="mt-2 text-sm font-semibold text-dark-muted">Plano de malha cria demanda. Recebimento pendente entra como material a receber. Aprovar recebimento move para estoque e movimentações.</p>
|
|
</div>
|
|
</div>
|
|
);
|
|
};
|
|
|
|
const Supplies = () => {
|
|
const { section } = useParams<{ section?: string }>();
|
|
|
|
if (!section) return <SuppliesHub />;
|
|
if (section === 'inventory') return <InventoryScreen />;
|
|
if (section === 'fabric-planning') return <FabricPlanningScreen />;
|
|
if (section === 'purchase-needs') return <PurchaseNeedsScreen />;
|
|
|
|
return <Navigate to="/supplies" replace />;
|
|
};
|
|
|
|
export default Supplies;
|