759 lines
62 KiB
TypeScript
759 lines
62 KiB
TypeScript
import { type FormEvent, useCallback, useEffect, useMemo, useState } from 'react';
|
|
import { Link, useNavigate, useOutletContext } from 'react-router-dom';
|
|
import { AlertTriangle, ArrowLeft, CheckCircle2, ChevronDown, ChevronUp, ClipboardList, Clock3, Download, PackageCheck, PlayCircle, Printer, Scissors, Search, Undo2, X, XCircle } from 'lucide-react';
|
|
import DateRangePicker from '../components/DateRangePicker';
|
|
import PaginationControls from '../components/PaginationControls';
|
|
import RefreshStatus from '../components/RefreshStatus';
|
|
import { addProductionOrderDefect, consumeSupplyLotForProduction, exportToCSV, fetchProductionOrders, fetchProductionWorkstations, fetchSupplySummary, updateProductionOrderExecution, updateProductionOrderStatus } from '../dataService';
|
|
import type { DateRange, ProductionOrderItem, ProductionOrderStatus, ProductionOrderSummary, ProductionWorkstation, SupplyLot } from '../types';
|
|
|
|
type ProductionOrderStatusTab = 'all' | 'open' | 'cutting' | 'sewing' | 'review' | 'finished' | 'canceled';
|
|
|
|
const emptySummary: ProductionOrderSummary = {
|
|
orders: [],
|
|
counts: { all: 0, open: 0, cutting: 0, sewing: 0, review: 0, finished: 0, canceled: 0 }
|
|
};
|
|
|
|
const statusTabs: Array<{ key: ProductionOrderStatusTab; label: string; dotClass: string }> = [
|
|
{ key: 'all', label: 'Todas', dotClass: 'bg-dark-muted' },
|
|
{ key: 'open', label: 'Em aberto', dotClass: 'bg-amber-400' },
|
|
{ key: 'cutting', label: 'Em corte', dotClass: 'bg-amber-300' },
|
|
{ key: 'sewing', label: 'Em costura', dotClass: 'bg-sky-400' },
|
|
{ key: 'review', label: 'Em revisão', dotClass: 'bg-violet-400' },
|
|
{ key: 'finished', label: 'Finalizada', dotClass: 'bg-emerald-400' },
|
|
{ key: 'canceled', label: 'Cancelada', dotClass: 'bg-zinc-500' }
|
|
];
|
|
|
|
const editableStatusOptions: Array<{ value: Exclude<ProductionOrderStatusTab, 'all'>; label: string; description: string }> = [
|
|
{ value: 'open', label: 'Em aberto', description: 'Aguardando o início da produção.' },
|
|
{ value: 'cutting', label: 'Em corte', description: 'Os materiais estão sendo transformados em peças cortadas.' },
|
|
{ value: 'sewing', label: 'Em costura', description: 'As peças cortadas estão em montagem.' },
|
|
{ value: 'review', label: 'Em revisão', description: 'A qualidade e as perdas estão sendo conferidas.' },
|
|
{ value: 'finished', label: 'Finalizada', description: 'A produção desta OP foi concluída.' },
|
|
{ value: 'canceled', label: 'Cancelada', description: 'Esta OP não seguirá para produção.' }
|
|
];
|
|
|
|
const statusStyles: Record<string, { label: string; className: string; dotClass: string }> = {
|
|
open: {
|
|
label: 'Em aberto',
|
|
className: 'border-amber-400/35 bg-amber-400/10 text-amber-300',
|
|
dotClass: 'bg-amber-400'
|
|
},
|
|
cutting: {
|
|
label: 'Em corte',
|
|
className: 'border-amber-300/35 bg-amber-300/10 text-amber-200',
|
|
dotClass: 'bg-amber-300'
|
|
},
|
|
sewing: {
|
|
label: 'Em costura',
|
|
className: 'border-sky-400/35 bg-sky-400/10 text-sky-300',
|
|
dotClass: 'bg-sky-400'
|
|
},
|
|
review: {
|
|
label: 'Em revisão',
|
|
className: 'border-violet-400/35 bg-violet-400/10 text-violet-300',
|
|
dotClass: 'bg-violet-400'
|
|
},
|
|
finished: {
|
|
label: 'Finalizada',
|
|
className: 'border-emerald-400/35 bg-emerald-400/10 text-emerald-300',
|
|
dotClass: 'bg-emerald-400'
|
|
},
|
|
canceled: {
|
|
label: 'Cancelada',
|
|
className: 'border-zinc-500/35 bg-zinc-500/10 text-zinc-400',
|
|
dotClass: 'bg-zinc-500'
|
|
}
|
|
};
|
|
|
|
const formatDate = (date: string | null) => {
|
|
if (!date) return '-';
|
|
const parsedDate = new Date(`${date}T00:00:00`);
|
|
if (Number.isNaN(parsedDate.getTime())) return date;
|
|
return new Intl.DateTimeFormat('pt-BR').format(parsedDate);
|
|
};
|
|
|
|
const formatQuantity = (value: number) => (
|
|
new Intl.NumberFormat('pt-BR', {
|
|
minimumFractionDigits: Number.isInteger(value) ? 0 : 2,
|
|
maximumFractionDigits: 4
|
|
}).format(value)
|
|
);
|
|
|
|
const getStatusStyle = (status: ProductionOrderStatus, fallbackLabel: string) => (
|
|
statusStyles[String(status)] || {
|
|
label: fallbackLabel || 'Em aberto',
|
|
className: 'border-dark-border bg-dark-input text-dark-muted',
|
|
dotClass: 'bg-dark-muted'
|
|
}
|
|
);
|
|
|
|
const getNextStage = (status: ProductionOrderStatus): { status: Exclude<ProductionOrderStatusTab, 'all'>; label: string; description: string } | null => {
|
|
switch (status) {
|
|
case 'open': return { status: 'cutting', label: 'Iniciar corte', description: 'Libera o apontamento de corte e a baixa física de materiais.' };
|
|
case 'cutting': return { status: 'sewing', label: 'Avançar para costura', description: 'Use quando o corte desta OP estiver concluído.' };
|
|
case 'sewing': return { status: 'review', label: 'Enviar para revisão', description: 'Use quando as peças estiverem prontas para a conferência final.' };
|
|
case 'review': return { status: 'finished', label: 'Finalizar OP', description: 'Confirme a quantidade aprovada antes de encerrar a ordem.' };
|
|
default: return null;
|
|
}
|
|
};
|
|
|
|
const ProductionOrdersSkeleton = () => (
|
|
<div className="space-y-6" aria-label="Carregando ordens de produção">
|
|
<div className="grid grid-cols-1 gap-4 md:grid-cols-4">
|
|
{[0, 1, 2, 3].map(item => (
|
|
<div key={`production-kpi-skeleton-${item}`} className="rounded-2xl border border-dark-border bg-dark-card p-5 shadow-sm">
|
|
<div className="skeleton h-3 w-28" />
|
|
<div className="skeleton mt-3 h-8 w-20" />
|
|
<div className="skeleton mt-3 h-3 w-36" />
|
|
</div>
|
|
))}
|
|
</div>
|
|
|
|
<div className="rounded-2xl border border-dark-border bg-dark-card shadow-sm">
|
|
<div className="border-b border-dark-border p-5">
|
|
<div className="flex flex-col gap-4 xl:flex-row xl:items-center xl:justify-between">
|
|
<div className="skeleton h-10 w-full max-w-xl" />
|
|
<div className="flex gap-3">
|
|
<div className="skeleton h-10 w-32" />
|
|
<div className="skeleton h-10 w-28" />
|
|
</div>
|
|
</div>
|
|
<div className="mt-5 flex gap-4">
|
|
{[0, 1, 2, 3, 4].map(item => (
|
|
<div key={`production-tab-skeleton-${item}`} className="skeleton h-8 w-28" />
|
|
))}
|
|
</div>
|
|
</div>
|
|
<div className="divide-y divide-dark-border">
|
|
{[0, 1, 2, 3, 4, 5, 6, 7].map(row => (
|
|
<div key={`production-row-skeleton-${row}`} className="grid grid-cols-[90px_120px_120px_1.5fr_110px_180px_110px] gap-5 px-6 py-4">
|
|
{[0, 1, 2, 3, 4, 5, 6].map(column => (
|
|
<div key={`production-cell-skeleton-${row}-${column}`} className="skeleton h-4" />
|
|
))}
|
|
</div>
|
|
))}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
|
|
const ProductionOrders = () => {
|
|
const { dateRange, setDateRange, refreshInterval, setRefreshInterval } = useOutletContext<{
|
|
dateRange: DateRange;
|
|
setDateRange: (range: DateRange) => void;
|
|
refreshInterval: number;
|
|
setRefreshInterval: (interval: number) => void;
|
|
}>();
|
|
const navigate = useNavigate();
|
|
const [searchTerm, setSearchTerm] = useState('');
|
|
const [statusFilter, setStatusFilter] = useState<ProductionOrderStatusTab>('all');
|
|
const [summary, setSummary] = useState<ProductionOrderSummary>(emptySummary);
|
|
const [supplyLots, setSupplyLots] = useState<SupplyLot[]>([]);
|
|
const [workstations, setWorkstations] = useState<ProductionWorkstation[]>([]);
|
|
const [isLoading, setIsLoading] = useState(true);
|
|
const [isSupplyBusy, setIsSupplyBusy] = useState(false);
|
|
const [busyExecutionOrderId, setBusyExecutionOrderId] = useState<number | null>(null);
|
|
const [supplyMessage, setSupplyMessage] = useState('');
|
|
const [executionMessage, setExecutionMessage] = useState('');
|
|
const [busyDefectOrderId, setBusyDefectOrderId] = useState<number | null>(null);
|
|
const [defectMessage, setDefectMessage] = useState('');
|
|
const [busyStatusOrderId, setBusyStatusOrderId] = useState<number | null>(null);
|
|
const [statusMessage, setStatusMessage] = useState('');
|
|
const [selectedOrderId, setSelectedOrderId] = useState<number | null>(null);
|
|
const [statusEditorOrderId, setStatusEditorOrderId] = useState<number | null>(null);
|
|
const [currentPage, setCurrentPage] = useState(1);
|
|
const [itemsPerPage, setItemsPerPage] = useState(20);
|
|
const [exitForm, setExitForm] = useState({
|
|
lotId: '',
|
|
quantity: '',
|
|
reason: '',
|
|
});
|
|
|
|
const loadProductionOrders = useCallback(async (options?: { force?: boolean }) => {
|
|
setIsLoading(true);
|
|
const nextSummary = await fetchProductionOrders(dateRange, { search: searchTerm }, options);
|
|
setSummary(nextSummary);
|
|
setIsLoading(false);
|
|
}, [dateRange, searchTerm]);
|
|
|
|
useEffect(() => {
|
|
let isMounted = true;
|
|
|
|
const load = async () => {
|
|
setIsLoading(true);
|
|
const [nextSummary, nextSupplySummary, nextWorkstations] = await Promise.all([
|
|
fetchProductionOrders(dateRange, { search: searchTerm }),
|
|
fetchSupplySummary(),
|
|
fetchProductionWorkstations()
|
|
]);
|
|
if (isMounted) {
|
|
setSummary(nextSummary);
|
|
setSupplyLots(nextSupplySummary.lots);
|
|
setWorkstations(nextWorkstations);
|
|
setIsLoading(false);
|
|
}
|
|
};
|
|
|
|
void load();
|
|
|
|
return () => {
|
|
isMounted = false;
|
|
};
|
|
}, [dateRange, searchTerm]);
|
|
|
|
useEffect(() => {
|
|
if (refreshInterval === 0) return undefined;
|
|
|
|
const intervalId = setInterval(() => {
|
|
void loadProductionOrders({ force: true });
|
|
}, refreshInterval);
|
|
|
|
return () => clearInterval(intervalId);
|
|
}, [loadProductionOrders, refreshInterval]);
|
|
|
|
const filteredOrders = useMemo(() => {
|
|
if (statusFilter === 'all') return summary.orders;
|
|
return summary.orders.filter(order => order.status === statusFilter);
|
|
}, [statusFilter, summary.orders]);
|
|
|
|
const totalPages = Math.ceil(filteredOrders.length / itemsPerPage);
|
|
const safeCurrentPage = Math.min(currentPage, totalPages || 1);
|
|
const startIndex = (safeCurrentPage - 1) * itemsPerPage;
|
|
const paginatedOrders = filteredOrders.slice(startIndex, startIndex + itemsPerPage);
|
|
const isRefreshing = isLoading && summary.orders.length > 0;
|
|
|
|
const openCount = summary.counts.open || 0;
|
|
const progressCount = (summary.counts.cutting || 0) + (summary.counts.sewing || 0) + (summary.counts.review || 0);
|
|
const finishedCount = summary.counts.finished || 0;
|
|
const finishedQuantity = summary.orders.filter(order => order.status === 'finished').reduce((total, order) => total + (order.finishedQuantity ?? order.quantity), 0);
|
|
const selectedOrder = summary.orders.find(order => order.id === selectedOrderId) || null;
|
|
const selectedOrderNextStage = selectedOrder ? getNextStage(selectedOrder.status) : null;
|
|
const selectedOrderCanReturnToCutPlan = Boolean(selectedOrder && selectedOrder.status === 'open' && (selectedOrder.integrationStatus || '').toLowerCase() === 'local');
|
|
|
|
const handleManualRefresh = () => {
|
|
void loadProductionOrders({ force: true });
|
|
};
|
|
|
|
const handleStatusChange = async (order: ProductionOrderItem, status: ProductionOrderStatusTab) => {
|
|
if (status === 'all' || status === order.status) return;
|
|
|
|
setBusyStatusOrderId(order.id);
|
|
setStatusMessage('');
|
|
try {
|
|
await updateProductionOrderStatus(order.id, status);
|
|
const nextSummary = await fetchProductionOrders(dateRange, { search: searchTerm }, { force: true });
|
|
setSummary(nextSummary);
|
|
setStatusMessage(`OP ${order.number || `#${order.id}`} atualizada para ${statusStyles[status]?.label || status}.`);
|
|
} catch (error) {
|
|
setStatusMessage(error instanceof Error ? error.message : 'Não foi possível atualizar o status da OP.');
|
|
} finally {
|
|
setBusyStatusOrderId(null);
|
|
setStatusEditorOrderId(null);
|
|
}
|
|
};
|
|
|
|
const returnOrderToCutPlan = async (order: ProductionOrderItem) => {
|
|
if (order.status !== 'open' || (order.integrationStatus || '').toLowerCase() !== 'local') return;
|
|
const orderNumber = order.number || `#${order.id}`;
|
|
if (!window.confirm(`Devolver a OP ${orderNumber} ao Plano de Corte?\n\nA OP será cancelada, preservando o histórico. A necessidade voltará para o Plano de Corte e poderá ser gerada novamente quando você decidir.`)) return;
|
|
|
|
setBusyStatusOrderId(order.id);
|
|
setStatusMessage('');
|
|
try {
|
|
await updateProductionOrderStatus(order.id, 'canceled');
|
|
setSelectedOrderId(null);
|
|
setStatusMessage(`OP ${orderNumber} devolvida ao Plano de Corte.`);
|
|
navigate('/supplies/cutting');
|
|
} catch (error) {
|
|
setStatusMessage(error instanceof Error ? error.message : 'Não foi possível devolver a OP ao Plano de Corte.');
|
|
} finally {
|
|
setBusyStatusOrderId(null);
|
|
}
|
|
};
|
|
|
|
const refreshSupplyLots = async () => {
|
|
const supplySummary = await fetchSupplySummary();
|
|
setSupplyLots(supplySummary.lots);
|
|
};
|
|
|
|
const selectedLot = supplyLots.find(lot => `${lot.id}` === exitForm.lotId);
|
|
|
|
const handleProductionExit = async (event: FormEvent<HTMLFormElement>) => {
|
|
event.preventDefault();
|
|
const lotId = Number(exitForm.lotId);
|
|
const quantity = Number(exitForm.quantity.replace(',', '.'));
|
|
if (!lotId || !selectedOrder || !Number.isFinite(quantity) || quantity <= 0) return;
|
|
|
|
setIsSupplyBusy(true);
|
|
setSupplyMessage('');
|
|
try {
|
|
await consumeSupplyLotForProduction(lotId, {
|
|
quantity,
|
|
productionOrderNumber: selectedOrder.number || `${selectedOrder.id}`,
|
|
reason: exitForm.reason.trim() || selectedOrder.productDescription,
|
|
});
|
|
await refreshSupplyLots();
|
|
setExitForm({ lotId: '', quantity: '', reason: '' });
|
|
setSupplyMessage('Saída de material registrada no estoque.');
|
|
} catch (error) {
|
|
setSupplyMessage(error instanceof Error ? error.message : 'Não foi possível baixar o material.');
|
|
} finally {
|
|
setIsSupplyBusy(false);
|
|
}
|
|
};
|
|
|
|
const handleExecutionSave = async (event: FormEvent<HTMLFormElement>, order: ProductionOrderItem) => {
|
|
event.preventDefault();
|
|
const form = new FormData(event.currentTarget);
|
|
const parseOptionalQuantity = (name: string) => {
|
|
const raw = String(form.get(name) || '').trim();
|
|
return raw ? Number(raw.replace(',', '.')) : null;
|
|
};
|
|
|
|
setBusyExecutionOrderId(order.id);
|
|
setExecutionMessage('');
|
|
try {
|
|
await updateProductionOrderExecution(order.id, {
|
|
actualCutQuantity: parseOptionalQuantity('actualCutQuantity'),
|
|
rejectedQuantity: parseOptionalQuantity('rejectedQuantity'),
|
|
finishedQuantity: parseOptionalQuantity('finishedQuantity'),
|
|
qualityNotes: String(form.get('qualityNotes') || '').trim(),
|
|
markerReference: String(form.get('markerReference') || '').trim(),
|
|
});
|
|
const nextSummary = await fetchProductionOrders(dateRange, { search: searchTerm }, { force: true });
|
|
setSummary(nextSummary);
|
|
setExecutionMessage(`Execução da OP ${order.number || `#${order.id}`} registrada.`);
|
|
} catch (error) {
|
|
setExecutionMessage(error instanceof Error ? error.message : 'Não foi possível registrar a execução.');
|
|
} finally {
|
|
setBusyExecutionOrderId(null);
|
|
}
|
|
};
|
|
|
|
const handleDefectSave = async (event: FormEvent<HTMLFormElement>, order: ProductionOrderItem) => {
|
|
event.preventDefault();
|
|
const form = new FormData(event.currentTarget);
|
|
const quantity = Number(String(form.get('quantity') || '').replace(',', '.'));
|
|
const workstationId = Number(form.get('workstationId'));
|
|
if (!workstationId || !Number.isFinite(quantity) || quantity <= 0) return;
|
|
|
|
setBusyDefectOrderId(order.id);
|
|
setDefectMessage('');
|
|
try {
|
|
await addProductionOrderDefect(order.id, {
|
|
workstationId,
|
|
defectType: String(form.get('defectType') || '').trim(),
|
|
origin: String(form.get('origin') || '').trim(),
|
|
quantity,
|
|
lotCode: String(form.get('lotCode') || '').trim(),
|
|
notes: String(form.get('notes') || '').trim(),
|
|
});
|
|
event.currentTarget.reset();
|
|
const nextSummary = await fetchProductionOrders(dateRange, { search: searchTerm }, { force: true });
|
|
setSummary(nextSummary);
|
|
setDefectMessage(`Defeito registrado na OP ${order.number || `#${order.id}`}.`);
|
|
} catch (error) {
|
|
setDefectMessage(error instanceof Error ? error.message : 'Não foi possível registrar o defeito.');
|
|
} finally {
|
|
setBusyDefectOrderId(null);
|
|
}
|
|
};
|
|
|
|
const printProductionSheet = (order: ProductionOrderItem) => {
|
|
const printWindow = window.open('', '_blank', 'noopener,noreferrer');
|
|
if (!printWindow) {
|
|
setStatusMessage('Permita pop-ups para imprimir a ficha de produção.');
|
|
return;
|
|
}
|
|
const componentRows = order.components.map(component => `<tr><td>${component.componentName}</td><td>${formatQuantity(component.totalQuantity)} ${component.unit}</td></tr>`).join('') || '<tr><td colspan="2">Composição não sincronizada.</td></tr>';
|
|
printWindow.document.write(`<!doctype html><html lang="pt-BR"><head><meta charset="utf-8"><title>Ficha ${order.number || order.id}</title><style>body{font-family:Arial,sans-serif;color:#18181b;padding:32px}h1{font-size:24px;margin:0}h2{font-size:14px;text-transform:uppercase;letter-spacing:.08em;margin:28px 0 10px}.meta{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:10px;margin-top:20px}.box{border:1px solid #d4d4d8;border-radius:8px;padding:10px;font-size:13px}.label{display:block;color:#71717a;font-size:10px;font-weight:bold;text-transform:uppercase;margin-bottom:4px}table{border-collapse:collapse;width:100%;font-size:13px}th,td{border:1px solid #d4d4d8;padding:9px;text-align:left}th{background:#f4f4f5}.line{border-bottom:1px solid #a1a1aa;height:28px;margin-top:8px}</style></head><body><h1>Ficha de Produção · ${order.number || `OP #${order.id}`}</h1><p>${order.productDescription}</p><div class="meta"><div class="box"><span class="label">SKU</span>${order.productSku || '-'}</div><div class="box"><span class="label">Quantidade planejada</span>${formatQuantity(order.quantity)} ${order.unit}</div><div class="box"><span class="label">Status</span>${order.statusLabel}</div><div class="box"><span class="label">Referência</span>${order.orderReference || '-'}</div></div><h2>Composição prevista</h2><table><thead><tr><th>Material</th><th>Quantidade</th></tr></thead><tbody>${componentRows}</tbody></table><h2>Apontamento de execução</h2><div class="meta"><div class="box"><span class="label">Quantidade cortada</span><div class="line"></div></div><div class="box"><span class="label">Perdas / defeitos</span><div class="line"></div></div><div class="box"><span class="label">Quantidade aprovada</span><div class="line"></div></div><div class="box"><span class="label">Responsável</span><div class="line"></div></div></div><h2>Observações</h2><div class="line"></div><div class="line"></div></body></html>`);
|
|
printWindow.document.close();
|
|
printWindow.focus();
|
|
printWindow.print();
|
|
};
|
|
|
|
const handleExport = () => {
|
|
const exportData = filteredOrders.map(order => ({
|
|
'Numero': order.number,
|
|
'Pedidos': order.orderReference,
|
|
'Status': order.statusLabel,
|
|
'Data': formatDate(order.issueDate),
|
|
'Data Prevista': formatDate(order.expectedDate),
|
|
'SKU': order.productSku,
|
|
'Descricao': order.productDescription,
|
|
'Quantidade': formatQuantity(order.quantity),
|
|
'Unidade': order.unit,
|
|
'Marcadores': order.markers.map(marker => marker.label).join('; '),
|
|
'Integracao': order.integrationStatus
|
|
}));
|
|
exportToCSV(exportData, `ordens_producao_${new Date().toISOString().split('T')[0]}.csv`);
|
|
};
|
|
|
|
const renderOrderDetails = (order: ProductionOrderItem) => {
|
|
const canRecordOperationalData = ['cutting', 'sewing', 'review'].includes(order.status);
|
|
|
|
return (
|
|
<div className="space-y-6">
|
|
<div className="grid grid-cols-2 gap-3 md:grid-cols-4">
|
|
<div className="rounded-xl border border-dark-border bg-dark-card p-3"><p className="text-[10px] font-bold uppercase tracking-wider text-dark-muted">Quantidade</p><p className="mt-1 text-lg font-bold text-dark-text">{formatQuantity(order.quantity)} <span className="text-xs text-dark-muted">{order.unit}</span></p></div>
|
|
<div className="relative rounded-xl border border-dark-border bg-dark-card p-3"><p className="text-[10px] font-bold uppercase tracking-wider text-dark-muted">Status</p><button type="button" onClick={() => setStatusEditorOrderId(current => current === order.id ? null : order.id)} aria-expanded={statusEditorOrderId === order.id} className={`mt-2 inline-flex cursor-pointer items-center gap-2 rounded-lg border px-2.5 py-1 text-xs font-bold ${getStatusStyle(order.status, order.statusLabel).className}`}><span className={`h-2 w-2 rounded-full ${getStatusStyle(order.status, order.statusLabel).dotClass}`} />{getStatusStyle(order.status, order.statusLabel).label}{statusEditorOrderId === order.id ? <ChevronUp className="h-3.5 w-3.5" /> : <ChevronDown className="h-3.5 w-3.5" />}</button>{statusEditorOrderId === order.id && <div className="absolute left-0 top-full z-20 mt-2 w-64 overflow-hidden rounded-xl border border-dark-border bg-dark-card shadow-xl"><p className="border-b border-dark-border px-3 py-2 text-[10px] font-bold uppercase tracking-wider text-dark-muted">Alterar status</p>{editableStatusOptions.map(option => { const style = getStatusStyle(option.value, option.label); const isCurrent = order.status === option.value; const Icon = option.value === 'finished' ? CheckCircle2 : option.value === 'canceled' ? XCircle : option.value === 'cutting' ? Scissors : option.value === 'sewing' ? PlayCircle : option.value === 'review' ? ClipboardList : Clock3; return <button key={option.value} type="button" disabled={isCurrent || busyStatusOrderId === order.id} onClick={() => void handleStatusChange(order, option.value)} className={`flex w-full cursor-pointer items-center gap-3 px-3 py-2.5 text-left transition-colors disabled:cursor-not-allowed disabled:opacity-60 ${isCurrent ? style.className : 'hover:bg-dark-input/70'}`}><Icon className="h-4 w-4 shrink-0" /><span className="text-xs font-bold text-dark-text">{option.label}{isCurrent && ' · Atual'}</span></button>; })}</div>}</div>
|
|
<div className="rounded-xl border border-dark-border bg-dark-card p-3"><p className="text-[10px] font-bold uppercase tracking-wider text-dark-muted">Emissão</p><p className="mt-1 text-sm font-bold text-dark-text">{formatDate(order.issueDate)}</p></div>
|
|
<div className="rounded-xl border border-dark-border bg-dark-card p-3"><p className="text-[10px] font-bold uppercase tracking-wider text-dark-muted">Previsão</p><p className="mt-1 text-sm font-bold text-dark-text">{formatDate(order.expectedDate)}</p></div>
|
|
</div>
|
|
|
|
{order.status === 'finished' && <section><h3 className="text-xs font-bold uppercase tracking-widest text-dark-muted">Resultado registrado</h3><div className="mt-3 grid gap-3 sm:grid-cols-3"><div className="rounded-xl border border-dark-border bg-dark-card p-3"><p className="text-[10px] font-bold uppercase tracking-wider text-dark-muted">Cortado</p><p className="mt-1 text-lg font-bold text-dark-text">{formatQuantity(order.actualCutQuantity ?? 0)} <span className="text-xs text-dark-muted">{order.unit}</span></p></div><div className="rounded-xl border border-dark-border bg-dark-card p-3"><p className="text-[10px] font-bold uppercase tracking-wider text-dark-muted">Perdas</p><p className="mt-1 text-lg font-bold text-amber-200">{formatQuantity(order.rejectedQuantity || 0)} <span className="text-xs text-dark-muted">{order.unit}</span></p></div><div className="rounded-xl border border-dark-border bg-dark-card p-3"><p className="text-[10px] font-bold uppercase tracking-wider text-dark-muted">Aprovado</p><p className="mt-1 text-lg font-bold text-emerald-300">{formatQuantity(order.finishedQuantity ?? 0)} <span className="text-xs text-dark-muted">{order.unit}</span></p></div></div>{(order.markerReference || order.qualityNotes) && <div className="mt-3 rounded-xl border border-dark-border bg-dark-card p-3 text-xs font-semibold text-dark-muted">{order.markerReference && <p>Referência do risco: <span className="font-bold text-dark-text">{order.markerReference}</span></p>}{order.qualityNotes && <p className={order.markerReference ? 'mt-2' : ''}>{order.qualityNotes}</p>}</div>}</section>}
|
|
|
|
<section>
|
|
<h3 className="text-xs font-bold uppercase tracking-widest text-dark-muted">Produto e marcadores</h3>
|
|
<div className="mt-3 rounded-xl border border-dark-border bg-dark-card p-4">
|
|
<p className="font-mono text-xs font-bold text-dark-muted">{order.productSku || 'Sem SKU'}</p>
|
|
{order.markers.length > 0 && <div className="mt-3 flex flex-wrap gap-1.5">{order.markers.map(marker => <span key={`${order.id}-${marker.label}`} className="inline-flex items-center gap-1.5 rounded-full border border-dark-border bg-dark-input px-2.5 py-1 text-[10px] font-bold text-dark-muted"><span className="h-1.5 w-1.5 rounded-full" style={{ backgroundColor: marker.color || 'var(--color-dark-muted)' }} />{marker.label}</span>)}</div>}
|
|
<div className="mt-4 flex flex-wrap gap-x-5 gap-y-2 text-xs font-semibold text-dark-muted"><span>Pedido: {order.orderReference || '-'}</span><span>Integração: {order.integrationStatus || 'Local'}</span></div>
|
|
</div>
|
|
</section>
|
|
|
|
{canRecordOperationalData && <section>
|
|
<div><h3 className="text-xs font-bold uppercase tracking-widest text-dark-muted">Execução e qualidade</h3><p className="mt-1 text-xs font-semibold text-dark-muted">Registre o corte real, perdas e a quantidade aprovada antes de avançar a OP.</p></div>
|
|
<form onSubmit={(event) => void handleExecutionSave(event, order)} className="mt-3 rounded-xl border border-dark-border bg-dark-card p-4">
|
|
<div className="grid grid-cols-1 gap-3 sm:grid-cols-3">
|
|
<label className="text-xs font-bold text-dark-muted">Cortado real<input name="actualCutQuantity" inputMode="decimal" defaultValue={order.actualCutQuantity ?? ''} className="mt-1.5 h-11 w-full rounded-lg border border-dark-border bg-dark-input px-3 text-sm font-semibold text-dark-text outline-none placeholder:text-dark-muted focus:border-brand-primary" placeholder={formatQuantity(order.quantity)} /></label>
|
|
<label className="text-xs font-bold text-dark-muted">Perdas / defeitos<input name="rejectedQuantity" inputMode="decimal" defaultValue={order.rejectedQuantity || ''} className="mt-1.5 h-11 w-full rounded-lg border border-dark-border bg-dark-input px-3 text-sm font-semibold text-dark-text outline-none placeholder:text-dark-muted focus:border-brand-primary" placeholder="0" /></label>
|
|
<label className="text-xs font-bold text-dark-muted">Aprovado final<input name="finishedQuantity" inputMode="decimal" defaultValue={order.finishedQuantity ?? ''} className="mt-1.5 h-11 w-full rounded-lg border border-dark-border bg-dark-input px-3 text-sm font-semibold text-dark-text outline-none placeholder:text-dark-muted focus:border-brand-primary" placeholder="Após revisão" /></label>
|
|
<label className="text-xs font-bold text-dark-muted sm:col-span-2">Referência do risco<input name="markerReference" defaultValue={order.markerReference} className="mt-1.5 h-11 w-full rounded-lg border border-dark-border bg-dark-input px-3 text-sm font-semibold text-dark-text outline-none placeholder:text-dark-muted focus:border-brand-primary" placeholder="Código ou arquivo do risco" /></label>
|
|
<label className="text-xs font-bold text-dark-muted sm:col-span-3">Motivo de perda / observação<textarea name="qualityNotes" defaultValue={order.qualityNotes} className="mt-1.5 min-h-20 w-full rounded-lg border border-dark-border bg-dark-input px-3 py-2.5 text-sm font-semibold text-dark-text outline-none placeholder:text-dark-muted focus:border-brand-primary" placeholder="Ex.: 4 peças com mancha de tinta; 2 recuperadas na revisão." /></label>
|
|
</div>
|
|
<div className="mt-4 flex flex-wrap items-center justify-between gap-3"><p className="text-xs font-semibold text-dark-muted">Planejado: {formatQuantity(order.quantity)} {order.unit}</p><button type="submit" disabled={busyExecutionOrderId === order.id} 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 disabled:cursor-not-allowed disabled:opacity-50 cursor-pointer"><CheckCircle2 className="h-4 w-4" />{busyExecutionOrderId === order.id ? 'Salvando...' : 'Salvar execução'}</button></div>
|
|
{executionMessage && <p className="mt-3 rounded-lg border border-dark-border bg-dark-input px-3 py-2 text-sm font-bold text-dark-muted">{executionMessage}</p>}
|
|
</form>
|
|
</section>}
|
|
|
|
{(canRecordOperationalData || order.defects.length > 0) && <section>
|
|
<div>
|
|
<h3 className="text-xs font-bold uppercase tracking-widest text-dark-muted">Defeitos e origem</h3>
|
|
<p className="mt-1 text-xs font-semibold text-dark-muted">Registre cada ocorrência para acompanhar perdas por posto, lote e tipo de defeito.</p>
|
|
</div>
|
|
{canRecordOperationalData && <form onSubmit={(event) => void handleDefectSave(event, order)} className="mt-3 rounded-xl border border-dark-border bg-dark-card p-4">
|
|
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2 lg:grid-cols-4">
|
|
<label className="text-xs font-bold text-dark-muted">Posto de trabalho<select required name="workstationId" defaultValue="" className="mt-1.5 h-11 w-full rounded-lg border border-dark-border bg-dark-input px-3 text-sm font-semibold text-dark-text outline-none focus:border-brand-primary"><option value="">Selecione...</option>{workstations.map(workstation => <option key={workstation.id} value={workstation.id}>{workstation.name}</option>)}</select></label>
|
|
<label className="text-xs font-bold text-dark-muted">Tipo de defeito<input required name="defectType" className="mt-1.5 h-11 w-full rounded-lg border border-dark-border bg-dark-input px-3 text-sm font-semibold text-dark-text outline-none placeholder:text-dark-muted focus:border-brand-primary" placeholder="Ex.: furo, mancha" /></label>
|
|
<label className="text-xs font-bold text-dark-muted">Quantidade<input required name="quantity" inputMode="decimal" className="mt-1.5 h-11 w-full rounded-lg border border-dark-border bg-dark-input px-3 text-sm font-semibold text-dark-text outline-none placeholder:text-dark-muted focus:border-brand-primary" placeholder="0" /></label>
|
|
<label className="text-xs font-bold text-dark-muted">Lote (opcional)<input name="lotCode" defaultValue={order.lotCode} className="mt-1.5 h-11 w-full rounded-lg border border-dark-border bg-dark-input px-3 text-sm font-semibold text-dark-text outline-none placeholder:text-dark-muted focus:border-brand-primary" placeholder="Código do lote" /></label>
|
|
<label className="text-xs font-bold text-dark-muted sm:col-span-2">Origem (opcional)<input name="origin" className="mt-1.5 h-11 w-full rounded-lg border border-dark-border bg-dark-input px-3 text-sm font-semibold text-dark-text outline-none placeholder:text-dark-muted focus:border-brand-primary" placeholder="Ex.: recebimento, corte, costura" /></label>
|
|
<label className="text-xs font-bold text-dark-muted sm:col-span-2">Observação (opcional)<input name="notes" className="mt-1.5 h-11 w-full rounded-lg border border-dark-border bg-dark-input px-3 text-sm font-semibold text-dark-text outline-none placeholder:text-dark-muted focus:border-brand-primary" placeholder="Detalhe necessário para a revisão" /></label>
|
|
</div>
|
|
<div className="mt-4 flex flex-wrap items-center justify-between gap-3"><p className="text-xs font-semibold text-dark-muted">{workstations.length ? `${workstations.length} postos ativos disponíveis.` : 'Nenhum posto de trabalho ativo cadastrado.'}</p><button type="submit" disabled={busyDefectOrderId === order.id || !workstations.length} className="inline-flex h-10 items-center gap-2 rounded-lg border border-amber-400/35 bg-amber-400/10 px-4 text-sm font-bold text-amber-200 transition-colors hover:bg-amber-400/20 disabled:cursor-not-allowed disabled:opacity-50 cursor-pointer"><AlertTriangle className="h-4 w-4" />{busyDefectOrderId === order.id ? 'Registrando...' : 'Registrar defeito'}</button></div>
|
|
{defectMessage && <p className="mt-3 rounded-lg border border-dark-border bg-dark-input px-3 py-2 text-sm font-bold text-dark-muted">{defectMessage}</p>}
|
|
</form>}
|
|
{order.defects.length > 0 && <div className="mt-3 overflow-hidden rounded-xl border border-dark-border bg-dark-card"><div className="grid grid-cols-[minmax(0,1fr)_auto] gap-3 border-b border-dark-border bg-dark-input/40 px-4 py-2.5 text-[10px] font-bold uppercase tracking-widest text-dark-muted"><span>Ocorrência</span><span>Quantidade</span></div><div className="divide-y divide-dark-border">{order.defects.map(defect => <div key={defect.id} className="grid grid-cols-[minmax(0,1fr)_auto] gap-3 px-4 py-3"><div><p className="text-sm font-bold text-dark-text">{defect.defectType}</p><p className="mt-1 text-xs font-semibold text-dark-muted">{[defect.workstationName || 'Sem posto', defect.origin, defect.lotCode ? `Lote ${defect.lotCode}` : ''].filter(Boolean).join(' · ')}</p>{defect.notes && <p className="mt-1 text-xs font-medium text-dark-muted">{defect.notes}</p>}</div><p className="text-sm font-bold text-amber-200">{formatQuantity(defect.quantity)} {order.unit}</p></div>)}</div></div>}
|
|
{!order.defects.length && canRecordOperationalData && <p className="mt-3 rounded-xl border border-dark-border bg-dark-card px-4 py-3 text-sm font-semibold text-dark-muted">Nenhum defeito registrado nesta OP.</p>}
|
|
</section>}
|
|
|
|
<section>
|
|
<h3 className="text-xs font-bold uppercase tracking-widest text-dark-muted">Composição</h3>
|
|
{order.components.length ? <div className="mt-3 overflow-hidden rounded-xl border border-dark-border"><table className="w-full text-xs"><thead className="bg-dark-header text-dark-muted"><tr><th className="px-3 py-2.5 text-left font-bold">Material</th><th className="px-3 py-2.5 text-right font-bold">Por unidade</th><th className="px-3 py-2.5 text-right font-bold">Total</th></tr></thead><tbody className="divide-y divide-dark-border">{order.components.map(component => <tr key={component.id}><td className="px-3 py-3"><p className="font-bold text-dark-text">{component.componentName}</p><p className="mt-0.5 font-mono text-[10px] font-semibold text-dark-muted">{component.componentSku || 'Sem SKU'}</p></td><td className="px-3 py-3 text-right font-semibold text-dark-muted">{formatQuantity(component.quantityPerUnit)} {component.unit}</td><td className="px-3 py-3 text-right font-bold text-dark-text">{formatQuantity(component.totalQuantity)} {component.unit}</td></tr>)}</tbody></table></div> : <p className="mt-3 rounded-xl border border-dark-border bg-dark-card px-4 py-3 text-sm font-semibold text-dark-muted">Sem composição sincronizada.</p>}
|
|
</section>
|
|
|
|
{order.lotReservations.length > 0 && <section>
|
|
<h3 className="text-xs font-bold uppercase tracking-widest text-dark-muted">Lotes reservados pelo plano</h3>
|
|
<div className="mt-3 overflow-hidden rounded-xl border border-dark-border"><table className="w-full text-xs"><thead className="bg-dark-header text-dark-muted"><tr><th className="px-3 py-2.5 text-left font-bold">Lote</th><th className="px-3 py-2.5 text-left font-bold">Material</th><th className="px-3 py-2.5 text-right font-bold">Reservado</th><th className="px-3 py-2.5 text-right font-bold">Consumido</th></tr></thead><tbody className="divide-y divide-dark-border">{order.lotReservations.map(reservation => <tr key={reservation.id}><td className="px-3 py-3"><p className="font-bold text-dark-text">#{reservation.lotId}</p><p className="mt-0.5 text-[10px] font-semibold text-dark-muted">{reservation.lotSupplier || 'Sem fornecedor'}</p></td><td className="px-3 py-3"><p className="font-bold text-dark-text">{reservation.lotProduct}</p><p className="mt-0.5 font-mono text-[10px] font-semibold text-dark-muted">{reservation.materialKey}</p></td><td className="px-3 py-3 text-right font-bold text-brand-primary">{formatQuantity(reservation.quantity)} {reservation.lotUnit}</td><td className="px-3 py-3 text-right font-semibold text-dark-muted">{formatQuantity(reservation.consumedQuantity)} {reservation.lotUnit}</td></tr>)}</tbody></table></div>
|
|
</section>}
|
|
|
|
{order.status === 'cutting' && <section>
|
|
<h3 className="text-xs font-bold uppercase tracking-widest text-dark-muted">Baixa de material</h3>
|
|
{order.status === 'cutting' ? <form onSubmit={handleProductionExit} className="mt-3 rounded-xl border border-dark-border bg-dark-card p-4"><p className="text-sm font-semibold text-dark-muted">Registre uma saída somente quando o material for realmente consumido pela OP.</p><div className="mt-4 grid grid-cols-1 gap-3 sm:grid-cols-2"><label className="text-xs font-bold text-dark-muted">Lote de material<select value={exitForm.lotId} onChange={(event) => setExitForm(current => ({ ...current, lotId: event.target.value }))} className="mt-1.5 h-11 w-full rounded-lg border border-dark-border bg-dark-input px-3 text-sm font-semibold text-dark-text outline-none focus:border-brand-primary"><option value="">Selecione...</option>{supplyLots.map(lot => <option key={lot.id} value={lot.id}>#{lot.id} · {lot.product} · {formatQuantity(lot.quantity)} {lot.unit}</option>)}</select></label><label className="text-xs font-bold text-dark-muted">Quantidade<input inputMode="decimal" value={exitForm.quantity} onChange={(event) => setExitForm(current => ({ ...current, quantity: event.target.value }))} className="mt-1.5 h-11 w-full rounded-lg border border-dark-border bg-dark-input px-3 text-sm font-semibold text-dark-text outline-none placeholder:text-dark-muted focus:border-brand-primary" placeholder="kg" /></label><label className="sm:col-span-2 text-xs font-bold text-dark-muted">Motivo (opcional)<input value={exitForm.reason} onChange={(event) => setExitForm(current => ({ ...current, reason: event.target.value }))} className="mt-1.5 h-11 w-full rounded-lg border border-dark-border bg-dark-input px-3 text-sm font-semibold text-dark-text outline-none placeholder:text-dark-muted focus:border-brand-primary" placeholder="Ex.: corte do pedido" /></label></div>{selectedLot && <p className="mt-3 text-xs font-bold text-dark-muted">Saldo do lote #{selectedLot.id}: {formatQuantity(selectedLot.quantity)} {selectedLot.unit}</p>}<button type="submit" disabled={isSupplyBusy || !supplyLots.length || !exitForm.lotId || !exitForm.quantity} className="mt-4 inline-flex h-11 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 disabled:cursor-not-allowed disabled:opacity-50"><PackageCheck className="h-4 w-4" />{isSupplyBusy ? 'Registrando...' : 'Registrar baixa'}</button>{supplyMessage && <p className="mt-3 rounded-lg border border-dark-border bg-dark-input px-3 py-2 text-sm font-bold text-dark-muted">{supplyMessage}</p>}</form> : <div className="mt-3 rounded-xl border border-dark-border bg-dark-card px-4 py-3 text-sm font-semibold text-dark-muted">Altere a OP para <span className="font-bold text-amber-200">Em corte</span> antes de registrar a baixa de materiais.</div>}
|
|
</section>}
|
|
|
|
{(order.steps.length > 0 || order.notes || order.supplier || order.lotCode || order.rollQuantity || order.fabricKg || order.ribKg || order.yieldPiecesPerKg) && <section><h3 className="text-xs font-bold uppercase tracking-widest text-dark-muted">Informações adicionais</h3><div className="mt-3 grid grid-cols-1 gap-3 sm:grid-cols-2">{(order.supplier || order.lotCode || order.rollQuantity || order.fabricKg || order.ribKg || order.yieldPiecesPerKg) && <dl className="grid grid-cols-2 gap-3 rounded-xl border border-dark-border bg-dark-card p-4 text-xs sm:col-span-2">{order.supplier && <div><dt className="font-bold text-dark-muted">Fornecedor</dt><dd className="mt-1 font-bold text-dark-text">{order.supplier}</dd></div>}{order.lotCode && <div><dt className="font-bold text-dark-muted">Lote</dt><dd className="mt-1 font-bold text-dark-text">{order.lotCode}</dd></div>}{order.rollQuantity !== null && <div><dt className="font-bold text-dark-muted">Rolos</dt><dd className="mt-1 font-bold text-dark-text">{formatQuantity(order.rollQuantity)}</dd></div>}{order.fabricKg !== null && <div><dt className="font-bold text-dark-muted">Malha</dt><dd className="mt-1 font-bold text-dark-text">{formatQuantity(order.fabricKg)} kg</dd></div>}{order.ribKg !== null && <div><dt className="font-bold text-dark-muted">Ribana</dt><dd className="mt-1 font-bold text-dark-text">{formatQuantity(order.ribKg)} kg</dd></div>}{order.yieldPiecesPerKg !== null && <div><dt className="font-bold text-dark-muted">Rendimento</dt><dd className="mt-1 font-bold text-dark-text">{formatQuantity(order.yieldPiecesPerKg)} un./kg</dd></div>}</dl>}{order.steps.length > 0 && <div className="rounded-xl border border-dark-border bg-dark-card p-4 sm:col-span-2"><p className="text-xs font-bold text-dark-text">Etapas</p><div className="mt-3 divide-y divide-dark-border">{order.steps.map(step => <div key={step.id} className="flex items-center gap-3 py-2 text-xs"><span className="h-2.5 w-2.5 rounded-full" style={{ backgroundColor: step.color || 'var(--color-brand-primary)' }} /><span className="font-bold text-dark-text">{step.name}</span><span className="ml-auto font-semibold text-dark-muted">{formatDate(step.startDate)} - {formatDate(step.endDate)}</span></div>)}</div></div>}{order.notes && <p className="whitespace-pre-wrap rounded-xl border border-dark-border bg-dark-card p-4 text-sm font-semibold text-dark-muted sm:col-span-2">{order.notes}</p>}</div></section>}
|
|
</div>
|
|
);
|
|
};
|
|
|
|
return (
|
|
<div className="space-y-6">
|
|
<div className="flex flex-col gap-4 xl:flex-row xl:items-center xl:justify-between">
|
|
<div>
|
|
<Link to="/supplies" className="mb-3 inline-flex items-center gap-2 text-sm font-bold text-dark-muted transition-colors hover:text-dark-text">
|
|
<ArrowLeft className="h-4 w-4" />
|
|
Suprimentos
|
|
</Link>
|
|
<h1 className="text-2xl font-bold text-dark-text">Ordens de Produção</h1>
|
|
<p className="mt-2 text-dark-muted font-medium">Acompanhe a fila de produção e o histórico das ordens.</p>
|
|
<div className="mt-3 flex flex-wrap gap-x-4 gap-y-2"><Link to="/production-plans" className="inline-flex items-center gap-2 text-sm font-bold text-brand-primary transition-colors hover:text-brand-primary/80"><ClipboardList className="h-4 w-4" />Ver planos de produção</Link><Link to="/production-quality" className="inline-flex items-center gap-2 text-sm font-bold text-brand-primary transition-colors hover:text-brand-primary/80"><AlertTriangle className="h-4 w-4" />Ver qualidade</Link></div>
|
|
</div>
|
|
<DateRangePicker
|
|
dateRange={dateRange}
|
|
onChange={(range) => {
|
|
setDateRange(range);
|
|
setCurrentPage(1);
|
|
}}
|
|
refreshInterval={refreshInterval}
|
|
setRefreshInterval={setRefreshInterval}
|
|
onManualRefresh={handleManualRefresh}
|
|
/>
|
|
</div>
|
|
|
|
<RefreshStatus isRefreshing={isRefreshing} />
|
|
|
|
{isLoading && !summary.orders.length ? (
|
|
<ProductionOrdersSkeleton />
|
|
) : (
|
|
<div className={isRefreshing ? 'refreshing-content space-y-6' : 'space-y-6'} aria-busy={isRefreshing}>
|
|
<div className="grid grid-cols-1 gap-4 md:grid-cols-4">
|
|
<div className="rounded-2xl border border-dark-border bg-dark-card p-5 shadow-sm">
|
|
<div className="flex items-start justify-between gap-4">
|
|
<div>
|
|
<p className="text-xs font-bold uppercase tracking-widest text-dark-muted">Total no período</p>
|
|
<p className="mt-2 text-3xl font-bold text-dark-text">{summary.counts.all}</p>
|
|
<p className="mt-1 text-xs font-semibold text-dark-muted">Ordens cadastradas</p>
|
|
</div>
|
|
<div className="rounded-xl border border-sky-400/25 bg-sky-400/10 p-3 text-sky-300">
|
|
<ClipboardList className="h-5 w-5" />
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<div className="rounded-2xl border border-dark-border bg-dark-card p-5 shadow-sm">
|
|
<div className="flex items-start justify-between gap-4">
|
|
<div>
|
|
<p className="text-xs font-bold uppercase tracking-widest text-dark-muted">Em aberto</p>
|
|
<p className="mt-2 text-3xl font-bold text-amber-300">{openCount}</p>
|
|
<p className="mt-1 text-xs font-semibold text-dark-muted">Aguardando produção</p>
|
|
</div>
|
|
<div className="rounded-xl border border-amber-400/25 bg-amber-400/10 p-3 text-amber-300">
|
|
<Clock3 className="h-5 w-5" />
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<div className="rounded-2xl border border-dark-border bg-dark-card p-5 shadow-sm">
|
|
<div className="flex items-start justify-between gap-4">
|
|
<div>
|
|
<p className="text-xs font-bold uppercase tracking-widest text-dark-muted">Em andamento</p>
|
|
<p className="mt-2 text-3xl font-bold text-sky-300">{progressCount}</p>
|
|
<p className="mt-1 text-xs font-semibold text-dark-muted">Em processo</p>
|
|
</div>
|
|
<div className="rounded-xl border border-sky-400/25 bg-sky-400/10 p-3 text-sky-300">
|
|
<PackageCheck className="h-5 w-5" />
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<div className="rounded-2xl border border-dark-border bg-dark-card p-5 shadow-sm">
|
|
<div className="flex items-start justify-between gap-4">
|
|
<div>
|
|
<p className="text-xs font-bold uppercase tracking-widest text-dark-muted">Finalizadas</p>
|
|
<p className="mt-2 text-3xl font-bold text-emerald-300">{finishedCount}</p>
|
|
<p className="mt-1 text-xs font-semibold text-dark-muted">{formatQuantity(finishedQuantity)} un. aprovadas</p>
|
|
</div>
|
|
<div className="rounded-xl border border-emerald-400/25 bg-emerald-400/10 p-3 text-emerald-300">
|
|
<CheckCircle2 className="h-5 w-5" />
|
|
</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>
|
|
) : (
|
|
<>
|
|
<div className="overflow-hidden rounded-2xl border border-dark-border bg-dark-card shadow-sm">
|
|
<div className="border-b border-dark-border p-5">
|
|
{statusMessage && (
|
|
<div className="mb-4 rounded-xl border border-dark-border bg-dark-input px-3 py-2 text-sm font-bold text-dark-muted">
|
|
{statusMessage}
|
|
</div>
|
|
)}
|
|
<div className="flex flex-col gap-4 xl:flex-row xl:items-center xl:justify-between">
|
|
<div className="relative w-full xl:max-w-xl">
|
|
<Search className="absolute left-3 top-1/2 h-5 w-5 -translate-y-1/2 text-dark-muted" />
|
|
<input
|
|
type="text"
|
|
value={searchTerm}
|
|
placeholder="Pesquise pelo produto, SKU ou número da ordem"
|
|
onChange={(event) => {
|
|
setSearchTerm(event.target.value);
|
|
setCurrentPage(1);
|
|
}}
|
|
className="w-full rounded-xl border border-dark-border bg-dark-input py-2.5 pl-10 pr-4 text-sm font-semibold text-dark-text shadow-sm transition-colors placeholder:text-dark-muted focus:border-brand-primary focus:outline-none"
|
|
/>
|
|
</div>
|
|
<div className="flex flex-wrap gap-3">
|
|
<button
|
|
type="button"
|
|
onClick={handleExport}
|
|
disabled={!filteredOrders.length}
|
|
className="inline-flex h-10 items-center gap-2 rounded-xl border border-dark-border bg-dark-input px-4 text-sm font-bold text-dark-text transition-colors hover:border-brand-primary disabled:cursor-not-allowed disabled:opacity-50"
|
|
>
|
|
<Download className="h-4 w-4 text-brand-primary" />
|
|
Exportar
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="mt-5 flex gap-5 overflow-x-auto border-b border-dark-border/70 [scrollbar-width:none] [&::-webkit-scrollbar]:hidden">
|
|
{statusTabs.map(tab => {
|
|
const isActive = statusFilter === tab.key;
|
|
const count = summary.counts[tab.key] || 0;
|
|
return (
|
|
<button
|
|
key={tab.key}
|
|
type="button"
|
|
onClick={() => {
|
|
setStatusFilter(tab.key);
|
|
setCurrentPage(1);
|
|
}}
|
|
className={`relative flex min-w-24 cursor-pointer flex-col items-start pb-3 text-left transition-colors ${
|
|
isActive ? 'text-dark-text' : 'text-dark-muted hover:text-dark-text'
|
|
}`}
|
|
>
|
|
<span className="flex items-center gap-2 text-xs font-bold">
|
|
{tab.key !== 'all' && <span className={`h-1.5 w-1.5 rounded-full ${tab.dotClass}`} />}
|
|
{tab.label}
|
|
</span>
|
|
<span className="mt-1 text-sm font-bold">{count}</span>
|
|
{isActive && <span className="absolute bottom-0 left-0 right-0 h-0.5 rounded-full bg-brand-primary" />}
|
|
</button>
|
|
);
|
|
})}
|
|
</div>
|
|
</div>
|
|
|
|
<div className="overflow-x-auto">
|
|
<table className="w-full min-w-[820px] text-left text-sm">
|
|
<thead className="border-b border-dark-border bg-dark-header text-dark-muted">
|
|
<tr>
|
|
<th className="px-6 py-4 text-[10px] font-bold uppercase tracking-wider">Número</th>
|
|
<th className="px-6 py-4 text-[10px] font-bold uppercase tracking-wider">Produto</th>
|
|
<th className="px-6 py-4 text-right text-[10px] font-bold uppercase tracking-wider">Quantidade</th>
|
|
<th className="px-6 py-4 text-[10px] font-bold uppercase tracking-wider">Status</th>
|
|
<th className="px-6 py-4 text-right text-[10px] font-bold uppercase tracking-wider">Ações</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody className="divide-y divide-dark-border">
|
|
{paginatedOrders.map((order: ProductionOrderItem) => {
|
|
const statusStyle = getStatusStyle(order.status, order.statusLabel);
|
|
return (
|
|
<tr key={order.id} className="transition-colors hover:bg-dark-input/50">
|
|
<td className="px-6 py-3 font-mono text-xs font-bold text-dark-text">{order.number || '-'}</td>
|
|
<td className="px-6 py-3">
|
|
<div className="font-bold text-dark-text">{order.productDescription}</div>
|
|
<div className="mt-1 flex flex-wrap items-center gap-x-3 gap-y-1 text-[10px] font-semibold text-dark-muted">
|
|
<span className="font-mono">{order.productSku || 'Sem SKU'}</span>
|
|
<span>{formatDate(order.issueDate)}</span>
|
|
{order.productionPlanNumber && <Link to="/production-plans" className="font-mono text-brand-primary hover:underline">{order.productionPlanNumber}</Link>}
|
|
{order.orderReference && <span>{order.orderReference}</span>}
|
|
</div>
|
|
</td>
|
|
<td className="px-6 py-3 text-right">
|
|
<span className="font-bold text-dark-text">{formatQuantity(order.quantity)}</span>
|
|
<span className="ml-1 text-xs font-semibold text-dark-muted">{order.unit}</span>
|
|
</td>
|
|
<td className="px-6 py-3">
|
|
<span className={`inline-flex min-h-9 items-center gap-2 rounded-lg border px-3 text-xs font-bold ${statusStyle.className}`}>
|
|
<span className={`h-2 w-2 rounded-full ${statusStyle.dotClass}`} />
|
|
{statusStyle.label}
|
|
</span>
|
|
</td>
|
|
<td className="px-6 py-3 text-right">
|
|
<button
|
|
type="button"
|
|
onClick={() => {
|
|
setSelectedOrderId(order.id);
|
|
setSupplyMessage('');
|
|
setExitForm({ lotId: '', quantity: '', reason: '' });
|
|
}}
|
|
title="Abrir ficha da OP"
|
|
aria-label={`Abrir ficha da OP ${order.number || order.id}`}
|
|
className="inline-flex h-9 items-center justify-center gap-2 rounded-lg border border-dark-border bg-dark-input px-3 text-xs font-bold text-dark-muted transition-colors hover:border-brand-primary hover:text-brand-primary cursor-pointer"
|
|
>
|
|
Abrir OP
|
|
<ChevronDown className="h-4 w-4" />
|
|
</button>
|
|
</td>
|
|
</tr>
|
|
);
|
|
})}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
|
|
{!filteredOrders.length && (
|
|
<div className="flex min-h-64 flex-col items-center justify-center border-t border-dark-border px-6 py-10 text-center">
|
|
<div className="rounded-2xl border border-dark-border bg-dark-input p-4 text-brand-primary">
|
|
<ClipboardList className="h-7 w-7" />
|
|
</div>
|
|
<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">
|
|
Ajuste a busca ou o status para ver outra parte da fila de produção.
|
|
</p>
|
|
</div>
|
|
)}
|
|
|
|
{totalPages > 1 && <PaginationControls
|
|
totalItems={filteredOrders.length}
|
|
currentPage={safeCurrentPage}
|
|
totalPages={totalPages}
|
|
pageSize={itemsPerPage}
|
|
pageSizeOptions={[10, 20, 50, 100]}
|
|
itemLabel="ordens"
|
|
pageSizeLabel="ordens por página"
|
|
startIndex={startIndex}
|
|
endIndex={Math.min(startIndex + itemsPerPage, filteredOrders.length)}
|
|
onPageChange={setCurrentPage}
|
|
onPageSizeChange={(pageSize) => {
|
|
setItemsPerPage(pageSize);
|
|
setCurrentPage(1);
|
|
}}
|
|
/>}
|
|
</div>
|
|
</>
|
|
)}
|
|
</div>
|
|
)}
|
|
|
|
{selectedOrder && <div className="fixed inset-0 z-50 flex items-center justify-center bg-black/70 p-4 backdrop-blur-sm" role="dialog" aria-modal="true" aria-label={`Ficha da OP ${selectedOrder.number || selectedOrder.id}`} onClick={() => busyStatusOrderId !== selectedOrder.id && setSelectedOrderId(null)}>
|
|
<section className="flex max-h-[calc(100vh-2rem)] w-full max-w-5xl flex-col overflow-hidden rounded-2xl border border-dark-border bg-dark-card shadow-2xl" onClick={(event) => event.stopPropagation()}>
|
|
<header className="flex items-start justify-between gap-4 border-b border-dark-border px-5 py-4">
|
|
<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">{selectedOrder.number || `OP #${selectedOrder.id}`}</span><span className={`inline-flex items-center gap-2 rounded-full border px-2.5 py-1 text-[10px] font-bold ${getStatusStyle(selectedOrder.status, selectedOrder.statusLabel).className}`}><span className={`h-1.5 w-1.5 rounded-full ${getStatusStyle(selectedOrder.status, selectedOrder.statusLabel).dotClass}`} />{getStatusStyle(selectedOrder.status, selectedOrder.statusLabel).label}</span></div><h2 className="mt-2 truncate text-lg font-bold text-dark-text" title={selectedOrder.productDescription}>{selectedOrder.productDescription}</h2><div className="mt-1 flex flex-wrap items-center gap-x-3 gap-y-1 text-xs font-semibold text-dark-muted"><span className="font-mono">{selectedOrder.productSku || 'Sem SKU'}</span>{selectedOrder.productionPlanNumber && <Link to="/production-plans" className="text-brand-primary hover:underline">{selectedOrder.productionPlanNumber}</Link>}<span>Previsão: {formatDate(selectedOrder.expectedDate)}</span></div></div>
|
|
<div className="flex shrink-0 items-center gap-2"><button type="button" onClick={() => printProductionSheet(selectedOrder)} title="Imprimir ficha" aria-label="Imprimir ficha" className="inline-flex h-9 w-9 items-center justify-center rounded-xl border border-dark-border bg-dark-input text-dark-muted transition-colors hover:border-brand-primary hover:text-brand-primary cursor-pointer"><Printer className="h-4 w-4" /></button><button type="button" onClick={() => setSelectedOrderId(null)} disabled={busyStatusOrderId === selectedOrder.id} title="Fechar" aria-label="Fechar ficha da OP" className="inline-flex h-9 w-9 items-center justify-center rounded-xl border border-dark-border bg-dark-input text-dark-muted transition-colors hover:border-brand-primary hover:text-dark-text disabled:cursor-not-allowed disabled:opacity-60 cursor-pointer"><X className="h-4 w-4" /></button></div>
|
|
</header>
|
|
<div className="overflow-y-auto p-5">
|
|
{statusMessage && <div className="mb-4 rounded-xl border border-dark-border bg-dark-input px-3 py-2 text-sm font-bold text-dark-muted">{statusMessage}</div>}
|
|
{selectedOrderNextStage ? <section className="flex flex-col gap-3 rounded-xl border border-brand-primary/25 bg-brand-primary/[0.055] p-4 sm:flex-row sm:items-center sm:justify-between"><div><p className="text-[10px] font-bold uppercase tracking-wider text-brand-primary">Próxima ação</p><p className="mt-1 text-sm font-bold text-dark-text">{selectedOrderNextStage.label}</p><p className="mt-1 text-xs font-semibold text-dark-muted">{selectedOrderNextStage.description}</p></div><button type="button" onClick={() => void handleStatusChange(selectedOrder, selectedOrderNextStage.status)} disabled={busyStatusOrderId === selectedOrder.id} className="inline-flex h-10 shrink-0 items-center justify-center gap-2 rounded-xl bg-brand-primary px-4 text-sm font-bold text-brand-contrast transition-colors hover:bg-brand-primary/90 disabled:cursor-not-allowed disabled:opacity-60 cursor-pointer">{busyStatusOrderId === selectedOrder.id ? 'Atualizando...' : selectedOrderNextStage.label}<ChevronDown className="h-4 w-4 -rotate-90" /></button></section> : <section className="rounded-xl border border-dark-border bg-dark-input/45 px-4 py-3 text-sm font-semibold text-dark-muted">{selectedOrder.status === 'finished' ? 'Esta OP foi finalizada. Os registros abaixo permanecem disponíveis para consulta.' : 'Esta OP foi cancelada e não possui próxima etapa operacional.'}</section>}
|
|
<div className="mt-5">{renderOrderDetails(selectedOrder)}</div>
|
|
</div>
|
|
<footer className="flex flex-col-reverse gap-2 border-t border-dark-border px-5 py-4 sm:flex-row sm:items-center sm:justify-between"><div>{selectedOrderCanReturnToCutPlan && <button type="button" onClick={() => void returnOrderToCutPlan(selectedOrder)} disabled={busyStatusOrderId === selectedOrder.id} className="inline-flex h-10 items-center gap-2 rounded-xl border border-dark-border bg-dark-input px-3 text-sm font-bold text-dark-muted transition-colors hover:border-amber-400/45 hover:text-amber-200 disabled:cursor-not-allowed disabled:opacity-60 cursor-pointer"><Undo2 className="h-4 w-4" />Devolver ao Plano de Corte</button>}</div><button type="button" onClick={() => setSelectedOrderId(null)} disabled={busyStatusOrderId === selectedOrder.id} className="h-10 rounded-xl border border-dark-border bg-dark-input px-4 text-sm font-bold text-dark-text transition-colors hover:border-brand-primary disabled:cursor-not-allowed disabled:opacity-60 cursor-pointer">Fechar</button></footer>
|
|
</section>
|
|
</div>}
|
|
|
|
</div>
|
|
);
|
|
};
|
|
|
|
export default ProductionOrders;
|