110 lines
8.7 KiB
TypeScript
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;
|