Connect supply planning to purchase needs
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 40s

This commit is contained in:
Cauê Faleiros
2026-07-13 12:06:50 -03:00
parent 8320f2ae35
commit 5b6a362bbd
6 changed files with 530 additions and 105 deletions

View File

@@ -20,19 +20,11 @@ import {
Truck,
Warehouse,
} from 'lucide-react';
import { approveSupplyReceipt, createSupplyReceipt, deleteSupplyReceipt, fetchSupplySummary } from '../dataService';
import type { SupplyLot, SupplyMovement, SupplyReceipt, SupplySummary } from '../types';
import { approveSupplyReceipt, 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';
type FabricPlan = {
id: string;
material: string;
color: string;
quantityKg: number;
supplier: string;
priority: string;
};
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';
@@ -44,6 +36,8 @@ const emptySupplySummary: SupplySummary = {
receipts: [],
lots: [],
movements: [],
fabricPlans: [],
purchaseNeeds: [],
stats: {
totalQuantityKg: 0,
activeLots: 0,
@@ -93,17 +87,6 @@ const exportCsv = (filename: string, rows: Array<Record<string, string | number
URL.revokeObjectURL(url);
};
const loadFabricPlans = (): FabricPlan[] => {
try {
const rawPlans = localStorage.getItem('nexstar_fabric_plans');
if (!rawPlans) return [];
const parsed = JSON.parse(rawPlans);
return Array.isArray(parsed) ? parsed : [];
} catch {
return [];
}
};
const inventoryTabs: Array<{ id: InventoryTab; name: string; icon: typeof BarChart3 }> = [
{ id: 'dashboard', name: 'Dashboard', icon: Warehouse },
{ id: 'balance', name: 'Saldo', icon: BarChart3 },
@@ -796,7 +779,7 @@ const InventoryScreen = () => {
};
const FabricPlanningScreen = () => {
const [plans, setPlans] = useState<FabricPlan[]>(loadFabricPlans);
const [plans, setPlans] = useState<SupplyFabricPlan[]>([]);
const [form, setForm] = useState({
material: '',
color: '',
@@ -804,36 +787,84 @@ const FabricPlanningScreen = () => {
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 savePlans = (nextPlans: FabricPlan[]) => {
setPlans(nextPlans);
localStorage.setItem('nexstar_fabric_plans', JSON.stringify(nextPlans));
const loadPlans = async () => {
setErrorMessage('');
const summary = await fetchSupplySummary();
setPlans(summary.fabricPlans);
};
const handleSubmit = (event: FormEvent<HTMLFormElement>) => {
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;
const nextPlan: FabricPlan = {
id: `${Date.now()}`,
material,
color: form.color.trim() || 'Todas as cores',
quantityKg,
supplier: form.supplier.trim() || 'Sem fornecedor',
priority: form.priority,
};
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);
}
};
savePlans([nextPlan, ...plans]);
setForm({ material: '', color: '', quantityKg: '', supplier: '', priority: 'Normal' });
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>
@@ -878,9 +909,9 @@ const FabricPlanningScreen = () => {
Fornecedor
<input value={form.supplier} onChange={(event) => setForm(current => ({ ...current, supplier: event.target.value }))} className={`${inputClassName} mt-1`} placeholder="Opcional" />
</label>
<button type="submit" 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 cursor-pointer">
<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" />
Salvar plano
{isBusy ? 'Salvando...' : 'Salvar plano'}
</button>
</div>
</form>
@@ -890,7 +921,12 @@ const FabricPlanningScreen = () => {
<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>
{plans.length ? (
{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_auto] md:items-center">
@@ -901,7 +937,7 @@ const FabricPlanningScreen = () => {
<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" onClick={() => savePlans(plans.filter(item => item.id !== plan.id))} className="text-sm font-bold text-red-400 transition-colors hover:text-red-300 cursor-pointer">
<button type="button" disabled={isBusy} onClick={() => removePlan(plan.id)} className="text-sm font-bold text-red-400 transition-colors hover:text-red-300 disabled:cursor-not-allowed disabled:opacity-60 cursor-pointer">
Remover
</button>
</div>
@@ -920,42 +956,152 @@ const FabricPlanningScreen = () => {
);
};
const PurchaseNeedsScreen = () => (
<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-3">
{[
{ label: 'Itens críticos', value: '0' },
{ label: 'Compra sugerida', value: '0 kg' },
{ label: 'Fornecedores pendentes', value: '0' },
].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">Calculada com estoque atual, mínimo configurado e consumo dos planos.</p>
</div>
<button type="button" className={buttonClassName}><Download className="h-4 w-4" /> Exportar CSV</button>
const purchaseStatusLabels: Record<SupplyPurchaseNeed['status'], string> = {
critical: 'Crítico',
attention: 'Atenção',
ok: 'Coberto',
};
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 criticalCount = summary.purchaseNeeds.filter(need => need.status === 'critical').length;
const suggestedPurchaseKg = summary.purchaseNeeds.reduce((total, need) => total + need.purchaseKg, 0);
const pendingSupplierCount = new Set(summary.purchaseNeeds.flatMap(need => need.suppliers)).size;
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-3">
{[
{ label: 'Itens críticos', value: `${criticalCount}` },
{ label: 'Compra sugerida', value: `${formatNumber(suggestedPurchaseKg)} kg` },
{ label: 'Fornecedores envolvidos', value: `${pendingSupplierCount}` },
].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={emptyStateClassName}>
<BarChart3 className="h-8 w-8 text-brand-primary" />
<h3 className="text-base font-bold text-dark-text">Nenhuma necessidade de compra</h3>
<p className="text-sm font-semibold text-dark-muted">Configure mínimos e registre estoque para gerar sugestões.</p>
<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,
prioridade: need.priority,
status: purchaseStatusLabels[need.status],
fornecedores: need.suppliers.join(' | '),
cores: need.colors.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>Status</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.colors.length ? need.colors.join(', ') : 'Todas as cores')} · {(need.suppliers.length ? need.suppliers.join(', ') : 'Sem fornecedor')}
</p>
</div>
<p className="text-sm font-bold text-dark-text">{formatNumber(need.plannedKg)} kg</p>
<p className="text-sm font-bold text-dark-text">{formatNumber(need.stockKg)} kg</p>
<p className="text-sm font-bold text-dark-text">{formatNumber(need.pendingKg)} kg</p>
<p className={`text-sm font-bold ${need.purchaseKg > 0 ? 'text-red-300' : 'text-emerald-300'}`}>{formatNumber(need.purchaseKg)} kg</p>
<span className={`w-fit rounded-full border px-2.5 py-1 text-xs font-bold ${
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'
}`}>
{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>
<div className={`${panelClassName} p-5`}>
<h2 className="text-base font-bold text-dark-text">Estoque mínimo por item</h2>
<p className="mt-2 text-sm font-semibold text-dark-muted">Defina o ponto de pedido de cada matéria-prima para aparecer aqui quando ficar abaixo do mínimo.</p>
<button type="button" className={`${buttonClassName} mt-4`}>Configurar estoques mínimos</button>
</div>
</div>
);
);
};
const Supplies = () => {
const { section } = useParams<{ section?: string }>();