All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 1m40s
602 lines
29 KiB
TypeScript
602 lines
29 KiB
TypeScript
import { type FormEvent, useCallback, useEffect, useMemo, useState } from 'react';
|
|
import { Link, useOutletContext } from 'react-router-dom';
|
|
import { ArrowLeft, CalendarDays, CheckCircle2, ClipboardList, Clock3, Download, PackageCheck, Search } from 'lucide-react';
|
|
import DateRangePicker from '../components/DateRangePicker';
|
|
import PaginationControls from '../components/PaginationControls';
|
|
import RefreshStatus from '../components/RefreshStatus';
|
|
import { consumeSupplyLotForProduction, exportToCSV, fetchProductionOrders, fetchSupplySummary, updateProductionOrderStatus } from '../dataService';
|
|
import type { DateRange, ProductionOrderItem, ProductionOrderStatus, ProductionOrderSummary, SupplyLot } from '../types';
|
|
|
|
type ProductionOrderStatusTab = 'all' | 'open' | 'in_progress' | 'finished' | 'canceled';
|
|
|
|
const emptySummary: ProductionOrderSummary = {
|
|
orders: [],
|
|
counts: { all: 0, open: 0, in_progress: 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: 'in_progress', label: 'Em andamento', dotClass: 'bg-sky-400' },
|
|
{ key: 'finished', label: 'Finalizada', dotClass: 'bg-emerald-400' },
|
|
{ key: 'canceled', label: 'Cancelada', dotClass: 'bg-zinc-500' }
|
|
];
|
|
|
|
const editableStatusOptions: Array<{ value: ProductionOrderStatusTab; label: string }> = statusTabs
|
|
.filter(tab => tab.key !== 'all')
|
|
.map(tab => ({ value: tab.key, label: tab.label }));
|
|
|
|
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'
|
|
},
|
|
in_progress: {
|
|
label: 'Em andamento',
|
|
className: 'border-sky-400/35 bg-sky-400/10 text-sky-300',
|
|
dotClass: 'bg-sky-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 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 [searchTerm, setSearchTerm] = useState('');
|
|
const [statusFilter, setStatusFilter] = useState<ProductionOrderStatusTab>('all');
|
|
const [summary, setSummary] = useState<ProductionOrderSummary>(emptySummary);
|
|
const [supplyLots, setSupplyLots] = useState<SupplyLot[]>([]);
|
|
const [isLoading, setIsLoading] = useState(true);
|
|
const [isSupplyBusy, setIsSupplyBusy] = useState(false);
|
|
const [supplyMessage, setSupplyMessage] = useState('');
|
|
const [busyStatusOrderId, setBusyStatusOrderId] = useState<number | null>(null);
|
|
const [statusMessage, setStatusMessage] = useState('');
|
|
const [currentPage, setCurrentPage] = useState(1);
|
|
const [itemsPerPage, setItemsPerPage] = useState(20);
|
|
const [exitForm, setExitForm] = useState({
|
|
orderId: '',
|
|
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] = await Promise.all([
|
|
fetchProductionOrders(dateRange, { search: searchTerm }),
|
|
fetchSupplySummary()
|
|
]);
|
|
if (isMounted) {
|
|
setSummary(nextSummary);
|
|
setSupplyLots(nextSupplySummary.lots);
|
|
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.in_progress || 0;
|
|
const finishedCount = summary.counts.finished || 0;
|
|
const totalQuantity = summary.orders.reduce((total, order) => total + order.quantity, 0);
|
|
|
|
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);
|
|
}
|
|
};
|
|
|
|
const refreshSupplyLots = async () => {
|
|
const supplySummary = await fetchSupplySummary();
|
|
setSupplyLots(supplySummary.lots);
|
|
};
|
|
|
|
const selectedOrder = summary.orders.find(order => `${order.id}` === exitForm.orderId);
|
|
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({ orderId: '', 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 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`);
|
|
};
|
|
|
|
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 as ordens locais geradas pelo plano de corte.</p>
|
|
</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(totalQuantity)} un. no período</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>
|
|
|
|
<form onSubmit={handleProductionExit} className="rounded-2xl border border-dark-border bg-dark-card p-5 shadow-sm">
|
|
<div className="flex flex-col gap-2 lg:flex-row lg:items-start lg:justify-between">
|
|
<div>
|
|
<h2 className="text-base font-bold text-dark-text">Baixa de material da OP</h2>
|
|
<p className="mt-1 text-sm font-semibold text-dark-muted">Consome um lote do estoque e registra a saída nas movimentações.</p>
|
|
</div>
|
|
{selectedLot && (
|
|
<span className="w-fit rounded-full border border-dark-border bg-dark-input px-2.5 py-1 text-xs font-bold text-dark-muted">
|
|
Saldo lote #{selectedLot.id}: {formatQuantity(selectedLot.quantity)} {selectedLot.unit}
|
|
</span>
|
|
)}
|
|
</div>
|
|
<div className="mt-4 grid grid-cols-1 gap-3 xl:grid-cols-[1.4fr_1.4fr_120px_1fr_auto]">
|
|
<label className="text-xs font-bold text-dark-muted">
|
|
Ordem de produção
|
|
<select
|
|
value={exitForm.orderId}
|
|
onChange={(event) => setExitForm(current => ({ ...current, orderId: event.target.value }))}
|
|
className="mt-1 h-10 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>
|
|
{summary.orders.filter(order => order.status !== 'finished' && order.status !== 'canceled').map(order => (
|
|
<option key={order.id} value={order.id}>
|
|
{order.number || `#${order.id}`} · {order.productDescription}
|
|
</option>
|
|
))}
|
|
</select>
|
|
</label>
|
|
<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 h-10 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 h-10 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="text-xs font-bold text-dark-muted">
|
|
Motivo
|
|
<input
|
|
value={exitForm.reason}
|
|
onChange={(event) => setExitForm(current => ({ ...current, reason: event.target.value }))}
|
|
className="mt-1 h-10 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>
|
|
<button
|
|
type="submit"
|
|
disabled={isSupplyBusy || !supplyLots.length}
|
|
className="mt-5 inline-flex h-10 items-center justify-center gap-2 rounded-lg border border-dark-border bg-dark-input px-3 text-sm font-bold text-dark-text transition-colors hover:border-brand-primary disabled:cursor-not-allowed disabled:opacity-50"
|
|
>
|
|
<PackageCheck className="h-4 w-4 text-brand-primary" />
|
|
Baixar
|
|
</button>
|
|
</div>
|
|
{supplyMessage && (
|
|
<div className="mt-3 rounded-lg border border-dark-border bg-dark-input px-3 py-2 text-sm font-bold text-dark-muted">
|
|
{supplyMessage}
|
|
</div>
|
|
)}
|
|
</form>
|
|
|
|
<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-[1180px] 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">Pedidos</th>
|
|
<th className="px-6 py-4 text-[10px] font-bold uppercase tracking-wider">Data</th>
|
|
<th className="px-6 py-4 text-[10px] font-bold uppercase tracking-wider">Data Prevista</th>
|
|
<th className="px-6 py-4 text-[10px] font-bold uppercase tracking-wider">SKU / 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">Marcadores</th>
|
|
<th className="px-6 py-4 text-[10px] font-bold uppercase tracking-wider">Integrações</th>
|
|
<th className="px-6 py-4 text-[10px] font-bold uppercase tracking-wider">Status</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 text-xs font-semibold text-dark-muted">{order.orderReference || '-'}</td>
|
|
<td className="px-6 py-3 text-xs font-semibold text-dark-muted">
|
|
<span className="inline-flex items-center gap-1.5">
|
|
<CalendarDays className="h-3.5 w-3.5" />
|
|
{formatDate(order.issueDate)}
|
|
</span>
|
|
</td>
|
|
<td className="px-6 py-3 text-xs font-semibold text-dark-muted">{formatDate(order.expectedDate)}</td>
|
|
<td className="px-6 py-3">
|
|
<div className="font-bold text-dark-text">{order.productDescription}</div>
|
|
<div className="mt-1 font-mono text-[10px] font-semibold text-dark-muted">{order.productSku || 'Sem SKU'}</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">
|
|
{order.markers.length ? (
|
|
<div className="flex max-w-52 flex-wrap gap-1.5">
|
|
{order.markers.map(marker => (
|
|
<span
|
|
key={`${order.id}-${marker.label}`}
|
|
className="inline-flex items-center gap-1 rounded-full border border-dark-border bg-dark-input px-2 py-0.5 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>
|
|
) : (
|
|
<span className="text-xs font-semibold text-dark-muted">-</span>
|
|
)}
|
|
</td>
|
|
<td className="px-6 py-3">
|
|
<span className="inline-flex items-center gap-2 text-xs font-semibold text-dark-muted">
|
|
<span className="h-2 w-2 rounded-full bg-sky-400" />
|
|
{order.integrationStatus || 'Tiny'}
|
|
</span>
|
|
</td>
|
|
<td className="px-6 py-3">
|
|
<label className="sr-only" htmlFor={`production-order-status-${order.id}`}>Status da OP</label>
|
|
<select
|
|
id={`production-order-status-${order.id}`}
|
|
value={order.status}
|
|
disabled={busyStatusOrderId === order.id}
|
|
onChange={(event) => void handleStatusChange(order, event.target.value as ProductionOrderStatusTab)}
|
|
className={`h-8 rounded-lg border bg-dark-input px-2 text-[10px] font-bold uppercase tracking-wide outline-none transition-colors focus:border-brand-primary disabled:cursor-not-allowed disabled:opacity-50 ${statusStyle.className}`}
|
|
>
|
|
{editableStatusOptions.map(option => (
|
|
<option key={option.value} value={option.value}>{option.label}</option>
|
|
))}
|
|
</select>
|
|
</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 de produção encontrada.</p>
|
|
<p className="mt-1 max-w-md text-sm font-medium text-dark-muted">
|
|
Gere ordens pelo Plano de Corte para acompanhar status, produto, quantidade e baixa de material dentro do Graphs.
|
|
</p>
|
|
</div>
|
|
)}
|
|
|
|
<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>
|
|
)}
|
|
</div>
|
|
);
|
|
};
|
|
|
|
export default ProductionOrders;
|