Add production execution workflow

This commit is contained in:
Cauê Faleiros
2026-08-25 10:44:39 -03:00
parent 713cc4a9fc
commit a226e6d9f4
8 changed files with 232 additions and 24 deletions

View File

@@ -488,6 +488,14 @@ const initDB = async () => {
ADD COLUMN IF NOT EXISTS completed_date DATE;
`).catch(() => {});
await pool.query(`
ALTER TABLE production_orders
ADD COLUMN IF NOT EXISTS actual_cut_quantity NUMERIC(14, 4),
ADD COLUMN IF NOT EXISTS rejected_quantity NUMERIC(14, 4) NOT NULL DEFAULT 0,
ADD COLUMN IF NOT EXISTS finished_quantity NUMERIC(14, 4),
ADD COLUMN IF NOT EXISTS quality_notes TEXT;
`).catch(() => {});
await pool.query(`
CREATE TABLE IF NOT EXISTS production_order_import_runs (
id SERIAL PRIMARY KEY,

View File

@@ -1,6 +1,6 @@
const express = require('express');
const { authenticateAPIKey, verifyToken } = require('../auth');
const { createProductionOrders, importTinyProductCompositionExport, listProductionOrders, updateProductionOrderStatus, upsertTinyProductionOrderDetail } = require('../services/productionOrderService');
const { createProductionOrders, importTinyProductCompositionExport, listProductionOrders, updateProductionOrderExecution, updateProductionOrderStatus, upsertTinyProductionOrderDetail } = require('../services/productionOrderService');
const router = express.Router();
@@ -50,4 +50,13 @@ router.patch('/production-orders/:id/status', verifyToken, async (req, res) => {
}
});
router.patch('/production-orders/:id/execution', verifyToken, async (req, res) => {
try {
res.json(await updateProductionOrderExecution(req.params.id, req.body || {}));
} catch (error) {
console.error('Error updating production order execution:', error);
res.status(error.statusCode || 500).json({ error: error.message || 'Internal Server Error' });
}
});
module.exports = router;

View File

@@ -2,7 +2,9 @@ const { pool } = require('../db');
const STATUS_LABELS = {
open: 'Em aberto',
in_progress: 'Em andamento',
cutting: 'Em corte',
sewing: 'Em costura',
review: 'Em revisão',
finished: 'Finalizada',
canceled: 'Cancelada'
};
@@ -10,7 +12,9 @@ const STATUS_LABELS = {
const normalizeStatus = (status) => {
const normalizedStatus = String(status || 'open').trim().toLowerCase();
if (['open', 'em_aberto', 'em aberto', 'aberta'].includes(normalizedStatus)) return 'open';
if (['in_progress', 'andamento', 'em andamento'].includes(normalizedStatus)) return 'in_progress';
if (['in_progress', 'andamento', 'em andamento', 'em corte', 'corte', 'cutting'].includes(normalizedStatus)) return 'cutting';
if (['em costura', 'costura', 'sewing'].includes(normalizedStatus)) return 'sewing';
if (['em revisao', 'em revisão', 'revisao', 'revisão', 'review'].includes(normalizedStatus)) return 'review';
if (['finished', 'finalizada', 'finalizado'].includes(normalizedStatus)) return 'finished';
if (['canceled', 'cancelada', 'cancelado', 'cancelled'].includes(normalizedStatus)) return 'canceled';
return normalizedStatus || 'open';
@@ -60,6 +64,15 @@ const normalizeNullableQuantity = (value) => {
return quantity || null;
};
const normalizeNonNegativeQuantity = (value) => {
if (value === '' || value === null || value === undefined) return null;
const normalizedValue = typeof value === 'string' && value.includes(',')
? value.replace(/\./g, '').replace(',', '.')
: value;
const quantity = Number(normalizedValue);
return Number.isFinite(quantity) && quantity >= 0 ? quantity : null;
};
const normalizeInteger = (value) => {
const number = Number(value);
return Number.isInteger(number) ? number : null;
@@ -267,6 +280,10 @@ const mapProductionOrderRow = (row) => {
fabricKg: row.fabric_kg === null ? null : Number(row.fabric_kg),
ribKg: row.rib_kg === null ? null : Number(row.rib_kg),
yieldPiecesPerKg: row.yield_pieces_per_kg === null ? null : Number(row.yield_pieces_per_kg),
actualCutQuantity: row.actual_cut_quantity === null ? null : Number(row.actual_cut_quantity),
rejectedQuantity: Number(row.rejected_quantity || 0),
finishedQuantity: row.finished_quantity === null ? null : Number(row.finished_quantity),
qualityNotes: row.quality_notes || '',
markers: Array.isArray(row.markers) ? row.markers.filter(Boolean) : [],
components: Array.isArray(row.components) ? row.components.map(mapComponent) : [],
steps: Array.isArray(row.steps) ? row.steps.map(mapStep) : [],
@@ -298,6 +315,10 @@ const getOrderById = async (id, client = pool) => {
po.fabric_kg,
po.rib_kg,
po.yield_pieces_per_kg,
po.actual_cut_quantity,
po.rejected_quantity,
po.finished_quantity,
po.quality_notes,
po.created_at,
po.updated_at,
COALESCE(
@@ -381,6 +402,10 @@ const baseProductionOrderSelect = `
po.fabric_kg,
po.rib_kg,
po.yield_pieces_per_kg,
po.actual_cut_quantity,
po.rejected_quantity,
po.finished_quantity,
po.quality_notes,
po.created_at,
po.updated_at,
COALESCE(
@@ -482,7 +507,9 @@ const listProductionOrders = async (filters = {}) => {
}, {
all: 0,
open: 0,
in_progress: 0,
cutting: 0,
sewing: 0,
review: 0,
finished: 0,
canceled: 0
});
@@ -514,7 +541,7 @@ const createProductionOrders = async (orders = []) => {
FROM production_orders
WHERE COALESCE(order_reference, '') = $1
AND COALESCE(product_sku, '') = $2
AND status IN ('open', 'in_progress')
AND status IN ('open', 'in_progress', 'cutting', 'sewing', 'review')
LIMIT 1;
`, [orderReference, productSku]);
@@ -1304,7 +1331,13 @@ const updateProductionOrderStatus = async (id, status) => {
const result = await pool.query(`
UPDATE production_orders
SET status = $1, updated_at = CURRENT_TIMESTAMP
SET status = $1,
completed_date = CASE
WHEN $1 = 'finished' THEN COALESCE(completed_date, CURRENT_DATE)
WHEN status = 'finished' THEN NULL
ELSE completed_date
END,
updated_at = CURRENT_TIMESTAMP
WHERE id = $2
RETURNING id;
`, [normalizedStatus, id]);
@@ -1318,6 +1351,58 @@ const updateProductionOrderStatus = async (id, status) => {
return getOrderById(id);
};
const updateProductionOrderExecution = async (id, payload = {}) => {
const actualCutQuantity = normalizeNonNegativeQuantity(payload.actualCutQuantity);
const rejectedQuantity = normalizeNonNegativeQuantity(payload.rejectedQuantity);
const finishedQuantity = normalizeNonNegativeQuantity(payload.finishedQuantity);
const qualityNotes = normalizeText(payload.qualityNotes);
if (actualCutQuantity === null && rejectedQuantity === null && finishedQuantity === null && !qualityNotes) {
const error = new Error('Informe uma quantidade de execução ou uma observação de qualidade.');
error.statusCode = 400;
throw error;
}
const currentResult = await pool.query(`
SELECT actual_cut_quantity, rejected_quantity, finished_quantity
FROM production_orders
WHERE id = $1;
`, [id]);
if (!currentResult.rowCount) {
const error = new Error('Production order not found');
error.statusCode = 404;
throw error;
}
const current = currentResult.rows[0];
const effectiveActual = actualCutQuantity === null ? (current.actual_cut_quantity === null ? null : Number(current.actual_cut_quantity)) : actualCutQuantity;
const effectiveRejected = rejectedQuantity === null ? Number(current.rejected_quantity || 0) : rejectedQuantity;
const effectiveFinished = finishedQuantity === null ? (current.finished_quantity === null ? null : Number(current.finished_quantity)) : finishedQuantity;
if (effectiveActual !== null && effectiveRejected > effectiveActual) {
const error = new Error('As perdas não podem ser maiores que a quantidade cortada.');
error.statusCode = 400;
throw error;
}
if (effectiveActual !== null && effectiveFinished !== null && effectiveFinished > effectiveActual - effectiveRejected) {
const error = new Error('A quantidade final não pode ser maior que o saldo após as perdas.');
error.statusCode = 400;
throw error;
}
await pool.query(`
UPDATE production_orders
SET actual_cut_quantity = $1,
rejected_quantity = $2,
finished_quantity = $3,
quality_notes = NULLIF($4, ''),
updated_at = CURRENT_TIMESTAMP
WHERE id = $5;
`, [effectiveActual, effectiveRejected, effectiveFinished, qualityNotes, id]);
return getOrderById(id);
};
module.exports = {
createProductionOrders,
deleteManualProductComposition,
@@ -1329,5 +1414,6 @@ module.exports = {
saveManualProductComposition,
upsertTinyProductComposition,
upsertTinyProductionOrderDetail,
updateProductionOrderExecution,
updateProductionOrderStatus
};

View File

@@ -156,8 +156,11 @@ const normalizeMatchText = (value: string) => normalizeProductText(value)
const isOpenProductionOrder = (order: ProductionOrderItem) => (
order.status === 'open' ||
order.status === 'cutting' ||
order.status === 'sewing' ||
order.status === 'review' ||
order.status === 'in_progress' ||
['em aberto', 'aberta', 'andamento', 'em andamento'].includes(normalizeMatchText(order.status))
['em aberto', 'aberta', 'andamento', 'em andamento', 'em corte', 'em costura', 'em revisao'].includes(normalizeMatchText(order.status))
);
export const buildOpenProductionByProductId = (

View File

@@ -1,4 +1,4 @@
import type { AuthUser, CampaignPreview, CampaignProcessSummary, CampaignQueueSummary, CatalogCategory, CatalogCategoryPayload, CatalogProduct, CatalogProductPayload, CatalogSummary, ClientAnalyticsItem, ClientDetailsAnalytics, ClientFilterOptions, ClientMetadataFilters, ClientPurchasePatternAnalytics, ConsumptionReference, ConsumptionReferencePayload, CreateProductionOrdersResult, CreateUserResult, CuttingSettings, DashboardAnalytics, DateRange, GraphsStockBalanceHistoryItem, GraphsStockBalancePayload, ManagedUser, OrderData, OlistRunDetails, OlistStatus, ProductAnalyticsItem, ProductComposition, ProductCompositionImportSummary, ProductCompositionPayload, ProductDetailsAnalytics, ProductionOrderImportData, ProductionOrderItem, ProductionOrderPayload, ProductionOrderStatus, ProductionOrderSummary, RfmAnalytics, SellerAnalyticsItem, SellerDetailsAnalytics, StockData, SupplyFabricPlan, SupplyFabricPlanPayload, SupplyInventoryAdjustmentPayload, SupplyLot, SupplyMaterialDetailsPayload, SupplyProductionExitPayload, SupplyPurchaseNeed, SupplyReceipt, SupplyReceiptPayload, SupplySummary } from './types';
import type { AuthUser, CampaignPreview, CampaignProcessSummary, CampaignQueueSummary, CatalogCategory, CatalogCategoryPayload, CatalogProduct, CatalogProductPayload, CatalogSummary, ClientAnalyticsItem, ClientDetailsAnalytics, ClientFilterOptions, ClientMetadataFilters, ClientPurchasePatternAnalytics, ConsumptionReference, ConsumptionReferencePayload, CreateProductionOrdersResult, CreateUserResult, CuttingSettings, DashboardAnalytics, DateRange, GraphsStockBalanceHistoryItem, GraphsStockBalancePayload, ManagedUser, OrderData, OlistRunDetails, OlistStatus, ProductAnalyticsItem, ProductComposition, ProductCompositionImportSummary, ProductCompositionPayload, ProductDetailsAnalytics, ProductionOrderExecutionPayload, ProductionOrderImportData, ProductionOrderItem, ProductionOrderPayload, ProductionOrderStatus, ProductionOrderSummary, RfmAnalytics, SellerAnalyticsItem, SellerDetailsAnalytics, StockData, SupplyFabricPlan, SupplyFabricPlanPayload, SupplyInventoryAdjustmentPayload, SupplyLot, SupplyMaterialDetailsPayload, SupplyProductionExitPayload, SupplyPurchaseNeed, SupplyReceipt, SupplyReceiptPayload, SupplySummary } from './types';
import { formatDateParam } from './dateRanges';
const API_URL = import.meta.env.VITE_API_URL || '/api';
@@ -230,11 +230,11 @@ export const fetchProductionOrders = async (
return getCachedAnalytics(path, async () => {
try {
const response = await authFetch(path, options?.force ? { cache: 'no-store' } : {});
if (!response.ok) return { orders: [], counts: { all: 0, open: 0, in_progress: 0, finished: 0, canceled: 0 } };
if (!response.ok) return { orders: [], counts: { all: 0, open: 0, cutting: 0, sewing: 0, review: 0, finished: 0, canceled: 0 } };
return await response.json();
} catch (error) {
console.error('Fetch production orders failed', error);
return { orders: [], counts: { all: 0, open: 0, in_progress: 0, finished: 0, canceled: 0 } };
return { orders: [], counts: { all: 0, open: 0, cutting: 0, sewing: 0, review: 0, finished: 0, canceled: 0 } };
}
}, options);
};
@@ -263,6 +263,19 @@ export const updateProductionOrderStatus = async (id: number, status: Production
return await response.json();
};
export const updateProductionOrderExecution = async (id: number, payload: ProductionOrderExecutionPayload): Promise<ProductionOrderItem> => {
const response = await authFetch(`/production-orders/${id}/execution`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload)
});
const data = await response.json().catch(() => null);
if (!response.ok) throw new Error(data?.error || 'Não foi possível registrar a execução da OP.');
analyticsCache.clear();
return data as ProductionOrderItem;
};
export const fetchCuttingSettings = async (): Promise<CuttingSettings> => {
try {
const response = await authFetch('/cutting-settings');

View File

@@ -1,30 +1,34 @@
import { Fragment, type FormEvent, useCallback, useEffect, useMemo, useState } from 'react';
import { Link, useNavigate, useOutletContext } from 'react-router-dom';
import { ArrowLeft, CheckCircle2, ChevronDown, ChevronUp, ClipboardList, Clock3, Download, PackageCheck, PlayCircle, Search, Undo2, XCircle } from 'lucide-react';
import { ArrowLeft, CheckCircle2, ChevronDown, ChevronUp, ClipboardList, Clock3, Download, PackageCheck, PlayCircle, Printer, Scissors, Search, Undo2, XCircle } 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 { consumeSupplyLotForProduction, exportToCSV, fetchProductionOrders, fetchSupplySummary, updateProductionOrderExecution, updateProductionOrderStatus } from '../dataService';
import type { DateRange, ProductionOrderItem, ProductionOrderStatus, ProductionOrderSummary, SupplyLot } from '../types';
type ProductionOrderStatusTab = 'all' | 'open' | 'in_progress' | 'finished' | 'canceled';
type ProductionOrderStatusTab = 'all' | 'open' | 'cutting' | 'sewing' | 'review' | 'finished' | 'canceled';
const emptySummary: ProductionOrderSummary = {
orders: [],
counts: { all: 0, open: 0, in_progress: 0, finished: 0, canceled: 0 }
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: 'in_progress', label: 'Em andamento', dotClass: 'bg-sky-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: 'in_progress', label: 'Em andamento', description: 'A produção já foi iniciada.' },
{ 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.' }
];
@@ -35,11 +39,21 @@ const statusStyles: Record<string, { label: string; className: string; dotClass:
className: 'border-amber-400/35 bg-amber-400/10 text-amber-300',
dotClass: 'bg-amber-400'
},
in_progress: {
label: 'Em andamento',
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',
@@ -128,7 +142,9 @@ const ProductionOrders = () => {
const [supplyLots, setSupplyLots] = useState<SupplyLot[]>([]);
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 [busyStatusOrderId, setBusyStatusOrderId] = useState<number | null>(null);
const [statusMessage, setStatusMessage] = useState('');
const [selectedOrderId, setSelectedOrderId] = useState<number | null>(null);
@@ -193,7 +209,7 @@ const ProductionOrders = () => {
const isRefreshing = isLoading && summary.orders.length > 0;
const openCount = summary.counts.open || 0;
const progressCount = summary.counts.in_progress || 0;
const progressCount = (summary.counts.cutting || 0) + (summary.counts.sewing || 0) + (summary.counts.review || 0);
const finishedCount = summary.counts.finished || 0;
const totalQuantity = summary.orders.reduce((total, order) => total + order.quantity, 0);
const selectedOrder = summary.orders.find(order => order.id === selectedOrderId) || null;
@@ -270,6 +286,46 @@ const ProductionOrders = () => {
}
};
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(),
});
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 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,
@@ -302,7 +358,7 @@ const ProductionOrders = () => {
<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 === 'in_progress' ? PlayCircle : 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="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>
@@ -316,6 +372,26 @@ const ProductionOrders = () => {
</div>
</section>
<section>
<div className="flex flex-wrap items-center justify-between gap-3">
<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 finalizar.</p>
</div>
<button type="button" onClick={() => printProductionSheet(order)} className="inline-flex h-9 items-center gap-2 rounded-lg border border-dark-border bg-dark-card px-3 text-xs font-bold text-dark-text transition-colors hover:border-brand-primary hover:text-brand-primary cursor-pointer"><Printer className="h-4 w-4" />Imprimir ficha</button>
</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-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>
<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>}
@@ -323,7 +399,7 @@ const ProductionOrders = () => {
<section>
<h3 className="text-xs font-bold uppercase tracking-widest text-dark-muted">Baixa de material</h3>
{order.status === 'in_progress' ? <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-sky-300">Em andamento</span> antes de registrar a baixa de materiais.</div>}
{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>}

View File

@@ -291,7 +291,7 @@ const SuppliesHub = () => {
]);
if (!isMounted) return;
setSummary(supplySummary);
setProductionCounts({ open: productionSummary.counts.open || 0, inProgress: productionSummary.counts.in_progress || 0 });
setProductionCounts({ open: productionSummary.counts.open || 0, inProgress: (productionSummary.counts.cutting || 0) + (productionSummary.counts.sewing || 0) + (productionSummary.counts.review || 0) });
};
void loadHub();
return () => { isMounted = false; };

View File

@@ -51,7 +51,7 @@ export type GraphsStockBalanceHistoryItem = {
created_at: string;
};
export type ProductionOrderStatus = 'open' | 'in_progress' | 'finished' | 'canceled' | string;
export type ProductionOrderStatus = 'open' | 'cutting' | 'sewing' | 'review' | 'finished' | 'canceled' | string;
export interface ProductionOrderMarker {
label: string;
@@ -100,6 +100,10 @@ export interface ProductionOrderItem {
fabricKg: number | null;
ribKg: number | null;
yieldPiecesPerKg: number | null;
actualCutQuantity: number | null;
rejectedQuantity: number;
finishedQuantity: number | null;
qualityNotes: string;
markers: ProductionOrderMarker[];
components: ProductionOrderComponent[];
steps: ProductionOrderStep[];
@@ -110,7 +114,9 @@ export interface ProductionOrderItem {
export interface ProductionOrderCounts {
all: number;
open: number;
in_progress: number;
cutting: number;
sewing: number;
review: number;
finished: number;
canceled: number;
[key: string]: number;
@@ -141,6 +147,13 @@ export type ProductionOrderPayload = {
metadata?: Record<string, unknown>;
};
export type ProductionOrderExecutionPayload = {
actualCutQuantity?: number | null;
rejectedQuantity?: number | null;
finishedQuantity?: number | null;
qualityNotes?: string;
};
export interface CreateProductionOrdersResult {
created: ProductionOrderItem[];
skipped: Array<{