Files
graphs/src/pages/ProductionPlans.tsx
2026-08-25 11:53:31 -03:00

110 lines
8.7 KiB
TypeScript

import { useCallback, useEffect, useMemo, useState } from 'react';
import { Link } from 'react-router-dom';
import { ArrowLeft, CheckCircle2, ClipboardList, Factory, Loader2, PlayCircle, type LucideIcon } from 'lucide-react';
import { fetchProductionPlans, updateProductionPlanStatus } from '../dataService';
import type { ProductionOrderStatus, ProductionPlan } from '../types';
const statusOptions: Array<{ value: ProductionOrderStatus; label: string; className: string }> = [
{ value: 'open', label: 'Em aberto', className: 'border-amber-400/35 bg-amber-400/10 text-amber-300' },
{ value: 'cutting', label: 'Em corte', className: 'border-amber-300/35 bg-amber-300/10 text-amber-200' },
{ value: 'sewing', label: 'Em costura', className: 'border-sky-400/35 bg-sky-400/10 text-sky-300' },
{ value: 'review', label: 'Em revisão', className: 'border-violet-400/35 bg-violet-400/10 text-violet-300' },
{ value: 'finished', label: 'Finalizada', className: 'border-emerald-400/35 bg-emerald-400/10 text-emerald-300' },
{ value: 'canceled', label: 'Cancelada', className: 'border-zinc-500/35 bg-zinc-500/10 text-zinc-400' }
];
const statusStyle = (status: string) => statusOptions.find(option => option.value === status)?.className || 'border-dark-border bg-dark-input text-dark-muted';
const formatNumber = (value: number) => new Intl.NumberFormat('pt-BR', { maximumFractionDigits: 2 }).format(value);
const formatDate = (value: string | null) => value ? new Intl.DateTimeFormat('pt-BR').format(new Date(`${value.slice(0, 10)}T00:00:00`)) : '-';
const planMetrics: Array<{ label: string; value: string | number; icon: LucideIcon; tone: string }> = [
{ label: 'Planos ativos', value: 0, icon: ClipboardList, tone: 'text-brand-primary' },
{ label: 'OPs planejadas', value: 0, icon: Factory, tone: 'text-sky-300' },
{ label: 'Unidades em fluxo', value: 0, icon: PlayCircle, tone: 'text-amber-300' }
];
const ProductionPlans = () => {
const [plans, setPlans] = useState<ProductionPlan[]>([]);
const [isLoading, setIsLoading] = useState(true);
const [busyPlanId, setBusyPlanId] = useState<number | null>(null);
const [message, setMessage] = useState('');
const loadPlans = useCallback(async () => {
setIsLoading(true);
try {
setPlans(await fetchProductionPlans());
} catch (error) {
setMessage(error instanceof Error ? error.message : 'Não foi possível carregar os planos.');
} finally {
setIsLoading(false);
}
}, []);
useEffect(() => {
const timer = window.setTimeout(() => { void loadPlans(); }, 0);
return () => window.clearTimeout(timer);
}, [loadPlans]);
const activePlans = useMemo(() => plans.filter(plan => !['finished', 'canceled'].includes(plan.status)), [plans]);
const totalOpenQuantity = useMemo(() => activePlans.reduce((total, plan) => total + plan.totalQuantity, 0), [activePlans]);
const movePlan = async (plan: ProductionPlan, status: ProductionOrderStatus) => {
if (status === plan.status) return;
setBusyPlanId(plan.id);
setMessage('');
try {
const updated = await updateProductionPlanStatus(plan.id, status);
setPlans(current => current.map(item => item.id === updated.id ? updated : item));
setMessage(`${updated.number} movido para ${updated.statusLabel}. As OPs ainda ativas acompanharam o plano.`);
} catch (error) {
setMessage(error instanceof Error ? error.message : 'Não foi possível mover o plano.');
} finally {
setBusyPlanId(null);
}
};
return (
<div className="space-y-6">
<div className="flex flex-col gap-4 lg:flex-row lg:items-end lg:justify-between">
<div>
<Link to="/production-orders" className="mb-3 inline-flex items-center gap-2 text-sm font-bold text-zinc-500 transition-colors hover:text-zinc-900 dark:text-dark-muted dark:hover:text-dark-text"><ArrowLeft className="h-4 w-4" />Ordens de Produção</Link>
<h1 className="text-2xl font-bold text-zinc-900 dark:text-dark-text">Planos de Produção</h1>
<p className="mt-2 font-medium text-zinc-500 dark:text-dark-muted">Controle PCP acima das fichas: cada plano agrupa OPs, materiais reservados e a movimentação da operação.</p>
</div>
<button type="button" onClick={() => void loadPlans()} className="inline-flex h-10 items-center justify-center rounded-xl border border-dark-border bg-dark-card px-4 text-sm font-bold text-dark-text transition-colors hover:border-brand-primary cursor-pointer">Atualizar</button>
</div>
<div className="grid grid-cols-1 gap-4 md:grid-cols-3">
{planMetrics.map(metric => {
const value = metric.label === 'Planos ativos'
? activePlans.length
: metric.label === 'OPs planejadas'
? activePlans.reduce((total, plan) => total + plan.orderCount, 0)
: formatNumber(totalOpenQuantity);
const MetricIcon = metric.icon;
return <div key={metric.label} className="rounded-2xl border border-dark-border bg-dark-card p-5 shadow-sm"><div className="flex items-center justify-between"><p className="text-sm font-bold text-dark-muted">{metric.label}</p><MetricIcon className={`h-5 w-5 ${metric.tone}`} /></div><p className="mt-3 text-3xl font-bold text-dark-text">{value}</p></div>;
})}
</div>
{message && <div className="rounded-xl border border-dark-border bg-dark-card px-4 py-3 text-sm font-bold text-dark-muted">{message}</div>}
<div className="overflow-hidden rounded-2xl border border-dark-border bg-dark-card shadow-sm">
<div className="border-b border-dark-border px-5 py-4"><h2 className="font-bold text-dark-text">Fila de planos</h2><p className="mt-1 text-xs font-semibold text-dark-muted">Mover um plano atualiza todas as OPs locais que continuam ativas; OPs concluídas e canceladas preservam seu histórico.</p></div>
{isLoading ? <div className="flex min-h-56 items-center justify-center text-brand-primary"><Loader2 className="h-7 w-7 animate-spin" /></div> : plans.length ? (
<div className="divide-y divide-dark-border">
{plans.map(plan => (
<div key={plan.id} className="grid gap-4 p-5 xl:grid-cols-[minmax(260px,1.4fr)_minmax(150px,.7fr)_minmax(180px,.7fr)_180px] xl:items-center">
<div className="min-w-0"><div className="flex flex-wrap items-center gap-2"><span className="font-mono text-xs font-bold text-brand-primary">{plan.number}</span><span className={`rounded-full border px-2.5 py-1 text-xs font-bold ${statusStyle(plan.status)}`}>{plan.statusLabel}</span></div><p className="mt-2 truncate font-bold text-dark-text" title={plan.name}>{plan.name}</p><p className="mt-1 text-xs font-semibold text-dark-muted">{plan.contractor ? `Facção: ${plan.contractor}` : 'Sem facção definida'} · previsão {formatDate(plan.expectedDate)}</p>{plan.notes && <p className="mt-1 truncate text-xs font-semibold text-dark-muted" title={plan.notes}>{plan.notes}</p>}</div>
<div className="text-sm"><p className="text-xs font-bold uppercase tracking-wider text-dark-muted">Escopo</p><p className="mt-1 font-bold text-dark-text">{plan.orderCount} {plan.orderCount === 1 ? 'OP' : 'OPs'} · {formatNumber(plan.totalQuantity)} un.</p><p className="mt-1 text-xs font-semibold text-dark-muted">Cortado {formatNumber(plan.actualCutQuantity)} · final {formatNumber(plan.finishedQuantity)}</p></div>
<div className="flex flex-wrap gap-1.5">{statusOptions.filter(option => Number(plan.statusCounts[option.value] || 0) > 0).map(option => <span key={option.value} className={`rounded-full border px-2 py-1 text-xs font-bold ${statusStyle(option.value)}`}>{plan.statusCounts[option.value]} {option.label.toLowerCase()}</span>)}</div>
<label className="text-xs font-bold text-dark-muted">Mover plano<select value={plan.status} disabled={busyPlanId === plan.id} onChange={(event) => void movePlan(plan, event.target.value)} className="mt-1 h-10 w-full rounded-lg border border-dark-border bg-dark-input px-3 text-sm font-bold text-dark-text outline-none focus:border-brand-primary disabled:opacity-60 cursor-pointer"><option value={plan.status}>{busyPlanId === plan.id ? 'Atualizando...' : plan.statusLabel}</option>{statusOptions.filter(option => option.value !== plan.status).map(option => <option key={option.value} value={option.value}>{option.label}</option>)}</select></label>
</div>
))}
</div>
) : <div className="flex min-h-56 flex-col items-center justify-center p-8 text-center"><CheckCircle2 className="h-8 w-8 text-brand-primary" /><h3 className="mt-3 font-bold text-dark-text">Nenhum plano confirmado</h3><p className="mt-1 text-sm font-semibold text-dark-muted">Gere OPs a partir do Plano de Corte para criar o primeiro plano PCP.</p></div>}
</div>
</div>
);
};
export default ProductionPlans;