diff --git a/backend/db.js b/backend/db.js index aefc80f..cdaddca 100644 --- a/backend/db.js +++ b/backend/db.js @@ -170,6 +170,31 @@ const initDB = async () => { ); `); + // A production plan is the PCP container above the individual OPs. The + // calculated cut screen may change tomorrow; a confirmed plan must not. + await pool.query(` + CREATE TABLE IF NOT EXISTS production_plans ( + id SERIAL PRIMARY KEY, + number VARCHAR(40) UNIQUE, + name TEXT NOT NULL, + status VARCHAR(30) NOT NULL DEFAULT 'open', + contractor TEXT, + planned_start_date DATE, + expected_date DATE, + notes TEXT, + source VARCHAR(40) NOT NULL DEFAULT 'cut_plan', + created_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP, + completed_at TIMESTAMPTZ + ); + `); + + await pool.query(` + ALTER TABLE production_orders + ADD COLUMN IF NOT EXISTS production_plan_id INTEGER REFERENCES production_plans(id) ON DELETE SET NULL, + ADD COLUMN IF NOT EXISTS contractor TEXT; + `).catch(() => {}); + await pool.query(` CREATE TABLE IF NOT EXISTS production_order_markers ( id SERIAL PRIMARY KEY, @@ -447,6 +472,7 @@ const initDB = async () => { CREATE TABLE IF NOT EXISTS cut_plan_lot_reservations ( id SERIAL PRIMARY KEY, cut_plan_reference TEXT NOT NULL, + production_plan_id INTEGER REFERENCES production_plans(id) ON DELETE SET NULL, supply_lot_id INTEGER NOT NULL REFERENCES supply_stock_lots(id) ON DELETE RESTRICT, material_key VARCHAR(255) NOT NULL, quantity NUMERIC(14, 4) NOT NULL CHECK (quantity > 0), @@ -460,6 +486,7 @@ const initDB = async () => { await pool.query(`CREATE INDEX IF NOT EXISTS idx_cut_plan_lot_reservations_lot_status ON cut_plan_lot_reservations (supply_lot_id, status);`); await pool.query(`CREATE INDEX IF NOT EXISTS idx_cut_plan_lot_reservations_reference ON cut_plan_lot_reservations (cut_plan_reference, status);`); + await pool.query(`CREATE INDEX IF NOT EXISTS idx_cut_plan_lot_reservations_plan ON cut_plan_lot_reservations (production_plan_id, status);`); await pool.query(` CREATE TABLE IF NOT EXISTS supply_movements ( diff --git a/backend/routes/productionOrderRoutes.js b/backend/routes/productionOrderRoutes.js index a29b4b7..aeb7ca3 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, updateProductionOrderExecution, updateProductionOrderStatus, upsertTinyProductionOrderDetail } = require('../services/productionOrderService'); +const { createProductionOrders, importTinyProductCompositionExport, listProductionOrders, listProductionPlans, updateProductionOrderExecution, updateProductionOrderStatus, updateProductionPlan, updateProductionPlanStatus, upsertTinyProductionOrderDetail } = require('../services/productionOrderService'); const router = express.Router(); @@ -17,13 +17,40 @@ router.post('/production-orders', verifyToken, async (req, res) => { try { const orders = Array.isArray(req.body?.orders) ? req.body.orders : []; const lotReservations = Array.isArray(req.body?.lotReservations) ? req.body.lotReservations : []; - res.status(201).json(await createProductionOrders(orders, lotReservations)); + res.status(201).json(await createProductionOrders(orders, lotReservations, req.body?.plan || null)); } catch (error) { console.error('Error creating production orders:', error); res.status(error.statusCode || 500).json({ error: error.message || 'Internal Server Error' }); } }); +router.get('/production-plans', verifyToken, async (req, res) => { + try { + res.json(await listProductionPlans()); + } catch (error) { + console.error('Error fetching production plans:', error); + res.status(500).json({ error: 'Internal Server Error' }); + } +}); + +router.put('/production-plans/:id', verifyToken, async (req, res) => { + try { + res.json(await updateProductionPlan(req.params.id, req.body || {})); + } catch (error) { + console.error('Error updating production plan:', error); + res.status(error.statusCode || 500).json({ error: error.message || 'Internal Server Error' }); + } +}); + +router.patch('/production-plans/:id/status', verifyToken, async (req, res) => { + try { + res.json(await updateProductionPlanStatus(req.params.id, req.body?.status)); + } catch (error) { + console.error('Error updating production plan status:', error); + res.status(error.statusCode || 500).json({ error: error.message || 'Internal Server Error' }); + } +}); + router.post('/production-orders/tiny-sync', authenticateAPIKey, async (req, res) => { try { res.status(201).json(await upsertTinyProductionOrderDetail(req.body || {})); diff --git a/backend/services/productionOrderService.js b/backend/services/productionOrderService.js index 179e963..d198124 100644 --- a/backend/services/productionOrderService.js +++ b/backend/services/productionOrderService.js @@ -276,6 +276,11 @@ const mapProductionOrderRow = (row) => { number: row.number || '', status, statusLabel: STATUS_LABELS[status] || row.status || 'Em aberto', + productionPlanId: row.production_plan_id === null || row.production_plan_id === undefined ? null : Number(row.production_plan_id), + productionPlanNumber: row.production_plan_number || '', + productionPlanName: row.production_plan_name || '', + productionPlanStatus: row.production_plan_status || '', + contractor: row.contractor || '', orderReference: row.order_reference || '', issueDate: formatDate(row.issue_date), expectedDate: formatDate(row.expected_date), @@ -305,6 +310,87 @@ const mapProductionOrderRow = (row) => { }; }; +const mapProductionPlanRow = (row) => ({ + id: Number(row.id), + number: row.number || '', + name: row.name || '', + status: normalizeStatus(row.status), + statusLabel: STATUS_LABELS[normalizeStatus(row.status)] || row.status || 'Em aberto', + contractor: row.contractor || '', + plannedStartDate: formatDate(row.planned_start_date), + expectedDate: formatDate(row.expected_date), + completedAt: row.completed_at || null, + notes: row.notes || '', + source: row.source || 'cut_plan', + orderCount: Number(row.order_count || 0), + totalQuantity: Number(row.total_quantity || 0), + actualCutQuantity: Number(row.actual_cut_quantity || 0), + finishedQuantity: Number(row.finished_quantity || 0), + statusCounts: row.status_counts || {}, + createdAt: row.created_at || null, + updatedAt: row.updated_at || null +}); + +const normalizePlanPayload = (payload = {}, fallback = {}) => ({ + id: normalizeInteger(payload.id), + name: normalizeText(payload.name) || normalizeText(fallback.name), + contractor: normalizeText(payload.contractor), + plannedStartDate: normalizeDateParam(payload.plannedStartDate || fallback.plannedStartDate), + expectedDate: normalizeDateParam(payload.expectedDate || fallback.expectedDate), + notes: normalizeText(payload.notes), + source: normalizeText(payload.source) || 'cut_plan' +}); + +const getProductionPlanById = async (id, client = pool) => { + const result = await client.query(` + SELECT + plan.id, plan.number, plan.name, plan.status, plan.contractor, + plan.planned_start_date, plan.expected_date, plan.completed_at, + plan.notes, plan.source, plan.created_at, plan.updated_at, + COUNT(po.id)::int AS order_count, + COALESCE(SUM(po.quantity), 0) AS total_quantity, + COALESCE(SUM(po.actual_cut_quantity), 0) AS actual_cut_quantity, + COALESCE(SUM(po.finished_quantity), 0) AS finished_quantity, + COALESCE(( + SELECT JSON_OBJECT_AGG(status, count) + FROM ( + SELECT status, COUNT(*)::int AS count + FROM production_orders + WHERE production_plan_id = plan.id + GROUP BY status + ) grouped_statuses + ), '{}'::json) AS status_counts + FROM production_plans plan + LEFT JOIN production_orders po ON po.production_plan_id = plan.id + WHERE plan.id = $1 + GROUP BY plan.id; + `, [id]); + return result.rows[0] ? mapProductionPlanRow(result.rows[0]) : null; +}; + +const createOrGetProductionPlan = async (client, payload, fallback) => { + const plan = normalizePlanPayload(payload, fallback); + if (plan.id) { + const existing = await getProductionPlanById(plan.id, client); + if (!existing) { + const error = new Error('Plano de produção não encontrado.'); + error.statusCode = 404; + throw error; + } + return existing; + } + if (!plan.name) return null; + + const inserted = await client.query(` + INSERT INTO production_plans (name, status, contractor, planned_start_date, expected_date, notes, source) + VALUES ($1, 'open', NULLIF($2, ''), $3, $4, NULLIF($5, ''), $6) + RETURNING id; + `, [plan.name, plan.contractor, plan.plannedStartDate, plan.expectedDate, plan.notes, plan.source]); + const id = Number(inserted.rows[0].id); + await client.query(`UPDATE production_plans SET number = $1, updated_at = CURRENT_TIMESTAMP WHERE id = $2;`, [`PP-${String(id).padStart(5, '0')}`, id]); + return getProductionPlanById(id, client); +}; + const getOrderById = async (id, client = pool) => { const result = await client.query(` SELECT @@ -312,6 +398,11 @@ const getOrderById = async (id, client = pool) => { po.tiny_id, po.number, po.status, + po.production_plan_id, + plan.number AS production_plan_number, + plan.name AS production_plan_name, + plan.status AS production_plan_status, + po.contractor, po.order_reference, po.issue_date, po.expected_date, @@ -385,7 +476,10 @@ const getOrderById = async (id, client = pool) => { ) FROM cut_plan_lot_reservations reservation JOIN supply_stock_lots lot ON lot.id = reservation.supply_lot_id - WHERE reservation.cut_plan_reference = po.order_reference + WHERE ( + reservation.production_plan_id = po.production_plan_id + OR (reservation.production_plan_id IS NULL AND reservation.cut_plan_reference = po.order_reference) + ) AND reservation.status IN ('active', 'consumed') ), '[]'::json @@ -410,6 +504,7 @@ const getOrderById = async (id, client = pool) => { '[]'::json ) as steps FROM production_orders po + LEFT JOIN production_plans plan ON plan.id = po.production_plan_id WHERE po.id = $1; `, [id]); @@ -422,6 +517,11 @@ const baseProductionOrderSelect = ` po.tiny_id, po.number, po.status, + po.production_plan_id, + plan.number AS production_plan_number, + plan.name AS production_plan_name, + plan.status AS production_plan_status, + po.contractor, po.order_reference, po.issue_date, po.expected_date, @@ -495,7 +595,10 @@ const baseProductionOrderSelect = ` ) FROM cut_plan_lot_reservations reservation JOIN supply_stock_lots lot ON lot.id = reservation.supply_lot_id - WHERE reservation.cut_plan_reference = po.order_reference + WHERE ( + reservation.production_plan_id = po.production_plan_id + OR (reservation.production_plan_id IS NULL AND reservation.cut_plan_reference = po.order_reference) + ) AND reservation.status IN ('active', 'consumed') ), '[]'::json @@ -520,6 +623,7 @@ const baseProductionOrderSelect = ` '[]'::json ) as steps FROM production_orders po + LEFT JOIN production_plans plan ON plan.id = po.production_plan_id `; const listProductionOrders = async (filters = {}) => { @@ -576,13 +680,113 @@ const listProductionOrders = async (filters = {}) => { return { orders, counts }; }; -const createProductionOrders = async (orders = [], lotReservations = []) => { +const listProductionPlans = async () => { + const result = await pool.query(` + SELECT + plan.id, plan.number, plan.name, plan.status, plan.contractor, + plan.planned_start_date, plan.expected_date, plan.completed_at, + plan.notes, plan.source, plan.created_at, plan.updated_at, + COUNT(po.id)::int AS order_count, + COALESCE(SUM(po.quantity), 0) AS total_quantity, + COALESCE(SUM(po.actual_cut_quantity), 0) AS actual_cut_quantity, + COALESCE(SUM(po.finished_quantity), 0) AS finished_quantity, + COALESCE(( + SELECT JSON_OBJECT_AGG(status, count) + FROM ( + SELECT status, COUNT(*)::int AS count + FROM production_orders + WHERE production_plan_id = plan.id + GROUP BY status + ) grouped_statuses + ), '{}'::json) AS status_counts + FROM production_plans plan + LEFT JOIN production_orders po ON po.production_plan_id = plan.id + GROUP BY plan.id + ORDER BY COALESCE(plan.planned_start_date, plan.created_at::date) DESC, plan.id DESC; + `); + return result.rows.map(mapProductionPlanRow); +}; + +const updateProductionPlan = async (id, payload = {}) => { + const plan = normalizePlanPayload(payload); + if (!plan.name) { + const error = new Error('O nome do plano é obrigatório.'); + error.statusCode = 400; + throw error; + } + const result = await pool.query(` + UPDATE production_plans + SET name = $1, + contractor = NULLIF($2, ''), + planned_start_date = $3, + expected_date = $4, + notes = NULLIF($5, ''), + updated_at = CURRENT_TIMESTAMP + WHERE id = $6 + RETURNING id; + `, [plan.name, plan.contractor, plan.plannedStartDate, plan.expectedDate, plan.notes, id]); + if (!result.rowCount) { + const error = new Error('Plano de produção não encontrado.'); + error.statusCode = 404; + throw error; + } + return getProductionPlanById(id); +}; + +const updateProductionPlanStatus = async (id, status) => { + const normalizedStatus = normalizeStatus(status); + if (!STATUS_LABELS[normalizedStatus]) { + const error = new Error('Invalid production plan status'); + error.statusCode = 400; + throw error; + } + const client = await pool.connect(); + try { + await client.query('BEGIN'); + const planResult = await client.query(` + UPDATE production_plans + SET status = $1, + completed_at = CASE WHEN $1 = 'finished' THEN COALESCE(completed_at, CURRENT_TIMESTAMP) ELSE NULL END, + updated_at = CURRENT_TIMESTAMP + WHERE id = $2 + RETURNING id; + `, [normalizedStatus, id]); + if (!planResult.rowCount) { + const error = new Error('Plano de produção não encontrado.'); + error.statusCode = 404; + throw error; + } + await client.query(` + UPDATE production_orders + SET status = $1, + completed_date = CASE WHEN $1 = 'finished' THEN COALESCE(completed_date, CURRENT_DATE) ELSE completed_date END, + updated_at = CURRENT_TIMESTAMP + WHERE production_plan_id = $2 + AND status NOT IN ('finished', 'canceled'); + `, [normalizedStatus, id]); + await client.query('COMMIT'); + return getProductionPlanById(id); + } catch (error) { + await client.query('ROLLBACK'); + throw error; + } finally { + client.release(); + } +}; + +const createProductionOrders = async (orders = [], lotReservations = [], planPayload = null) => { const client = await pool.connect(); try { await client.query('BEGIN'); const created = []; const skipped = []; + const fallbackPlan = { + name: normalizeText(orders[0]?.orderReference), + plannedStartDate: orders[0]?.issueDate, + expectedDate: orders[0]?.expectedDate + }; + const plan = planPayload ? await createOrGetProductionPlan(client, planPayload, fallbackPlan) : null; for (const order of orders) { const productDescription = normalizeText(order.productDescription); @@ -615,6 +819,8 @@ const createProductionOrders = async (orders = [], lotReservations = []) => { tiny_id, number, status, + production_plan_id, + contractor, order_reference, issue_date, expected_date, @@ -637,11 +843,15 @@ const createProductionOrders = async (orders = [], lotReservations = []) => { $7, $8, $9, - $10::jsonb + $10, + $11, + $12::jsonb ) RETURNING id; `, [ normalizeStatus(order.status || 'open'), + plan?.id || null, + normalizeText(order.contractor) || plan?.contractor || null, orderReference, normalizeDateParam(order.issueDate) || formatDate(new Date()), normalizeDateParam(order.expectedDate), @@ -734,15 +944,15 @@ const createProductionOrders = async (orders = [], lotReservations = []) => { } await client.query(` INSERT INTO cut_plan_lot_reservations ( - cut_plan_reference, supply_lot_id, material_key, quantity, consumed_quantity, status, updated_at + cut_plan_reference, production_plan_id, supply_lot_id, material_key, quantity, consumed_quantity, status, updated_at ) - VALUES ($1, $2, $3, $4, 0, 'active', CURRENT_TIMESTAMP); - `, [cutPlanReference, lotId, materialKey, quantity]); + VALUES ($1, $2, $3, $4, $5, 0, 'active', CURRENT_TIMESTAMP); + `, [cutPlanReference, plan?.id || null, lotId, materialKey, quantity]); } } await client.query('COMMIT'); - return { created, skipped }; + return { created, skipped, plan }; } catch (error) { await client.query('ROLLBACK'); throw error; @@ -1534,12 +1744,15 @@ module.exports = { deleteManualProductComposition, getProductComposition, importTinyProductCompositionExport, + listProductionPlans, listProductCompositions, listProductionOrders, normalizeStatus, saveManualProductComposition, upsertTinyProductComposition, upsertTinyProductionOrderDetail, + updateProductionPlan, + updateProductionPlanStatus, updateProductionOrderExecution, updateProductionOrderStatus }; diff --git a/src/App.tsx b/src/App.tsx index 43affbd..d871ba9 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -12,6 +12,7 @@ const Replenishment = React.lazy(() => import('./pages/Replenishment')); const Cutting = React.lazy(() => import('./pages/Cutting')); const PlanningIssues = React.lazy(() => import('./pages/PlanningIssues')); const ProductionOrders = React.lazy(() => import('./pages/ProductionOrders')); +const ProductionPlans = React.lazy(() => import('./pages/ProductionPlans')); const Supplies = React.lazy(() => import('./pages/Supplies')); const Clients = React.lazy(() => import('./pages/Clients')); const ClientDetails = React.lazy(() => import('./pages/ClientDetails')); @@ -64,6 +65,7 @@ function App() { } /> } /> } /> + } /> } /> } /> } /> diff --git a/src/dataService.ts b/src/dataService.ts index 8988580..af55590 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, CutPlanLotReservationPayload, 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, SupplyPurchaseOrder, SupplyPurchaseOrderPayload, SupplyPurchaseOrderStatus, 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, CutPlanLotReservationPayload, CuttingSettings, DashboardAnalytics, DateRange, GraphsStockBalanceHistoryItem, GraphsStockBalancePayload, ManagedUser, OrderData, OlistRunDetails, OlistStatus, ProductAnalyticsItem, ProductComposition, ProductCompositionImportSummary, ProductCompositionPayload, ProductDetailsAnalytics, ProductionOrderExecutionPayload, ProductionOrderImportData, ProductionOrderItem, ProductionOrderPayload, ProductionOrderStatus, ProductionOrderSummary, ProductionPlan, ProductionPlanPayload, RfmAnalytics, SellerAnalyticsItem, SellerDetailsAnalytics, StockData, SupplyFabricPlan, SupplyFabricPlanPayload, SupplyInventoryAdjustmentPayload, SupplyLot, SupplyMaterialDetailsPayload, SupplyProductionExitPayload, SupplyPurchaseNeed, SupplyPurchaseOrder, SupplyPurchaseOrderPayload, SupplyPurchaseOrderStatus, SupplyReceipt, SupplyReceiptPayload, SupplySummary } from './types'; import { formatDateParam } from './dateRanges'; const API_URL = import.meta.env.VITE_API_URL || '/api'; @@ -239,11 +239,11 @@ export const fetchProductionOrders = async ( }, options); }; -export const createProductionOrders = async (orders: ProductionOrderPayload[], lotReservations: CutPlanLotReservationPayload[] = []): Promise => { +export const createProductionOrders = async (orders: ProductionOrderPayload[], lotReservations: CutPlanLotReservationPayload[] = [], plan?: ProductionPlanPayload): Promise => { const response = await authFetch('/production-orders', { method: 'POST', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ orders, lotReservations }) + body: JSON.stringify({ orders, lotReservations, plan }) }); if (!response.ok) throw new Error('Não foi possível criar as ordens de produção.'); @@ -251,6 +251,36 @@ export const createProductionOrders = async (orders: ProductionOrderPayload[], l return await response.json(); }; +export const fetchProductionPlans = async (): Promise => { + const response = await authFetch('/production-plans', { cache: 'no-store' }); + if (!response.ok) throw new Error('Não foi possível carregar os planos de produção.'); + return response.json() as Promise; +}; + +export const updateProductionPlan = async (id: number, payload: ProductionPlanPayload): Promise => { + const response = await authFetch(`/production-plans/${id}`, { + method: 'PUT', + 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 atualizar o plano de produção.'); + analyticsCache.clear(); + return data as ProductionPlan; +}; + +export const updateProductionPlanStatus = async (id: number, status: ProductionOrderStatus): Promise => { + const response = await authFetch(`/production-plans/${id}/status`, { + method: 'PATCH', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ status }) + }); + const data = await response.json().catch(() => null); + if (!response.ok) throw new Error(data?.error || 'Não foi possível mover o plano de produção.'); + analyticsCache.clear(); + return data as ProductionPlan; +}; + export const updateProductionOrderStatus = async (id: number, status: ProductionOrderStatus): Promise => { const response = await authFetch(`/production-orders/${id}/status`, { method: 'PATCH', diff --git a/src/pages/Cutting.tsx b/src/pages/Cutting.tsx index 6380c3d..aa74aab 100644 --- a/src/pages/Cutting.tsx +++ b/src/pages/Cutting.tsx @@ -167,6 +167,7 @@ const Cutting = () => { const [previewItemsPerPage, setPreviewItemsPerPage] = useState(10); const [selectedPreviewRowIds, setSelectedPreviewRowIds] = useState([]); const [lotSelections, setLotSelections] = useState>({}); + const [productionPlanForm, setProductionPlanForm] = useState({ name: '', contractor: '', expectedDate: '', notes: '' }); const [editingCutRow, setEditingCutRow] = useState(null); const [quickCutEdit, setQuickCutEdit] = useState({}); const [isQuickCutEditSaving, setIsQuickCutEditSaving] = useState(false); @@ -666,6 +667,7 @@ const Cutting = () => { setPreviewPage(1); setSelectedPreviewRowIds(rowsAvailableForOrder.map(row => row.id)); setLotSelections({}); + setProductionPlanForm({ name: cutPlanReference, contractor: '', expectedDate: '', notes: '' }); setIsOrderPreviewOpen(true); }; @@ -746,11 +748,18 @@ const Cutting = () => { availableQuantity: row.availableQuantity, issues: row.issues } - })), selectedLotReservations); + })), selectedLotReservations, { + name: productionPlanForm.name.trim() || cutPlanReference, + contractor: productionPlanForm.contractor.trim(), + plannedStartDate: issueDate, + expectedDate: productionPlanForm.expectedDate || null, + notes: productionPlanForm.notes.trim(), + source: 'cut_plan' + }); const refreshedOrders = await fetchProductionOrders(allProductionOrdersRange, undefined, { force: true }); setProductionOrders(refreshedOrders.orders); - setGenerationMessage(`${result.created.length} OPs criadas${result.skipped.length ? ` · ${result.skipped.length} já existiam abertas` : ''}.`); + setGenerationMessage(`${result.plan?.number || 'Plano'} criado com ${result.created.length} OPs${result.skipped.length ? ` · ${result.skipped.length} já existiam abertas` : ''}.`); setIsOrderPreviewOpen(false); } catch (error) { setGenerationMessage(error instanceof Error ? error.message : 'Não foi possível gerar as ordens de produção.'); @@ -900,6 +909,27 @@ const Cutting = () => { ].map(([label, value, tone]) =>

{label}

{value}

)} +
+
+

Plano de produção

+

Estas OPs serão gravadas dentro de um único plano PCP. O plano pode avançar todas as OPs ativas juntas.

+
+
+ + + + +
+
+

Todos os SKUs abaixo estão aptos para gerar OP. A seleção permanece ao trocar de página.

diff --git a/src/pages/ProductionOrders.tsx b/src/pages/ProductionOrders.tsx index 2732411..d1a407b 100644 --- a/src/pages/ProductionOrders.tsx +++ b/src/pages/ProductionOrders.tsx @@ -422,6 +422,7 @@ const ProductionOrders = () => {

Ordens de Produção

Acompanhe as ordens locais geradas pelo plano de corte.

+ Ver planos de produção
{
{order.productSku || 'Sem SKU'} {formatDate(order.issueDate)} + {order.productionPlanNumber && {order.productionPlanNumber}} {order.orderReference && {order.orderReference}}
diff --git a/src/pages/ProductionPlans.tsx b/src/pages/ProductionPlans.tsx new file mode 100644 index 0000000..0d231cc --- /dev/null +++ b/src/pages/ProductionPlans.tsx @@ -0,0 +1,103 @@ +import { useCallback, useEffect, useMemo, useState } from 'react'; +import { Link } from 'react-router-dom'; +import { ArrowLeft, CheckCircle2, ClipboardList, Factory, Loader2, PlayCircle } from 'lucide-react'; +import { fetchProductionPlans, updateProductionPlanStatus } from '../dataService'; +import type { ProductionOrderStatus, ProductionPlan } from '../types'; + +const statusOptions: Array<{ value: ProductionOrderStatus; label: string; className: string }> = [ + { value: 'open', label: 'Em aberto', className: 'border-amber-400/35 bg-amber-400/10 text-amber-300' }, + { value: 'cutting', label: 'Em corte', className: 'border-amber-300/35 bg-amber-300/10 text-amber-200' }, + { value: 'sewing', label: 'Em costura', className: 'border-sky-400/35 bg-sky-400/10 text-sky-300' }, + { value: 'review', label: 'Em revisão', className: 'border-violet-400/35 bg-violet-400/10 text-violet-300' }, + { value: 'finished', label: 'Finalizada', className: 'border-emerald-400/35 bg-emerald-400/10 text-emerald-300' }, + { value: 'canceled', label: 'Cancelada', className: 'border-zinc-500/35 bg-zinc-500/10 text-zinc-400' } +]; + +const statusStyle = (status: string) => statusOptions.find(option => option.value === status)?.className || 'border-dark-border bg-dark-input text-dark-muted'; +const formatNumber = (value: number) => new Intl.NumberFormat('pt-BR', { maximumFractionDigits: 2 }).format(value); +const formatDate = (value: string | null) => value ? new Intl.DateTimeFormat('pt-BR').format(new Date(`${value.slice(0, 10)}T00:00:00`)) : '-'; + +const ProductionPlans = () => { + const [plans, setPlans] = useState([]); + const [isLoading, setIsLoading] = useState(true); + const [busyPlanId, setBusyPlanId] = useState(null); + const [message, setMessage] = useState(''); + + const loadPlans = useCallback(async () => { + setIsLoading(true); + try { + setPlans(await fetchProductionPlans()); + } catch (error) { + setMessage(error instanceof Error ? error.message : 'Não foi possível carregar os planos.'); + } finally { + setIsLoading(false); + } + }, []); + + useEffect(() => { + const timer = window.setTimeout(() => { void loadPlans(); }, 0); + return () => window.clearTimeout(timer); + }, [loadPlans]); + + const activePlans = useMemo(() => plans.filter(plan => !['finished', 'canceled'].includes(plan.status)), [plans]); + const totalOpenQuantity = useMemo(() => activePlans.reduce((total, plan) => total + plan.totalQuantity, 0), [activePlans]); + + const movePlan = async (plan: ProductionPlan, status: ProductionOrderStatus) => { + if (status === plan.status) return; + setBusyPlanId(plan.id); + setMessage(''); + try { + const updated = await updateProductionPlanStatus(plan.id, status); + setPlans(current => current.map(item => item.id === updated.id ? updated : item)); + setMessage(`${updated.number} movido para ${updated.statusLabel}. As OPs ainda ativas acompanharam o plano.`); + } catch (error) { + setMessage(error instanceof Error ? error.message : 'Não foi possível mover o plano.'); + } finally { + setBusyPlanId(null); + } + }; + + return ( +
+
+
+ Ordens de Produção +

Planos de Produção

+

Controle PCP acima das fichas: cada plano agrupa OPs, materiais reservados e a movimentação da operação.

+
+ +
+ +
+ {[ + ['Planos ativos', activePlans.length, ClipboardList, 'text-brand-primary'], + ['OPs planejadas', activePlans.reduce((total, plan) => total + plan.orderCount, 0), Factory, 'text-sky-300'], + ['Unidades em fluxo', formatNumber(totalOpenQuantity), PlayCircle, 'text-amber-300'] + ].map(([label, value, Icon, tone]) => { + const MetricIcon = Icon as typeof ClipboardList; + return

{label}

{value}

; + })} +
+ + {message &&
{message}
} + +
+

Fila de planos

Mover um plano atualiza todas as OPs locais que continuam ativas; OPs concluídas e canceladas preservam seu histórico.

+ {isLoading ?
: plans.length ? ( +
+ {plans.map(plan => ( +
+
{plan.number}{plan.statusLabel}

{plan.name}

{plan.contractor ? `Facção: ${plan.contractor}` : 'Sem facção definida'} · previsão {formatDate(plan.expectedDate)}

{plan.notes &&

{plan.notes}

}
+

Escopo

{plan.orderCount} {plan.orderCount === 1 ? 'OP' : 'OPs'} · {formatNumber(plan.totalQuantity)} un.

Cortado {formatNumber(plan.actualCutQuantity)} · final {formatNumber(plan.finishedQuantity)}

+
{statusOptions.filter(option => Number(plan.statusCounts[option.value] || 0) > 0).map(option => {plan.statusCounts[option.value]} {option.label.toLowerCase()})}
+ +
+ ))} +
+ ) :

Nenhum plano confirmado

Gere OPs a partir do Plano de Corte para criar o primeiro plano PCP.

} +
+
+ ); +}; + +export default ProductionPlans; diff --git a/src/pages/Supplies.tsx b/src/pages/Supplies.tsx index ea443b6..5da2667 100644 --- a/src/pages/Supplies.tsx +++ b/src/pages/Supplies.tsx @@ -2667,7 +2667,12 @@ const PurchaseOrdersScreen = () => { const [nextOrders, nextSuppliers] = await Promise.all([fetchSupplyPurchaseOrders(), fetchSupplySuppliers()]); setOrders(nextOrders); setSuppliers(nextSuppliers); }; - useEffect(() => { void load().catch(error => setMessage(error instanceof Error ? error.message : 'Não foi possível carregar as OCs.')); }, []); + useEffect(() => { + const timer = window.setTimeout(() => { + void load().catch(error => setMessage(error instanceof Error ? error.message : 'Não foi possível carregar as OCs.')); + }, 0); + return () => window.clearTimeout(timer); + }, []); const submit = async (event: FormEvent) => { event.preventDefault(); setBusy(true); setMessage(''); try { diff --git a/src/types.ts b/src/types.ts index 90035aa..a2c476f 100644 --- a/src/types.ts +++ b/src/types.ts @@ -96,6 +96,11 @@ export interface ProductionOrderItem { number: string; status: ProductionOrderStatus; statusLabel: string; + productionPlanId: number | null; + productionPlanNumber: string; + productionPlanName: string; + productionPlanStatus: string; + contractor: string; orderReference: string; issueDate: string | null; expectedDate: string | null; @@ -156,6 +161,7 @@ export type ProductionOrderPayload = { quantity: number; unit?: string; integrationStatus?: string; + contractor?: string; components?: Array<{ componentTinyId?: string; componentSku?: string; @@ -168,6 +174,37 @@ export type ProductionOrderPayload = { metadata?: Record; }; +export interface ProductionPlan { + id: number; + number: string; + name: string; + status: ProductionOrderStatus; + statusLabel: string; + contractor: string; + plannedStartDate: string | null; + expectedDate: string | null; + completedAt: string | null; + notes: string; + source: string; + orderCount: number; + totalQuantity: number; + actualCutQuantity: number; + finishedQuantity: number; + statusCounts: Record; + createdAt: string | null; + updatedAt: string | null; +} + +export type ProductionPlanPayload = { + id?: number; + name: string; + contractor?: string; + plannedStartDate?: string | null; + expectedDate?: string | null; + notes?: string; + source?: string; +}; + export type CutPlanLotReservationPayload = { lotId: number; materialKey: string; @@ -189,6 +226,7 @@ export interface CreateProductionOrdersResult { productDescription?: string; reason: string; }>; + plan: ProductionPlan | null; } export type CutFamilyKey = 'BLCS' | 'BLOS' | 'BLMC' | 'BLPM' | 'BLRM' | 'OUTROS';