Compare commits
7 Commits
0e06e800ec
...
ab187dece8
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ab187dece8 | ||
|
|
d2f9cd91bf | ||
|
|
62cf279ad4 | ||
|
|
58caee64eb | ||
|
|
8e8994099e | ||
|
|
43632c9aa5 | ||
|
|
22dd4ca376 |
@@ -12,6 +12,7 @@ import type { CuttingSettings, DateRange, ProductAnalyticsItem, ProductCompositi
|
|||||||
import { getPlanningStock } from '../planningStock';
|
import { getPlanningStock } from '../planningStock';
|
||||||
|
|
||||||
type CutFilter = 'need' | 'all' | 'issues' | 'covered';
|
type CutFilter = 'need' | 'all' | 'issues' | 'covered';
|
||||||
|
type ExecutionFilter = 'all' | 'ready' | 'blocked' | 'data';
|
||||||
type CutSort = 'need_desc' | 'need_asc' | 'demand_desc' | 'stock_asc' | 'sold_desc' | 'name_asc';
|
type CutSort = 'need_desc' | 'need_asc' | 'demand_desc' | 'stock_asc' | 'sold_desc' | 'name_asc';
|
||||||
type SaveStatus = 'idle' | 'saving' | 'saved' | 'error';
|
type SaveStatus = 'idle' | 'saving' | 'saved' | 'error';
|
||||||
type CutProductIssue = Exclude<CutIssue, 'missing_yield_rule'>;
|
type CutProductIssue = Exclude<CutIssue, 'missing_yield_rule'>;
|
||||||
@@ -146,6 +147,7 @@ const Cutting = () => {
|
|||||||
const [targetCoverageDays, setTargetCoverageDays] = useState(30);
|
const [targetCoverageDays, setTargetCoverageDays] = useState(30);
|
||||||
const [familyFilter, setFamilyFilter] = useState<CutFamilyKey | 'all'>('all');
|
const [familyFilter, setFamilyFilter] = useState<CutFamilyKey | 'all'>('all');
|
||||||
const [cutFilter, setCutFilter] = useState<CutFilter>('need');
|
const [cutFilter, setCutFilter] = useState<CutFilter>('need');
|
||||||
|
const [executionFilter, setExecutionFilter] = useState<ExecutionFilter>('all');
|
||||||
const [sortBy, setSortBy] = useState<CutSort>('need_desc');
|
const [sortBy, setSortBy] = useState<CutSort>('need_desc');
|
||||||
const [isSettingsOpen, setIsSettingsOpen] = useState(false);
|
const [isSettingsOpen, setIsSettingsOpen] = useState(false);
|
||||||
const [settingsSection, setSettingsSection] = useState<SettingsSection>('rules');
|
const [settingsSection, setSettingsSection] = useState<SettingsSection>('rules');
|
||||||
@@ -309,7 +311,15 @@ const Cutting = () => {
|
|||||||
return row.suggestedCutQuantity === 0;
|
return row.suggestedCutQuantity === 0;
|
||||||
});
|
});
|
||||||
|
|
||||||
return [...statusRows].sort((a, b) => {
|
const executionRows = statusRows.filter(row => {
|
||||||
|
if (executionFilter === 'all') return true;
|
||||||
|
const status = materialReadinessByProductId.get(row.id)?.status;
|
||||||
|
if (executionFilter === 'ready') return row.suggestedCutQuantity > 0 && status === 'ready';
|
||||||
|
if (executionFilter === 'blocked') return row.suggestedCutQuantity > 0 && (status === 'blocked' || status === 'missing');
|
||||||
|
return row.suggestedCutQuantity > 0 && status === 'material_ready';
|
||||||
|
});
|
||||||
|
|
||||||
|
return [...executionRows].sort((a, b) => {
|
||||||
switch (sortBy) {
|
switch (sortBy) {
|
||||||
case 'need_asc': return a.suggestedCutQuantity - b.suggestedCutQuantity;
|
case 'need_asc': return a.suggestedCutQuantity - b.suggestedCutQuantity;
|
||||||
case 'demand_desc': return b.projectedDemand - a.projectedDemand;
|
case 'demand_desc': return b.projectedDemand - a.projectedDemand;
|
||||||
@@ -321,7 +331,7 @@ const Cutting = () => {
|
|||||||
return b.suggestedCutQuantity - a.suggestedCutQuantity;
|
return b.suggestedCutQuantity - a.suggestedCutQuantity;
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}, [cutFilter, cutPlan.rows, familyFilter, searchTerm, sortBy]);
|
}, [cutFilter, cutPlan.rows, executionFilter, familyFilter, materialReadinessByProductId, searchTerm, sortBy]);
|
||||||
|
|
||||||
const rowsAvailableForOrder = useMemo(() => (
|
const rowsAvailableForOrder = useMemo(() => (
|
||||||
filteredRows.filter(row => row.suggestedCutQuantity > 0 && materialReadinessByProductId.get(row.id)?.status === 'ready')
|
filteredRows.filter(row => row.suggestedCutQuantity > 0 && materialReadinessByProductId.get(row.id)?.status === 'ready')
|
||||||
@@ -599,12 +609,18 @@ const Cutting = () => {
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
const renderMaterialReadiness = (row: CutPlanSkuRow) => {
|
const renderCutStatus = (row: CutPlanSkuRow) => {
|
||||||
const readiness = materialReadinessByProductId.get(row.id);
|
const readiness = materialReadinessByProductId.get(row.id);
|
||||||
if (readiness?.status === 'ready') return <span className="text-xs font-bold text-emerald-300">Pode cortar</span>;
|
if (readiness?.status === 'ready') {
|
||||||
if (readiness?.status === 'material_ready') return <span className="text-xs font-bold text-sky-300">Material OK · revisar dados</span>;
|
return <span className="inline-flex rounded-full border border-emerald-400/30 bg-emerald-400/10 px-2.5 py-1 text-xs font-bold text-emerald-300">Pronto para cortar</span>;
|
||||||
if (readiness?.status === 'missing') return <span className="text-xs font-bold text-amber-300">Sem composição</span>;
|
}
|
||||||
return <span className="inline-flex max-w-full rounded-full border border-red-400/30 bg-red-400/10 px-2 py-1 text-xs font-bold text-red-300" title={readiness?.blockers.join(' · ')}><span className="truncate">Falta: {readiness?.blockers[0] || 'material'}</span></span>;
|
if (readiness?.status === 'material_ready') {
|
||||||
|
return <span className="inline-flex max-w-full items-center gap-1.5 rounded-full border border-amber-400/30 bg-amber-400/10 px-2.5 py-1 text-xs font-bold text-amber-300" title={row.issues.map(issue => issueLabels[issue]).join(', ')}><AlertTriangle className="h-3.5 w-3.5 shrink-0" /><span className="truncate">Dados pendentes</span></span>;
|
||||||
|
}
|
||||||
|
if (readiness?.status === 'missing') {
|
||||||
|
return <span className="inline-flex rounded-full border border-amber-400/30 bg-amber-400/10 px-2.5 py-1 text-xs font-bold text-amber-300">Sem composição</span>;
|
||||||
|
}
|
||||||
|
return <span className="inline-flex max-w-full rounded-full border border-red-400/30 bg-red-400/10 px-2.5 py-1 text-xs font-bold text-red-300" title={readiness?.blockers.join(' · ')}><span className="truncate">Falta: {readiness?.blockers[0] || 'material'}</span></span>;
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -1241,6 +1257,29 @@ const Cutting = () => {
|
|||||||
|
|
||||||
{activeCuttingView === 'plan' && (
|
{activeCuttingView === 'plan' && (
|
||||||
<>
|
<>
|
||||||
|
<div className="grid grid-cols-2 gap-3 xl:grid-cols-4">
|
||||||
|
{[
|
||||||
|
{ id: 'all' as const, label: 'Fila de corte', detail: `${formatNumber(cutPlan.summary.skuCount)} SKUs com necessidade`, value: cutExecutionSummary.rawUnits, tone: 'default' },
|
||||||
|
{ id: 'ready' as const, label: 'Pronto para cortar', detail: `${formatNumber(cutExecutionSummary.readyRows)} SKUs liberados`, value: cutExecutionSummary.readyUnits, tone: 'ready' },
|
||||||
|
{ id: 'blocked' as const, label: 'Falta material', detail: `${formatNumber(cutExecutionSummary.blockedByMaterial)} SKUs bloqueados`, value: cutExecutionSummary.blockedByMaterial, tone: 'blocked' },
|
||||||
|
{ id: 'data' as const, label: 'Dados pendentes', detail: 'Material disponível, cadastro incompleto', value: cutExecutionSummary.blockedByData, tone: 'data' },
|
||||||
|
].map(option => (
|
||||||
|
<button
|
||||||
|
key={option.id}
|
||||||
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
setExecutionFilter(option.id);
|
||||||
|
setCutFilter('need');
|
||||||
|
setCurrentPage(1);
|
||||||
|
}}
|
||||||
|
className={`rounded-2xl border p-4 text-left shadow-sm transition-colors cursor-pointer ${executionFilter === option.id ? option.tone === 'ready' ? 'border-emerald-400/45 bg-emerald-400/[0.07]' : option.tone === 'blocked' ? 'border-red-400/45 bg-red-400/[0.07]' : option.tone === 'data' ? 'border-amber-400/45 bg-amber-400/[0.07]' : 'border-brand-primary/45 bg-brand-primary/[0.07]' : 'border-dark-border bg-dark-card hover:border-brand-primary/35'}`}
|
||||||
|
>
|
||||||
|
<p className="text-xs font-bold uppercase tracking-widest text-dark-muted">{option.label}</p>
|
||||||
|
<p className={`mt-2 text-2xl font-bold ${option.tone === 'ready' ? 'text-emerald-300' : option.tone === 'blocked' ? 'text-red-300' : option.tone === 'data' ? 'text-amber-300' : 'text-dark-text'}`}>{formatNumber(option.value)}{option.id === 'all' || option.id === 'ready' ? ' un.' : ''}</p>
|
||||||
|
<p className="mt-1 truncate text-xs font-semibold text-dark-muted">{option.detail}</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-[1fr_170px_170px_170px_190px]">
|
<div className="grid grid-cols-1 gap-3 rounded-2xl border border-dark-border bg-dark-card p-4 shadow-sm xl:grid-cols-[1fr_170px_170px_170px_190px]">
|
||||||
<div className="relative">
|
<div className="relative">
|
||||||
<Search className="absolute left-3 top-1/2 h-5 w-5 -translate-y-1/2 text-zinc-400 dark:text-dark-muted" />
|
<Search className="absolute left-3 top-1/2 h-5 w-5 -translate-y-1/2 text-zinc-400 dark:text-dark-muted" />
|
||||||
@@ -1313,63 +1352,37 @@ const Cutting = () => {
|
|||||||
) : (
|
) : (
|
||||||
<div className={`overflow-hidden rounded-2xl border border-zinc-200 bg-white shadow-sm dark:border-dark-border dark:bg-dark-card ${isRefreshing ? 'refreshing-content' : ''}`} aria-busy={isRefreshing}>
|
<div className={`overflow-hidden rounded-2xl border border-zinc-200 bg-white shadow-sm dark:border-dark-border dark:bg-dark-card ${isRefreshing ? 'refreshing-content' : ''}`} aria-busy={isRefreshing}>
|
||||||
<div className="overflow-x-auto">
|
<div className="overflow-x-auto">
|
||||||
<table className="w-full min-w-[1450px] table-fixed text-left text-sm">
|
<table className="w-full min-w-[940px] table-fixed text-left text-sm">
|
||||||
<colgroup>
|
<colgroup>
|
||||||
<col className="w-[105px]" />
|
<col className="w-[380px]" />
|
||||||
<col className="w-[320px]" />
|
<col className="w-[120px]" />
|
||||||
<col className="w-[110px]" />
|
<col className="w-[120px]" />
|
||||||
<col className="w-[145px]" />
|
|
||||||
<col className="w-[115px]" />
|
|
||||||
<col className="w-[125px]" />
|
|
||||||
<col className="w-[140px]" />
|
<col className="w-[140px]" />
|
||||||
<col className="w-[80px]" />
|
<col className="w-[220px]" />
|
||||||
<col className="w-[230px]" />
|
|
||||||
<col className="w-[160px]" />
|
|
||||||
<col className="w-[100px]" />
|
<col className="w-[100px]" />
|
||||||
</colgroup>
|
</colgroup>
|
||||||
<thead className="border-b border-zinc-100 bg-zinc-50 text-zinc-500 dark:border-dark-border dark:bg-dark-header dark:text-dark-muted">
|
<thead className="border-b border-zinc-100 bg-zinc-50 text-zinc-500 dark:border-dark-border dark:bg-dark-header dark:text-dark-muted">
|
||||||
<tr>
|
<tr>
|
||||||
<th className="px-4 py-4 text-[10px] font-bold uppercase tracking-wider">ID Produto</th>
|
<th className="px-4 py-4 text-[10px] font-bold uppercase tracking-wider">Produto</th>
|
||||||
<th className="px-4 py-4 text-[10px] font-bold uppercase tracking-wider">Descrição</th>
|
|
||||||
<th className="px-4 py-4 text-[10px] font-bold uppercase tracking-wider">Família</th>
|
|
||||||
<th className="px-4 py-4 text-[10px] font-bold uppercase tracking-wider">Variação</th>
|
|
||||||
<th className="px-4 py-4 text-[10px] font-bold uppercase tracking-wider">Demanda</th>
|
<th className="px-4 py-4 text-[10px] font-bold uppercase tracking-wider">Demanda</th>
|
||||||
<th className="px-4 py-4 text-[10px] font-bold uppercase tracking-wider">Estoque</th>
|
<th className="px-4 py-4 text-[10px] font-bold uppercase tracking-wider">Estoque</th>
|
||||||
<th className="px-4 py-4 text-[10px] font-bold uppercase tracking-wider">Necessidade</th>
|
<th className="px-4 py-4 text-[10px] font-bold uppercase tracking-wider">Necessidade</th>
|
||||||
<th className="px-4 py-4 text-[10px] font-bold uppercase tracking-wider">Rolos</th>
|
<th className="px-4 py-4 text-[10px] font-bold uppercase tracking-wider">Situação para corte</th>
|
||||||
<th className="px-4 py-4 text-[10px] font-bold uppercase tracking-wider">Materiais</th>
|
|
||||||
<th
|
|
||||||
className="px-4 py-4 text-[10px] font-bold uppercase tracking-wider"
|
|
||||||
title="Dados que ainda faltam para fechar o plano de corte do SKU."
|
|
||||||
>
|
|
||||||
Dados pendentes
|
|
||||||
</th>
|
|
||||||
<th className="px-4 py-4 text-right text-[10px] font-bold uppercase tracking-wider">Ações</th>
|
<th className="px-4 py-4 text-right text-[10px] font-bold uppercase tracking-wider">Ações</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody className="divide-y divide-zinc-100 dark:divide-dark-border">
|
<tbody className="divide-y divide-zinc-100 dark:divide-dark-border">
|
||||||
{paginatedRows.map(row => (
|
{paginatedRows.map(row => (
|
||||||
<tr key={row.id} className="transition-colors hover:bg-zinc-50/80 dark:hover:bg-dark-input/50">
|
<tr key={row.id} className="transition-colors hover:bg-zinc-50/80 dark:hover:bg-dark-input/50">
|
||||||
<td className="px-4 py-2.5 font-mono text-[11px] text-zinc-400 dark:text-dark-muted">#{row.id}</td>
|
|
||||||
<td className="max-w-0 px-4 py-2.5">
|
<td className="max-w-0 px-4 py-2.5">
|
||||||
<div className="truncate font-semibold text-zinc-900 dark:text-dark-text" title={row.name}>{row.name}</div>
|
<div className="truncate font-semibold text-zinc-900 dark:text-dark-text" title={row.name}>{row.name}</div>
|
||||||
<div className="text-[10px] font-medium text-zinc-400 dark:text-dark-muted">
|
<div className="mt-1 flex flex-wrap items-center gap-1.5">
|
||||||
Média: {formatNumber(row.dailySales, 2)} un./dia · Cobertura: {formatDays(row.daysOfCover)}
|
<span className="font-mono text-[10px] font-medium text-zinc-400 dark:text-dark-muted">#{row.id}</span>
|
||||||
</div>
|
<span className={`inline-flex rounded-full border px-2 py-0.5 text-[10px] font-bold ${familyStyles[row.family.key]}`}>{row.family.materialLabel}</span>
|
||||||
</td>
|
<ProductColorBadge label={row.color} className="max-w-[92px] px-2 py-0.5 text-[10px]" />
|
||||||
<td className="px-4 py-2.5">
|
<span className="inline-flex items-center gap-1 rounded-full border border-emerald-400/25 bg-emerald-400/10 px-2 py-0.5 text-[10px] font-bold text-emerald-300"><Ruler className="h-3 w-3" />{row.size || '-'}</span>
|
||||||
<span className={`inline-flex whitespace-nowrap rounded-full border px-2.5 py-1 text-xs font-bold ${familyStyles[row.family.key]}`}>
|
|
||||||
{row.family.materialLabel}
|
|
||||||
</span>
|
|
||||||
</td>
|
|
||||||
<td className="px-4 py-2.5">
|
|
||||||
<div className="flex min-w-0 items-center gap-1.5">
|
|
||||||
<ProductColorBadge label={row.color} className="max-w-[92px] px-2" />
|
|
||||||
<span className="inline-flex items-center gap-1 rounded-full border border-emerald-400/25 bg-emerald-400/10 px-2 py-1 text-xs font-bold text-emerald-300">
|
|
||||||
<Ruler className="h-3 w-3" />
|
|
||||||
{row.size || '-'}
|
|
||||||
</span>
|
|
||||||
</div>
|
</div>
|
||||||
|
<div className="mt-1 text-[10px] font-medium text-zinc-400 dark:text-dark-muted">Média: {formatNumber(row.dailySales, 2)} un./dia · Cobertura: {formatDays(row.daysOfCover)}</div>
|
||||||
</td>
|
</td>
|
||||||
<td className="px-4 py-2.5 font-bold whitespace-nowrap text-zinc-900 dark:text-dark-text">{formatNumber(row.projectedDemand, 1)} un.</td>
|
<td className="px-4 py-2.5 font-bold whitespace-nowrap text-zinc-900 dark:text-dark-text">{formatNumber(row.projectedDemand, 1)} un.</td>
|
||||||
<td className="px-4 py-2.5 whitespace-nowrap">
|
<td className="px-4 py-2.5 whitespace-nowrap">
|
||||||
@@ -1383,11 +1396,7 @@ const Cutting = () => {
|
|||||||
{formatNumber(row.suggestedCutQuantity)} un.
|
{formatNumber(row.suggestedCutQuantity)} un.
|
||||||
</span>
|
</span>
|
||||||
</td>
|
</td>
|
||||||
<td className="px-4 py-2.5 font-bold whitespace-nowrap text-zinc-900 dark:text-dark-text">
|
<td className="px-4 py-2.5 align-middle">{renderCutStatus(row)}</td>
|
||||||
{row.estimatedRolls === null ? '-' : formatNumber(row.estimatedRolls)}
|
|
||||||
</td>
|
|
||||||
<td className="px-4 py-2.5 align-middle">{renderMaterialReadiness(row)}</td>
|
|
||||||
<td className="px-4 py-2.5 align-middle">{renderIssueBadge(row)}</td>
|
|
||||||
<td className="px-4 py-2.5 text-right">
|
<td className="px-4 py-2.5 text-right">
|
||||||
<div className="flex justify-end gap-2">
|
<div className="flex justify-end gap-2">
|
||||||
<Link
|
<Link
|
||||||
|
|||||||
@@ -353,6 +353,37 @@ const ProductionOrders = () => {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{summary.orders.length === 0 ? (
|
||||||
|
<div className="rounded-2xl border border-dark-border bg-dark-card p-6 shadow-sm">
|
||||||
|
<div className="mx-auto max-w-3xl text-center">
|
||||||
|
<div className="mx-auto flex h-12 w-12 items-center justify-center rounded-2xl border border-brand-primary/30 bg-brand-primary/10 text-brand-primary">
|
||||||
|
<ClipboardList className="h-6 w-6" />
|
||||||
|
</div>
|
||||||
|
<h2 className="mt-4 text-lg font-bold text-dark-text">Nenhuma ordem de produção neste período</h2>
|
||||||
|
<p className="mt-2 text-sm font-semibold leading-6 text-dark-muted">As OPs só são criadas depois que o Plano de Corte confirma SKUs com material e dados prontos para produção.</p>
|
||||||
|
</div>
|
||||||
|
<div className="mx-auto mt-6 grid max-w-4xl grid-cols-1 gap-3 md:grid-cols-3">
|
||||||
|
{[
|
||||||
|
['01', 'Resolver bloqueios', 'Confirme saldos de material e dados de composição pendentes.'],
|
||||||
|
['02', 'Definir o corte', 'Escolha SKUs liberados e revise a prévia das OPs.'],
|
||||||
|
['03', 'Acompanhar produção', 'Gere as OPs aqui e registre o consumo de material.'],
|
||||||
|
].map(([number, title, description]) => (
|
||||||
|
<div key={number} className="rounded-xl border border-dark-border bg-dark-input/45 p-4 text-left">
|
||||||
|
<span className="text-xs font-bold tracking-[0.16em] text-dark-muted">{number}</span>
|
||||||
|
<h3 className="mt-3 text-sm font-bold text-dark-text">{title}</h3>
|
||||||
|
<p className="mt-1 text-xs font-semibold leading-5 text-dark-muted">{description}</p>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<div className="mt-6 flex justify-center">
|
||||||
|
<Link to="/cutting" className="inline-flex h-10 items-center gap-2 rounded-lg border border-brand-primary/35 bg-brand-primary/10 px-4 text-sm font-bold text-brand-primary transition-colors hover:bg-brand-primary/20">
|
||||||
|
<ClipboardList className="h-4 w-4" />
|
||||||
|
Abrir Plano de Corte
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
{activeOrders.length > 0 && (
|
{activeOrders.length > 0 && (
|
||||||
<form onSubmit={handleProductionExit} className="rounded-2xl border border-dark-border bg-dark-card p-5 shadow-sm">
|
<form onSubmit={handleProductionExit} className="rounded-2xl border border-dark-border bg-dark-card p-5 shadow-sm">
|
||||||
<div className="flex flex-col gap-2 lg:flex-row lg:items-start lg:justify-between">
|
<div className="flex flex-col gap-2 lg:flex-row lg:items-start lg:justify-between">
|
||||||
@@ -654,17 +685,10 @@ const ProductionOrders = () => {
|
|||||||
<div className="rounded-2xl border border-dark-border bg-dark-input p-4 text-brand-primary">
|
<div className="rounded-2xl border border-dark-border bg-dark-input p-4 text-brand-primary">
|
||||||
<ClipboardList className="h-7 w-7" />
|
<ClipboardList className="h-7 w-7" />
|
||||||
</div>
|
</div>
|
||||||
<p className="mt-4 text-sm font-bold text-dark-text">Nenhuma ordem de produção encontrada.</p>
|
<p className="mt-4 text-sm font-bold text-dark-text">Nenhuma ordem encontrada neste filtro.</p>
|
||||||
<p className="mt-1 max-w-md text-sm font-medium text-dark-muted">
|
<p className="mt-1 max-w-md text-sm font-medium text-dark-muted">
|
||||||
Gere ordens pelo Plano de Corte para acompanhar status, produto, quantidade e baixa de material dentro do Graphs.
|
Ajuste a busca ou o status para ver outra parte da fila de produção.
|
||||||
</p>
|
</p>
|
||||||
<Link
|
|
||||||
to="/cutting"
|
|
||||||
className="mt-5 inline-flex h-10 items-center gap-2 rounded-lg border border-brand-primary/35 bg-brand-primary/10 px-4 text-sm font-bold text-brand-primary transition-colors hover:bg-brand-primary/20"
|
|
||||||
>
|
|
||||||
<ClipboardList className="h-4 w-4" />
|
|
||||||
Ir para Plano de Corte
|
|
||||||
</Link>
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -685,6 +709,8 @@ const ProductionOrders = () => {
|
|||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { type FormEvent, useEffect, useMemo, useState } from 'react';
|
import { type FormEvent, useEffect, useMemo, useState } from 'react';
|
||||||
import { Link as RouterLink, Navigate, useOutletContext, useParams } from 'react-router-dom';
|
import { Link as RouterLink, Navigate, useOutletContext, useParams, useSearchParams } from 'react-router-dom';
|
||||||
import {
|
import {
|
||||||
AlertTriangle,
|
AlertTriangle,
|
||||||
ArrowLeft,
|
ArrowLeft,
|
||||||
@@ -135,17 +135,17 @@ type DemandRow = ProductAnalyticsItem & {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const demandQueueLabels: Record<DemandQueue, string> = {
|
const demandQueueLabels: Record<DemandQueue, string> = {
|
||||||
apparel: 'Cortar / Repor',
|
apparel: 'Repor produtos',
|
||||||
accessories: 'Repor acessórios',
|
accessories: 'Repor acessórios',
|
||||||
inputs: 'Comprar insumos',
|
inputs: 'Insumos a comprar',
|
||||||
review: 'Revisar dados',
|
review: 'Revisar dados',
|
||||||
dead: 'Estoque parado',
|
dead: 'Estoque parado',
|
||||||
};
|
};
|
||||||
|
|
||||||
const demandQueueDescriptions: Record<DemandQueue, string> = {
|
const demandQueueDescriptions: Record<DemandQueue, string> = {
|
||||||
apparel: 'Produtos acabados com demanda ativa e cobertura abaixo da meta.',
|
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.',
|
accessories: 'Acessórios acabados com demanda ativa e cobertura abaixo da meta.',
|
||||||
inputs: 'Embalagens, matérias-primas e insumos de produção com necessidade de reposição.',
|
inputs: 'Embalagens, matérias-primas e insumos com necessidade comercial de reposição.',
|
||||||
review: 'SKUs com informações pendentes para qualificar o planejamento.',
|
review: 'SKUs com informações pendentes para qualificar o planejamento.',
|
||||||
dead: 'Itens com saldo disponível e baixa movimentação no período.',
|
dead: 'Itens com saldo disponível e baixa movimentação no período.',
|
||||||
};
|
};
|
||||||
@@ -201,6 +201,81 @@ const ModuleCard = ({
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const FlowStepCard = ({
|
||||||
|
number,
|
||||||
|
title,
|
||||||
|
description,
|
||||||
|
meta,
|
||||||
|
icon: Icon,
|
||||||
|
to,
|
||||||
|
tone = 'default',
|
||||||
|
}: {
|
||||||
|
number: string;
|
||||||
|
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-44 flex-col p-5 transition-colors hover:border-brand-primary/70`}>
|
||||||
|
<div className="flex items-start justify-between gap-4">
|
||||||
|
<span className="text-xs font-bold tracking-[0.18em] text-dark-muted">{number}</span>
|
||||||
|
<div className={`flex h-10 w-10 items-center justify-center rounded-xl border ${tones[tone]}`}>
|
||||||
|
<Icon className="h-5 w-5" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="mt-5">
|
||||||
|
<h3 className="text-base font-bold text-dark-text">{title}</h3>
|
||||||
|
<p className="mt-1.5 text-sm font-semibold leading-5 text-dark-muted">{description}</p>
|
||||||
|
</div>
|
||||||
|
<div className="mt-auto flex items-center justify-between gap-3 pt-5">
|
||||||
|
<span className={`text-xs font-bold ${tone === 'critical' ? 'text-red-300' : tone === 'attention' ? 'text-amber-300' : tone === 'ready' ? 'text-emerald-300' : 'text-dark-text'}`}>{meta}</span>
|
||||||
|
<ArrowRight className="h-4 w-4 shrink-0 text-dark-muted transition-transform group-hover:translate-x-0.5 group-hover:text-brand-primary" />
|
||||||
|
</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 SuppliesHub = () => {
|
||||||
const [isExportingDiagnostic, setIsExportingDiagnostic] = useState(false);
|
const [isExportingDiagnostic, setIsExportingDiagnostic] = useState(false);
|
||||||
const [diagnosticError, setDiagnosticError] = useState('');
|
const [diagnosticError, setDiagnosticError] = useState('');
|
||||||
@@ -224,10 +299,63 @@ const SuppliesHub = () => {
|
|||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const materialPurchaseNeeds = summary.purchaseNeeds.filter(need => !need.missingReference && !isServicePurchaseNeed(need) && need.purchaseKg > 0);
|
const materialPurchaseNeeds = summary.purchaseNeeds.filter(need => !need.missingReference && !isServicePurchaseNeed(need) && need.purchaseKg > 0);
|
||||||
|
const negativeMaterialNeeds = materialPurchaseNeeds.filter(need => need.stockKg < 0);
|
||||||
|
const noStockMaterialNeeds = materialPurchaseNeeds.filter(need => need.stockKg === 0);
|
||||||
const mappingSkuCount = new Set(summary.purchaseNeeds
|
const mappingSkuCount = new Set(summary.purchaseNeeds
|
||||||
.filter(need => need.missingReference)
|
.filter(need => need.missingReference)
|
||||||
.flatMap(need => (need.products || []).map(product => product.productId))
|
.flatMap(need => (need.products || []).map(product => product.productId))
|
||||||
.filter(Boolean)).size;
|
.filter(Boolean)).size;
|
||||||
|
const activeProductionCount = productionCounts.open + productionCounts.inProgress;
|
||||||
|
const nextAction = negativeMaterialNeeds.length > 0
|
||||||
|
? {
|
||||||
|
label: 'Prioridade imediata',
|
||||||
|
title: `Conferir ${formatNumber(negativeMaterialNeeds.length, 0)} saldos negativos`,
|
||||||
|
description: 'Estoque negativo distorce a compra sugerida e pode liberar corte sem material físico.',
|
||||||
|
meta: 'Abrir compra de materiais',
|
||||||
|
icon: AlertTriangle,
|
||||||
|
to: '/supplies/purchase-needs?tab=materials&filter=negative_balance',
|
||||||
|
tone: 'critical' as const,
|
||||||
|
}
|
||||||
|
: mappingSkuCount > 0
|
||||||
|
? {
|
||||||
|
label: 'Próxima ação',
|
||||||
|
title: `Mapear o consumo de ${formatNumber(mappingSkuCount, 0)} SKUs`,
|
||||||
|
description: 'Sem composição confiável, a necessidade de compra e o corte não representam a demanda real.',
|
||||||
|
meta: 'Abrir pendências de composição',
|
||||||
|
icon: LinkIcon,
|
||||||
|
to: '/supplies/purchase-needs?tab=mapping',
|
||||||
|
tone: 'info' as const,
|
||||||
|
}
|
||||||
|
: 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 () => {
|
const handleExportDiagnostic = async () => {
|
||||||
setIsExportingDiagnostic(true);
|
setIsExportingDiagnostic(true);
|
||||||
@@ -262,17 +390,44 @@ const SuppliesHub = () => {
|
|||||||
{diagnosticError}
|
{diagnosticError}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<section>
|
<section className="grid gap-4 xl:grid-cols-[minmax(0,1.35fr)_minmax(320px,.65fr)]">
|
||||||
<div className="mb-3 flex items-end justify-between gap-4">
|
<RouterLink to={nextAction.to} className={`group rounded-2xl border p-6 shadow-sm transition-colors hover:border-brand-primary/70 ${nextAction.tone === 'critical' ? 'border-red-400/30 bg-red-500/[0.055]' : nextAction.tone === 'attention' ? 'border-amber-400/30 bg-amber-500/[0.055]' : nextAction.tone === 'ready' ? 'border-emerald-400/30 bg-emerald-500/[0.055]' : nextAction.tone === 'info' ? 'border-sky-400/30 bg-sky-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 === 'critical' ? 'border-red-400/30 bg-red-500/10 text-red-300' : 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' : nextAction.tone === 'info' ? 'border-sky-400/30 bg-sky-500/10 text-sky-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>
|
<div>
|
||||||
<h2 className="text-base font-bold text-dark-text">Ações de hoje</h2>
|
<h2 className="text-base font-bold text-dark-text">Pontos que bloqueiam o fluxo</h2>
|
||||||
<p className="mt-1 text-sm font-semibold text-dark-muted">Comprar materiais, definir o corte e acompanhar a produção.</p>
|
<p className="mt-1 text-sm font-semibold text-dark-muted">Resolva estes pontos antes de confiar no plano.</p>
|
||||||
|
</div>
|
||||||
|
<div className="mt-4 space-y-2">
|
||||||
|
<FlowBlocker title="Saldos a conferir" description="Materiais com estoque negativo" count={negativeMaterialNeeds.length} to="/supplies/purchase-needs?tab=materials&filter=negative_balance" tone="critical" />
|
||||||
|
<FlowBlocker title="Dados para mapear" description="SKUs sem composição confiável" count={mappingSkuCount} to="/supplies/purchase-needs?tab=mapping" tone="info" />
|
||||||
|
<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" />
|
||||||
</div>
|
</div>
|
||||||
</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">
|
<div className="grid grid-cols-1 gap-4 xl:grid-cols-3">
|
||||||
<ModuleCard title="Comprar materiais" description="Necessidades de matéria-prima calculadas a partir da demanda e da composição." icon={BarChart3} to="/supplies/purchase-needs" priority meta={`${formatNumber(materialPurchaseNeeds.length, 0)} materiais para comprar${mappingSkuCount ? ` · ${formatNumber(mappingSkuCount, 0)} SKUs para mapear` : ''}`} />
|
<FlowStepCard number="01" title="Garantir materiais" description="Confira saldos, pendências de composição e a compra sugerida." icon={BarChart3} to={negativeMaterialNeeds.length > 0 ? '/supplies/purchase-needs?tab=materials&filter=negative_balance' : materialPurchaseNeeds.length > 0 ? '/supplies/purchase-needs?tab=materials&filter=to_buy' : mappingSkuCount > 0 ? '/supplies/purchase-needs?tab=mapping' : '/supplies/purchase-needs?tab=materials&filter=all'} tone={negativeMaterialNeeds.length > 0 ? 'critical' : materialPurchaseNeeds.length > 0 ? 'attention' : mappingSkuCount > 0 ? 'attention' : 'ready'} meta={negativeMaterialNeeds.length > 0 ? `${formatNumber(negativeMaterialNeeds.length, 0)} saldos negativos` : materialPurchaseNeeds.length > 0 ? `${formatNumber(materialPurchaseNeeds.length, 0)} materiais para comprar` : mappingSkuCount > 0 ? 'Sem falta calculada · dados pendentes' : 'Materiais cobertos'} />
|
||||||
<ModuleCard title="Planejar corte" description="Defina quais SKUs, cores e tamanhos priorizar conforme demanda e disponibilidade de material." icon={Scissors} to="/cutting" priority meta="Revise bloqueios de material antes de gerar OPs" />
|
<FlowStepCard number="02" title="Definir corte" description="Priorize SKUs, cores e tamanhos com necessidade e material disponível." icon={Scissors} to={mappingSkuCount > 0 ? '/supplies/purchase-needs?tab=mapping' : '/cutting'} tone={mappingSkuCount > 0 ? 'attention' : 'default'} meta={mappingSkuCount > 0 ? `${formatNumber(mappingSkuCount, 0)} SKUs precisam de mapeamento` : 'Abrir plano de corte'} />
|
||||||
<ModuleCard title="Acompanhar produção" description="Consulte ordens geradas pelo corte e acompanhe o andamento da fabricação." icon={ClipboardList} to="/production-orders" priority meta={`${formatNumber(productionCounts.open, 0)} abertas · ${formatNumber(productionCounts.inProgress, 0)} em andamento`} />
|
<FlowStepCard number="03" 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>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
<section className="border-t border-dark-border pt-6">
|
<section className="border-t border-dark-border pt-6">
|
||||||
@@ -445,7 +600,7 @@ const DemandPlanningScreen = () => {
|
|||||||
return (
|
return (
|
||||||
<div className={pageClassName}>
|
<div className={pageClassName}>
|
||||||
<div className="grid grid-cols-1 gap-4 2xl:grid-cols-[minmax(520px,1fr)_auto] 2xl:items-start">
|
<div className="grid grid-cols-1 gap-4 2xl:grid-cols-[minmax(520px,1fr)_auto] 2xl:items-start">
|
||||||
<Header title="Planejamento Operacional" subtitle="Prioridades de reposição, compra e revisão calculadas por demanda, estoque e cobertura." backTo="/supplies" />
|
<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
|
<DateRangePicker
|
||||||
dateRange={dateRange}
|
dateRange={dateRange}
|
||||||
onChange={(range) => {
|
onChange={(range) => {
|
||||||
@@ -557,41 +712,37 @@ const DemandPlanningScreen = () => {
|
|||||||
) : queueRows.length ? (
|
) : queueRows.length ? (
|
||||||
<div className="overflow-hidden rounded-2xl border border-dark-border bg-dark-card shadow-sm">
|
<div className="overflow-hidden rounded-2xl border border-dark-border bg-dark-card shadow-sm">
|
||||||
<div className="overflow-x-auto">
|
<div className="overflow-x-auto">
|
||||||
<table className="w-full min-w-[1080px] table-fixed border-collapse">
|
<table className="w-full min-w-[860px] table-fixed border-collapse">
|
||||||
<colgroup>
|
<colgroup>
|
||||||
<col className="w-[120px]" />
|
|
||||||
<col />
|
<col />
|
||||||
<col className="w-[140px]" />
|
|
||||||
<col className="w-[120px]" />
|
<col className="w-[120px]" />
|
||||||
<col className="w-[120px]" />
|
<col className="w-[120px]" />
|
||||||
<col className="w-[120px]" />
|
<col className="w-[120px]" />
|
||||||
<col className="w-[130px]" />
|
<col className="w-[130px]" />
|
||||||
<col className="w-[100px]" />
|
<col className="w-[140px]" />
|
||||||
</colgroup>
|
</colgroup>
|
||||||
<thead className="border-b border-dark-border bg-dark-header text-xs font-bold uppercase tracking-widest text-dark-muted">
|
<thead className="border-b border-dark-border bg-dark-header text-xs font-bold uppercase tracking-widest text-dark-muted">
|
||||||
<tr>
|
<tr>
|
||||||
<th scope="col" className="px-4 py-3 text-left">SKU</th>
|
|
||||||
<th scope="col" className="px-4 py-3 text-left">Produto</th>
|
<th scope="col" className="px-4 py-3 text-left">Produto</th>
|
||||||
<th scope="col" className="px-4 py-3 text-left">Tipo</th>
|
|
||||||
<th scope="col" className="px-4 py-3 text-right">Média/dia</th>
|
<th scope="col" className="px-4 py-3 text-right">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">Estoque</th>
|
||||||
<th scope="col" className="px-4 py-3 text-right">Cobertura</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">{queue === 'dead' ? 'Valor estoque' : 'Sugestão'}</th>
|
||||||
<th scope="col" className="px-4 py-3 text-right">Ações</th>
|
<th scope="col" className="px-4 py-3 text-right">Próximo passo</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody className="divide-y divide-dark-border">
|
<tbody className="divide-y divide-dark-border">
|
||||||
{paginatedRows.map(row => (
|
{paginatedRows.map(row => (
|
||||||
<tr key={row.id} className="transition-colors hover:bg-dark-input/50">
|
<tr key={row.id} className="transition-colors hover:bg-dark-input/50">
|
||||||
<td className="px-4 py-2.5 font-mono text-[11px] text-dark-muted">#{row.id}</td>
|
|
||||||
<td className="px-4 py-2.5">
|
<td className="px-4 py-2.5">
|
||||||
<p className="truncate text-sm font-bold text-dark-text" title={row.name}>{row.name}</p>
|
<p className="truncate text-sm font-bold text-dark-text" title={row.name}>{row.name}</p>
|
||||||
<p className="mt-1 truncate text-xs font-semibold text-dark-muted">
|
<div className="mt-1 flex flex-wrap items-center gap-1.5">
|
||||||
Cor: {row.color || '-'} · Tam.: {row.size || '-'}
|
<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(', ')}` : ''}
|
{row.missingData.length ? ` · Falta: ${row.missingData.join(', ')}` : ''}
|
||||||
</p>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
<td className="px-4 py-2.5"><ProductTypeBadge type={row.productType} /></td>
|
|
||||||
<td className="px-4 py-2.5 text-right text-sm font-bold text-dark-text">{formatNumber(row.dailySales, 2)}</td>
|
<td className="px-4 py-2.5 text-right text-sm font-bold text-dark-text">{formatNumber(row.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">{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 text-dark-text">{formatDays(row.daysOfCover)}</td>
|
||||||
@@ -602,24 +753,42 @@ const DemandPlanningScreen = () => {
|
|||||||
</td>
|
</td>
|
||||||
<td className="px-4 py-2.5 text-right">
|
<td className="px-4 py-2.5 text-right">
|
||||||
<div className="flex justify-end gap-2">
|
<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' && (
|
{queue === 'review' && (
|
||||||
<RouterLink
|
<RouterLink
|
||||||
to="/planning-issues"
|
to="/planning-issues"
|
||||||
className="inline-flex h-8 w-8 items-center justify-center rounded-lg bg-dark-input text-dark-text transition-colors hover:bg-dark-border"
|
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"
|
title="Abrir dados pendentes"
|
||||||
aria-label="Abrir dados pendentes"
|
|
||||||
>
|
>
|
||||||
<Pencil className="h-3.5 w-3.5" />
|
<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>
|
</RouterLink>
|
||||||
)}
|
)}
|
||||||
<RouterLink
|
|
||||||
to={`/products/${row.id}`}
|
|
||||||
className="inline-flex h-8 w-8 items-center justify-center rounded-lg bg-brand-primary/10 text-brand-primary transition-opacity hover:opacity-80"
|
|
||||||
title={`Ver SKU ${row.id}`}
|
|
||||||
aria-label={`Ver SKU ${row.id}`}
|
|
||||||
>
|
|
||||||
<ArrowRight className="h-3.5 w-3.5" />
|
|
||||||
</RouterLink>
|
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
@@ -676,7 +845,17 @@ const StatGrid = ({ summary }: { summary: SupplySummary }) => {
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
const InventoryDashboard = ({ summary, onNewReceipt }: { summary: SupplySummary; onNewReceipt: () => void }) => {
|
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 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 };
|
const current = acc[lot.category] || { quantity: 0, unit: lot.unit, lots: 0 };
|
||||||
acc[lot.category] = {
|
acc[lot.category] = {
|
||||||
@@ -687,12 +866,46 @@ const InventoryDashboard = ({ summary, onNewReceipt }: { summary: SupplySummary;
|
|||||||
return acc;
|
return acc;
|
||||||
}, {});
|
}, {});
|
||||||
const latestReceipts = summary.receipts.slice(0, 4);
|
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 (
|
return (
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<StatGrid summary={summary} />
|
<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={`${panelClassName} p-5`}>
|
||||||
<h2 className="text-base font-bold text-dark-text">Por categoria de material</h2>
|
<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 ? (
|
{Object.keys(categories).length ? (
|
||||||
<div className="mt-4 grid grid-cols-1 gap-3 md:grid-cols-2">
|
<div className="mt-4 grid grid-cols-1 gap-3 md:grid-cols-2">
|
||||||
{Object.entries(categories).map(([category, data]) => (
|
{Object.entries(categories).map(([category, data]) => (
|
||||||
@@ -706,14 +919,25 @@ const InventoryDashboard = ({ summary, onNewReceipt }: { summary: SupplySummary;
|
|||||||
) : (
|
) : (
|
||||||
<div className={emptyStateClassName}>
|
<div className={emptyStateClassName}>
|
||||||
<Warehouse className="h-8 w-8 text-brand-primary" />
|
<Warehouse className="h-8 w-8 text-brand-primary" />
|
||||||
<h3 className="text-base font-bold text-dark-text">Nenhuma categoria com saldo</h3>
|
<h3 className="text-base font-bold text-dark-text">Nenhum lote físico registrado</h3>
|
||||||
<p className="text-sm font-semibold text-dark-muted">Registre entradas para agrupar o estoque por tipo de material.</p>
|
<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>
|
</div>
|
||||||
<div className={`${panelClassName} p-5`}>
|
<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>
|
<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>
|
||||||
<p className="mt-4 text-sm font-semibold text-dark-muted">Nenhum alerta no momento.</p>
|
<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>
|
||||||
<div className={`${panelClassName} p-5`}>
|
<div className={`${panelClassName} p-5`}>
|
||||||
<div className="flex items-center justify-between gap-3">
|
<div className="flex items-center justify-between gap-3">
|
||||||
@@ -1577,7 +1801,7 @@ const InventoryScreen = () => {
|
|||||||
<div className={`${panelClassName} p-5 text-sm font-bold text-dark-muted`}>Carregando estoque...</div>
|
<div className={`${panelClassName} p-5 text-sm font-bold text-dark-muted`}>Carregando estoque...</div>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
{activeTab === 'dashboard' && <InventoryDashboard summary={summary} onNewReceipt={() => setActiveTab('receipts')} />}
|
{activeTab === 'dashboard' && <InventoryDashboard summary={summary} stock={stockSnapshot} onNewReceipt={() => setActiveTab('receipts')} onOpenStock={() => setActiveTab('stock')} />}
|
||||||
{activeTab === 'stock' && <StockTab stock={stockSnapshot} onRefresh={loadStockSnapshot} />}
|
{activeTab === 'stock' && <StockTab stock={stockSnapshot} onRefresh={loadStockSnapshot} />}
|
||||||
{activeTab === 'balance' && <BalanceTab summary={summary} onRefresh={loadSummary} />}
|
{activeTab === 'balance' && <BalanceTab summary={summary} onRefresh={loadSummary} />}
|
||||||
{activeTab === 'receipts' && (
|
{activeTab === 'receipts' && (
|
||||||
@@ -1621,6 +1845,7 @@ const InventoryScreen = () => {
|
|||||||
|
|
||||||
const FabricPlanningScreen = () => {
|
const FabricPlanningScreen = () => {
|
||||||
const [plans, setPlans] = useState<SupplyFabricPlan[]>([]);
|
const [plans, setPlans] = useState<SupplyFabricPlan[]>([]);
|
||||||
|
const [summary, setSummary] = useState<SupplySummary>(emptySupplySummary);
|
||||||
const [form, setForm] = useState({
|
const [form, setForm] = useState({
|
||||||
material: '',
|
material: '',
|
||||||
color: '',
|
color: '',
|
||||||
@@ -1632,12 +1857,20 @@ const FabricPlanningScreen = () => {
|
|||||||
const [isBusy, setIsBusy] = useState(false);
|
const [isBusy, setIsBusy] = useState(false);
|
||||||
const [errorMessage, setErrorMessage] = useState('');
|
const [errorMessage, setErrorMessage] = useState('');
|
||||||
|
|
||||||
const totalKg = plans.reduce((total, plan) => total + plan.quantityKg, 0);
|
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 () => {
|
const loadPlans = async () => {
|
||||||
setErrorMessage('');
|
setErrorMessage('');
|
||||||
const summary = await fetchSupplySummary();
|
const summary = await fetchSupplySummary();
|
||||||
setPlans(summary.fabricPlans);
|
setPlans(summary.fabricPlans);
|
||||||
|
setSummary(summary);
|
||||||
};
|
};
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -1647,7 +1880,10 @@ const FabricPlanningScreen = () => {
|
|||||||
setIsLoading(true);
|
setIsLoading(true);
|
||||||
try {
|
try {
|
||||||
const summary = await fetchSupplySummary();
|
const summary = await fetchSupplySummary();
|
||||||
if (isMounted) setPlans(summary.fabricPlans);
|
if (isMounted) {
|
||||||
|
setPlans(summary.fabricPlans);
|
||||||
|
setSummary(summary);
|
||||||
|
}
|
||||||
} finally {
|
} finally {
|
||||||
if (isMounted) setIsLoading(false);
|
if (isMounted) setIsLoading(false);
|
||||||
}
|
}
|
||||||
@@ -1700,7 +1936,7 @@ const FabricPlanningScreen = () => {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={pageClassName}>
|
<div className={pageClassName}>
|
||||||
<Header title="Planejamento de Malha" subtitle="Fila de matéria-prima para compra, recebimento e abastecimento do corte." backTo="/supplies" />
|
<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 && (
|
{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">
|
<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}
|
{errorMessage}
|
||||||
@@ -1708,24 +1944,80 @@ const FabricPlanningScreen = () => {
|
|||||||
)}
|
)}
|
||||||
<div className="grid grid-cols-1 gap-4 md:grid-cols-3">
|
<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">
|
<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="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-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="rounded-2xl border border-dark-border bg-dark-card p-5 shadow-sm">
|
</div>
|
||||||
<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 className={`${panelClassName} overflow-hidden`}>
|
||||||
</div>
|
<div className="flex flex-col gap-4 border-b border-dark-border p-5 md:flex-row md:items-start md:justify-between">
|
||||||
<div className="rounded-2xl border border-dark-border bg-dark-card p-5 shadow-sm">
|
<div>
|
||||||
<p className="text-xs font-bold uppercase tracking-widest text-dark-muted">Críticos</p>
|
<h2 className="text-base font-bold text-dark-text">Fila de tecidos para abastecer o corte</h2>
|
||||||
<p className="mt-2 text-3xl font-bold text-dark-text">{plans.filter(plan => plan.priority === 'Crítico').length}</p>
|
<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>
|
</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>
|
||||||
|
|
||||||
<div className="grid grid-cols-1 items-stretch gap-6 xl:grid-cols-[1fr_420px]">
|
<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={`${panelClassName} flex flex-col overflow-hidden`}>
|
||||||
<div className="border-b border-dark-border p-5">
|
<div className="border-b border-dark-border p-5">
|
||||||
<h2 className="text-base font-bold text-dark-text">Planos de malha</h2>
|
<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">Itens planejados para compra ou recebimento.</p>
|
<p className="mt-1 text-sm font-semibold text-dark-muted">Registros operacionais internos; não substituem a necessidade calculada acima.</p>
|
||||||
</div>
|
</div>
|
||||||
{isLoading ? (
|
{isLoading ? (
|
||||||
<div className={`${emptyStateClassName} min-h-[220px] flex-1`}>
|
<div className={`${emptyStateClassName} min-h-[220px] flex-1`}>
|
||||||
@@ -2085,11 +2377,14 @@ const PurchaseMappingQueue = ({ needs }: { needs: SupplyPurchaseNeed[] }) => (
|
|||||||
);
|
);
|
||||||
|
|
||||||
const PurchaseNeedsScreen = () => {
|
const PurchaseNeedsScreen = () => {
|
||||||
|
const [searchParams, setSearchParams] = useSearchParams();
|
||||||
const [summary, setSummary] = useState<SupplySummary>(emptySupplySummary);
|
const [summary, setSummary] = useState<SupplySummary>(emptySupplySummary);
|
||||||
const [isLoading, setIsLoading] = useState(true);
|
const [isLoading, setIsLoading] = useState(true);
|
||||||
const [search, setSearch] = useState('');
|
const [search, setSearch] = useState('');
|
||||||
const [activeTab, setActiveTab] = useState<PurchaseNeedTab>('materials');
|
const requestedTab = searchParams.get('tab');
|
||||||
const [materialFilter, setMaterialFilter] = useState<MaterialPurchaseFilter>('to_buy');
|
const requestedFilter = searchParams.get('filter');
|
||||||
|
const activeTab: PurchaseNeedTab = requestedTab === 'services' || requestedTab === 'mapping' ? requestedTab : 'materials';
|
||||||
|
const materialFilter: MaterialPurchaseFilter = requestedFilter === 'negative_balance' || requestedFilter === 'no_stock' || requestedFilter === 'all' ? requestedFilter : 'to_buy';
|
||||||
const [currentPage, setCurrentPage] = useState(1);
|
const [currentPage, setCurrentPage] = useState(1);
|
||||||
const [itemsPerPage, setItemsPerPage] = useState(10);
|
const [itemsPerPage, setItemsPerPage] = useState(10);
|
||||||
|
|
||||||
@@ -2118,6 +2413,12 @@ const PurchaseNeedsScreen = () => {
|
|||||||
};
|
};
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
const openQueue = (tab: PurchaseNeedTab, filter: MaterialPurchaseFilter = 'to_buy') => {
|
||||||
|
setSearch('');
|
||||||
|
setCurrentPage(1);
|
||||||
|
setSearchParams(tab === 'materials' ? { tab, filter } : { tab });
|
||||||
|
};
|
||||||
|
|
||||||
const materialNeeds = summary.purchaseNeeds.filter(need => !need.missingReference && !isServicePurchaseNeed(need));
|
const materialNeeds = summary.purchaseNeeds.filter(need => !need.missingReference && !isServicePurchaseNeed(need));
|
||||||
const serviceNeeds = summary.purchaseNeeds.filter(need => !need.missingReference && isServicePurchaseNeed(need));
|
const serviceNeeds = summary.purchaseNeeds.filter(need => !need.missingReference && isServicePurchaseNeed(need));
|
||||||
const mappingNeeds = summary.purchaseNeeds.filter(need => need.missingReference);
|
const mappingNeeds = summary.purchaseNeeds.filter(need => need.missingReference);
|
||||||
@@ -2170,7 +2471,7 @@ const PurchaseNeedsScreen = () => {
|
|||||||
<div className="grid grid-cols-1 gap-4 md:grid-cols-4">
|
<div className="grid grid-cols-1 gap-4 md:grid-cols-4">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => { setActiveTab('materials'); setMaterialFilter('to_buy'); setSearch(''); setCurrentPage(1); }}
|
onClick={() => openQueue('materials', 'to_buy')}
|
||||||
className={`rounded-2xl border p-5 text-left shadow-sm transition-colors ${activeTab === 'materials' && materialFilter === 'to_buy' ? 'border-brand-primary/45 bg-brand-primary/[0.07]' : 'border-dark-border bg-dark-card hover:border-brand-primary/35'}`}
|
className={`rounded-2xl border p-5 text-left shadow-sm transition-colors ${activeTab === 'materials' && materialFilter === 'to_buy' ? 'border-brand-primary/45 bg-brand-primary/[0.07]' : 'border-dark-border bg-dark-card hover:border-brand-primary/35'}`}
|
||||||
>
|
>
|
||||||
<p className="text-xs font-bold uppercase tracking-widest text-dark-muted">Materiais para comprar</p>
|
<p className="text-xs font-bold uppercase tracking-widest text-dark-muted">Materiais para comprar</p>
|
||||||
@@ -2184,7 +2485,7 @@ const PurchaseNeedsScreen = () => {
|
|||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => { setActiveTab('materials'); setMaterialFilter('negative_balance'); setSearch(''); setCurrentPage(1); }}
|
onClick={() => openQueue('materials', 'negative_balance')}
|
||||||
className={`rounded-2xl border p-5 text-left shadow-sm transition-colors ${activeTab === 'materials' && materialFilter === 'negative_balance' ? 'border-red-400/45 bg-red-400/[0.07]' : 'border-dark-border bg-dark-card hover:border-red-400/35'}`}
|
className={`rounded-2xl border p-5 text-left shadow-sm transition-colors ${activeTab === 'materials' && materialFilter === 'negative_balance' ? 'border-red-400/45 bg-red-400/[0.07]' : 'border-dark-border bg-dark-card hover:border-red-400/35'}`}
|
||||||
>
|
>
|
||||||
<p className="text-xs font-bold uppercase tracking-widest text-dark-muted">Saldos a conferir</p>
|
<p className="text-xs font-bold uppercase tracking-widest text-dark-muted">Saldos a conferir</p>
|
||||||
@@ -2193,7 +2494,7 @@ const PurchaseNeedsScreen = () => {
|
|||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => { setActiveTab('mapping'); setSearch(''); setCurrentPage(1); }}
|
onClick={() => openQueue('mapping')}
|
||||||
className={`rounded-2xl border p-5 text-left shadow-sm transition-colors ${activeTab === 'mapping' ? 'border-sky-400/45 bg-sky-400/[0.07]' : 'border-dark-border bg-dark-card hover:border-sky-400/35'}`}
|
className={`rounded-2xl border p-5 text-left shadow-sm transition-colors ${activeTab === 'mapping' ? 'border-sky-400/45 bg-sky-400/[0.07]' : 'border-dark-border bg-dark-card hover:border-sky-400/35'}`}
|
||||||
>
|
>
|
||||||
<p className="text-xs font-bold uppercase tracking-widest text-dark-muted">Dados bloqueando o plano</p>
|
<p className="text-xs font-bold uppercase tracking-widest text-dark-muted">Dados bloqueando o plano</p>
|
||||||
@@ -2238,7 +2539,7 @@ const PurchaseNeedsScreen = () => {
|
|||||||
{ id: 'services' as const, label: `Serviços (${serviceNeeds.length})` },
|
{ id: 'services' as const, label: `Serviços (${serviceNeeds.length})` },
|
||||||
{ id: 'mapping' as const, label: `Mapear dados (${mappingNeeds.length})` },
|
{ id: 'mapping' as const, label: `Mapear dados (${mappingNeeds.length})` },
|
||||||
].map(tab => (
|
].map(tab => (
|
||||||
<button key={tab.id} type="button" onClick={() => { setActiveTab(tab.id); setSearch(''); setCurrentPage(1); }} className={`rounded-lg px-3 py-2 text-xs font-bold transition-colors cursor-pointer ${activeTab === tab.id ? 'bg-brand-primary text-brand-contrast' : 'text-dark-muted hover:text-dark-text'}`}>
|
<button key={tab.id} type="button" onClick={() => openQueue(tab.id)} className={`rounded-lg px-3 py-2 text-xs font-bold transition-colors cursor-pointer ${activeTab === tab.id ? 'bg-brand-primary text-brand-contrast' : 'text-dark-muted hover:text-dark-text'}`}>
|
||||||
{tab.label}
|
{tab.label}
|
||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
@@ -2262,7 +2563,7 @@ const PurchaseNeedsScreen = () => {
|
|||||||
<button
|
<button
|
||||||
key={filter.id}
|
key={filter.id}
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => { setMaterialFilter(filter.id); setCurrentPage(1); }}
|
onClick={() => openQueue('materials', filter.id)}
|
||||||
className={`rounded-lg border px-3 py-2 text-left text-xs font-bold transition-colors ${materialFilter === filter.id ? 'border-brand-primary/40 bg-brand-primary/10 text-brand-primary' : 'border-dark-border bg-dark-input text-dark-muted hover:border-brand-primary/35 hover:text-dark-text'}`}
|
className={`rounded-lg border px-3 py-2 text-left text-xs font-bold transition-colors ${materialFilter === filter.id ? 'border-brand-primary/40 bg-brand-primary/10 text-brand-primary' : 'border-dark-border bg-dark-input text-dark-muted hover:border-brand-primary/35 hover:text-dark-text'}`}
|
||||||
title={filter.description}
|
title={filter.description}
|
||||||
>
|
>
|
||||||
|
|||||||
Reference in New Issue
Block a user