Add production order generation preview
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 1m20s
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 1m20s
This commit is contained in:
@@ -143,6 +143,7 @@ const Cutting = () => {
|
|||||||
const [currentPage, setCurrentPage] = useState(1);
|
const [currentPage, setCurrentPage] = useState(1);
|
||||||
const [itemsPerPage, setItemsPerPage] = useState(10);
|
const [itemsPerPage, setItemsPerPage] = useState(10);
|
||||||
const [isGeneratingOrders, setIsGeneratingOrders] = useState(false);
|
const [isGeneratingOrders, setIsGeneratingOrders] = useState(false);
|
||||||
|
const [isOrderPreviewOpen, setIsOrderPreviewOpen] = useState(false);
|
||||||
const [generationMessage, setGenerationMessage] = useState('');
|
const [generationMessage, setGenerationMessage] = useState('');
|
||||||
const cutConfigSection = searchParams.get('config');
|
const cutConfigSection = searchParams.get('config');
|
||||||
const targetCorrectionSku = (searchParams.get('sku') || '').trim();
|
const targetCorrectionSku = (searchParams.get('sku') || '').trim();
|
||||||
@@ -254,6 +255,10 @@ const Cutting = () => {
|
|||||||
filteredRows.filter(row => row.suggestedCutQuantity > 0)
|
filteredRows.filter(row => row.suggestedCutQuantity > 0)
|
||||||
), [filteredRows]);
|
), [filteredRows]);
|
||||||
|
|
||||||
|
const orderPreviewTotalUnits = useMemo(() => (
|
||||||
|
rowsAvailableForOrder.reduce((total, row) => total + row.suggestedCutQuantity, 0)
|
||||||
|
), [rowsAvailableForOrder]);
|
||||||
|
|
||||||
const totalPages = Math.ceil(filteredRows.length / itemsPerPage);
|
const totalPages = Math.ceil(filteredRows.length / itemsPerPage);
|
||||||
const safeCurrentPage = Math.min(currentPage, totalPages || 1);
|
const safeCurrentPage = Math.min(currentPage, totalPages || 1);
|
||||||
const startIndex = (safeCurrentPage - 1) * itemsPerPage;
|
const startIndex = (safeCurrentPage - 1) * itemsPerPage;
|
||||||
@@ -302,11 +307,15 @@ const Cutting = () => {
|
|||||||
}, [correctionItemsPerPage, correctionRows, cutConfigSection, targetCorrectionSku]);
|
}, [correctionItemsPerPage, correctionRows, cutConfigSection, targetCorrectionSku]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!isSettingsOpen) return undefined;
|
if (!isSettingsOpen && !isOrderPreviewOpen) return undefined;
|
||||||
|
|
||||||
const closeOnEscape = (event: KeyboardEvent) => {
|
const closeOnEscape = (event: KeyboardEvent) => {
|
||||||
if (event.key === 'Escape') {
|
if (event.key === 'Escape') {
|
||||||
setIsSettingsOpen(false);
|
if (isOrderPreviewOpen) {
|
||||||
|
setIsOrderPreviewOpen(false);
|
||||||
|
} else {
|
||||||
|
setIsSettingsOpen(false);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -317,7 +326,7 @@ const Cutting = () => {
|
|||||||
document.body.style.overflow = previousOverflow;
|
document.body.style.overflow = previousOverflow;
|
||||||
document.removeEventListener('keydown', closeOnEscape);
|
document.removeEventListener('keydown', closeOnEscape);
|
||||||
};
|
};
|
||||||
}, [isSettingsOpen]);
|
}, [isOrderPreviewOpen, isSettingsOpen]);
|
||||||
|
|
||||||
const updateFamilyYield = (familyKey: CutFamilyKey, value: string) => {
|
const updateFamilyYield = (familyKey: CutFamilyKey, value: string) => {
|
||||||
const parsedValue = Number(value);
|
const parsedValue = Number(value);
|
||||||
@@ -413,6 +422,16 @@ const Cutting = () => {
|
|||||||
})), `plano_corte_${new Date().toISOString().split('T')[0]}.csv`);
|
})), `plano_corte_${new Date().toISOString().split('T')[0]}.csv`);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const openProductionOrderPreview = () => {
|
||||||
|
if (!rowsAvailableForOrder.length) {
|
||||||
|
setGenerationMessage('Não há necessidade de corte no filtro atual.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setGenerationMessage('');
|
||||||
|
setIsOrderPreviewOpen(true);
|
||||||
|
};
|
||||||
|
|
||||||
const generateProductionOrders = async () => {
|
const generateProductionOrders = async () => {
|
||||||
if (!rowsAvailableForOrder.length) {
|
if (!rowsAvailableForOrder.length) {
|
||||||
setGenerationMessage('Não há necessidade de corte no filtro atual.');
|
setGenerationMessage('Não há necessidade de corte no filtro atual.');
|
||||||
@@ -460,6 +479,7 @@ const Cutting = () => {
|
|||||||
const refreshedOrders = await fetchProductionOrders(allProductionOrdersRange, undefined, { force: true });
|
const refreshedOrders = await fetchProductionOrders(allProductionOrdersRange, undefined, { force: true });
|
||||||
setProductionOrders(refreshedOrders.orders);
|
setProductionOrders(refreshedOrders.orders);
|
||||||
setGenerationMessage(`${result.created.length} OPs criadas${result.skipped.length ? ` · ${result.skipped.length} já existiam abertas` : ''}.`);
|
setGenerationMessage(`${result.created.length} OPs criadas${result.skipped.length ? ` · ${result.skipped.length} já existiam abertas` : ''}.`);
|
||||||
|
setIsOrderPreviewOpen(false);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
setGenerationMessage(error instanceof Error ? error.message : 'Não foi possível gerar as ordens de produção.');
|
setGenerationMessage(error instanceof Error ? error.message : 'Não foi possível gerar as ordens de produção.');
|
||||||
} finally {
|
} finally {
|
||||||
@@ -513,13 +533,13 @@ const Cutting = () => {
|
|||||||
|
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => void generateProductionOrders()}
|
onClick={openProductionOrderPreview}
|
||||||
disabled={isGeneratingOrders || !rowsAvailableForOrder.length}
|
disabled={isGeneratingOrders || !rowsAvailableForOrder.length}
|
||||||
className="flex items-center justify-center gap-2 rounded-xl border border-brand-primary/30 bg-brand-primary/15 px-4 py-2.5 text-sm font-medium text-brand-primary shadow-sm transition-colors hover:border-brand-primary disabled:cursor-not-allowed disabled:opacity-50 cursor-pointer"
|
className="flex items-center justify-center gap-2 rounded-xl border border-brand-primary/30 bg-brand-primary/15 px-4 py-2.5 text-sm font-medium text-brand-primary shadow-sm transition-colors hover:border-brand-primary disabled:cursor-not-allowed disabled:opacity-50 cursor-pointer"
|
||||||
title="Gerar ordens locais para as necessidades do filtro atual"
|
title="Revisar ordens locais antes de gerar"
|
||||||
>
|
>
|
||||||
<ClipboardList size={16} />
|
<ClipboardList size={16} />
|
||||||
<span className="hidden sm:inline">{isGeneratingOrders ? 'Gerando' : 'Gerar OPs'}</span>
|
<span className="hidden sm:inline">{isGeneratingOrders ? 'Gerando' : 'Prévia OPs'}</span>
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<button
|
<button
|
||||||
@@ -552,6 +572,128 @@ const Cutting = () => {
|
|||||||
|
|
||||||
<RefreshStatus isRefreshing={isRefreshing} />
|
<RefreshStatus isRefreshing={isRefreshing} />
|
||||||
|
|
||||||
|
{isOrderPreviewOpen && (
|
||||||
|
<div
|
||||||
|
className="fixed inset-0 z-50 flex h-dvh items-center justify-center overflow-hidden bg-zinc-950/80 p-4 backdrop-blur-md dark:bg-black/75"
|
||||||
|
role="dialog"
|
||||||
|
aria-modal="true"
|
||||||
|
aria-label="Prévia de ordens de produção"
|
||||||
|
onClick={() => {
|
||||||
|
if (!isGeneratingOrders) setIsOrderPreviewOpen(false);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className="flex max-h-[90dvh] w-full max-w-6xl flex-col overflow-hidden rounded-2xl border border-dark-border bg-dark-card shadow-2xl"
|
||||||
|
onClick={(event) => event.stopPropagation()}
|
||||||
|
>
|
||||||
|
<div className="flex flex-col gap-4 border-b border-dark-border p-5 md:flex-row md:items-start md:justify-between">
|
||||||
|
<div>
|
||||||
|
<h2 className="text-lg font-bold text-dark-text">Prévia das OPs</h2>
|
||||||
|
<p className="mt-1 text-sm font-semibold text-dark-muted">
|
||||||
|
Serão criadas ordens locais para as necessidades do filtro atual.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setIsOrderPreviewOpen(false)}
|
||||||
|
disabled={isGeneratingOrders}
|
||||||
|
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-50 cursor-pointer"
|
||||||
|
title="Fechar"
|
||||||
|
aria-label="Fechar prévia"
|
||||||
|
>
|
||||||
|
<X className="h-4 w-4" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 gap-3 border-b border-dark-border p-4 md:grid-cols-3">
|
||||||
|
<div className="rounded-xl border border-dark-border bg-dark-input/60 p-3">
|
||||||
|
<p className="text-[10px] font-bold uppercase tracking-widest text-dark-muted">OPs previstas</p>
|
||||||
|
<p className="mt-2 text-2xl font-bold text-dark-text">{formatNumber(rowsAvailableForOrder.length)}</p>
|
||||||
|
</div>
|
||||||
|
<div className="rounded-xl border border-dark-border bg-dark-input/60 p-3">
|
||||||
|
<p className="text-[10px] font-bold uppercase tracking-widest text-dark-muted">Unidades totais</p>
|
||||||
|
<p className="mt-2 text-2xl font-bold text-red-300">{formatNumber(orderPreviewTotalUnits)} un.</p>
|
||||||
|
</div>
|
||||||
|
<div className="rounded-xl border border-dark-border bg-dark-input/60 p-3">
|
||||||
|
<p className="text-[10px] font-bold uppercase tracking-widest text-dark-muted">Cobertura alvo</p>
|
||||||
|
<p className="mt-2 text-2xl font-bold text-brand-primary">{formatNumber(targetCoverageDays)} dias</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="overflow-auto">
|
||||||
|
<table className="w-full min-w-[980px] text-left text-sm">
|
||||||
|
<thead className="border-b border-dark-border bg-dark-header text-dark-muted">
|
||||||
|
<tr>
|
||||||
|
<th className="px-4 py-3 text-[10px] font-bold uppercase tracking-wider">SKU</th>
|
||||||
|
<th className="px-4 py-3 text-[10px] font-bold uppercase tracking-wider">Produto</th>
|
||||||
|
<th className="px-4 py-3 text-[10px] font-bold uppercase tracking-wider">Família</th>
|
||||||
|
<th className="px-4 py-3 text-[10px] font-bold uppercase tracking-wider">Variação</th>
|
||||||
|
<th className="px-4 py-3 text-[10px] font-bold uppercase tracking-wider">Necessidade</th>
|
||||||
|
<th className="px-4 py-3 text-[10px] font-bold uppercase tracking-wider">Dados</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody className="divide-y divide-dark-border">
|
||||||
|
{rowsAvailableForOrder.map(row => (
|
||||||
|
<tr key={row.id} className="hover:bg-dark-input/50">
|
||||||
|
<td className="px-4 py-3 font-mono text-[11px] text-dark-muted">#{row.id}</td>
|
||||||
|
<td className="max-w-0 px-4 py-3">
|
||||||
|
<div className="truncate font-bold text-dark-text" title={row.name}>{row.name}</div>
|
||||||
|
<div className="mt-1 text-[10px] font-semibold text-dark-muted">
|
||||||
|
Média: {formatNumber(row.dailySales, 2)} un./dia · Estoque: {formatNumber(row.stock)} un.
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3">
|
||||||
|
<span className={`inline-flex whitespace-nowrap rounded-full border px-2.5 py-1 text-xs font-bold ${familyStyles[row.family.key]}`}>
|
||||||
|
{row.family.materialLabel}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3">
|
||||||
|
<div className="flex min-w-0 items-center gap-1.5">
|
||||||
|
<ProductColorBadge label={row.color} className="max-w-[110px] px-2" />
|
||||||
|
<span className="inline-flex items-center gap-1 rounded-full border border-emerald-400/25 bg-emerald-400/10 px-2 py-1 text-xs font-bold text-emerald-300">
|
||||||
|
<Ruler className="h-3 w-3" />
|
||||||
|
{row.size || '-'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3 font-bold whitespace-nowrap text-red-300">
|
||||||
|
{formatNumber(row.suggestedCutQuantity)} un.
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3">{renderIssueBadge(row)}</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-col gap-3 border-t border-dark-border p-4 sm:flex-row sm:items-center sm:justify-between">
|
||||||
|
<p className="text-xs font-semibold text-dark-muted">
|
||||||
|
OPs abertas já existentes para o mesmo plano e SKU serão ignoradas.
|
||||||
|
</p>
|
||||||
|
<div className="flex flex-col gap-2 sm:flex-row sm:justify-end">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setIsOrderPreviewOpen(false)}
|
||||||
|
disabled={isGeneratingOrders}
|
||||||
|
className="inline-flex items-center justify-center rounded-xl border border-dark-border bg-dark-input px-4 py-2 text-sm font-bold text-dark-text transition-colors hover:border-brand-primary disabled:cursor-not-allowed disabled:opacity-50 cursor-pointer"
|
||||||
|
>
|
||||||
|
Cancelar
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => void generateProductionOrders()}
|
||||||
|
disabled={isGeneratingOrders}
|
||||||
|
className="inline-flex items-center justify-center gap-2 rounded-xl border border-brand-primary/30 bg-brand-primary/15 px-4 py-2 text-sm font-bold text-brand-primary transition-colors hover:border-brand-primary disabled:cursor-not-allowed disabled:opacity-50 cursor-pointer"
|
||||||
|
>
|
||||||
|
<ClipboardList className="h-4 w-4" />
|
||||||
|
{isGeneratingOrders ? 'Gerando OPs' : 'Confirmar geração'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{isSettingsOpen && (
|
{isSettingsOpen && (
|
||||||
<div
|
<div
|
||||||
className="fixed inset-0 z-50 flex h-dvh justify-end overflow-hidden bg-zinc-950/80 backdrop-blur-md dark:bg-black/75"
|
className="fixed inset-0 z-50 flex h-dvh justify-end overflow-hidden bg-zinc-950/80 backdrop-blur-md dark:bg-black/75"
|
||||||
|
|||||||
Reference in New Issue
Block a user