diff --git a/backend/db.js b/backend/db.js index a3c0de1..44ac5b2 100644 --- a/backend/db.js +++ b/backend/db.js @@ -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, diff --git a/backend/routes/productionOrderRoutes.js b/backend/routes/productionOrderRoutes.js index fd6598b..5308ef1 100644 --- a/backend/routes/productionOrderRoutes.js +++ b/backend/routes/productionOrderRoutes.js @@ -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; diff --git a/backend/services/productionOrderService.js b/backend/services/productionOrderService.js index 5593692..27bebd9 100644 --- a/backend/services/productionOrderService.js +++ b/backend/services/productionOrderService.js @@ -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 }; diff --git a/src/analytics/cutting.ts b/src/analytics/cutting.ts index db19d59..5991be3 100644 --- a/src/analytics/cutting.ts +++ b/src/analytics/cutting.ts @@ -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 = ( diff --git a/src/dataService.ts b/src/dataService.ts index b0026a5..7a9e0ab 100644 --- a/src/dataService.ts +++ b/src/dataService.ts @@ -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 => { + 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 => { try { const response = await authFetch('/cutting-settings'); diff --git a/src/pages/ProductionOrders.tsx b/src/pages/ProductionOrders.tsx index 90f6b1b..3424924 100644 --- a/src/pages/ProductionOrders.tsx +++ b/src/pages/ProductionOrders.tsx @@ -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; 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 { const [supplyLots, setSupplyLots] = useState([]); const [isLoading, setIsLoading] = useState(true); const [isSupplyBusy, setIsSupplyBusy] = useState(false); + const [busyExecutionOrderId, setBusyExecutionOrderId] = useState(null); const [supplyMessage, setSupplyMessage] = useState(''); + const [executionMessage, setExecutionMessage] = useState(''); const [busyStatusOrderId, setBusyStatusOrderId] = useState(null); const [statusMessage, setStatusMessage] = useState(''); const [selectedOrderId, setSelectedOrderId] = useState(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, 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 => `${component.componentName}${formatQuantity(component.totalQuantity)} ${component.unit}`).join('') || 'Composição não sincronizada.'; + printWindow.document.write(`Ficha ${order.number || order.id}

Ficha de Produção · ${order.number || `OP #${order.id}`}

${order.productDescription}

SKU${order.productSku || '-'}
Quantidade planejada${formatQuantity(order.quantity)} ${order.unit}
Status${order.statusLabel}
Referência${order.orderReference || '-'}

Composição prevista

${componentRows}
MaterialQuantidade

Apontamento de execução

Quantidade cortada
Perdas / defeitos
Quantidade aprovada
Responsável

Observações

`); + printWindow.document.close(); + printWindow.focus(); + printWindow.print(); + }; + const handleExport = () => { const exportData = filteredOrders.map(order => ({ 'Numero': order.number, @@ -302,7 +358,7 @@ const ProductionOrders = () => {

Quantidade

{formatQuantity(order.quantity)} {order.unit}

-

Status

{statusEditorOrderId === order.id &&

Alterar status

{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 ; })}
}
+

Status

{statusEditorOrderId === order.id &&

Alterar status

{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 ; })}
}

Emissão

{formatDate(order.issueDate)}

Previsão

{formatDate(order.expectedDate)}

@@ -316,6 +372,26 @@ const ProductionOrders = () => { +
+
+
+

Execução e qualidade

+

Registre o corte real, perdas e a quantidade aprovada antes de finalizar.

+
+ +
+
void handleExecutionSave(event, order)} className="mt-3 rounded-xl border border-dark-border bg-dark-card p-4"> +
+ + + +