From 791246c55e76c04b71aea04b7f8c3b41c09e326e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Cau=C3=AA=20Faleiros?= Date: Thu, 27 Aug 2026 10:51:41 -0300 Subject: [PATCH] Add production plan release checklist --- backend/db.js | 15 +++++ backend/routes/productionOrderRoutes.js | 10 ++- backend/services/productionOrderService.js | 64 ++++++++++++++++-- src/dataService.ts | 12 +++- src/pages/ProductionPlans.tsx | 78 +++++++++++++++++++--- src/types.ts | 25 +++++++ 6 files changed, 189 insertions(+), 15 deletions(-) diff --git a/backend/db.js b/backend/db.js index f309c50..35db2dc 100644 --- a/backend/db.js +++ b/backend/db.js @@ -189,6 +189,21 @@ const initDB = async () => { ); `); + // The release decision remains auditable when the plan changes status. + await pool.query(` + CREATE TABLE IF NOT EXISTS production_plan_reviews ( + id SERIAL PRIMARY KEY, + production_plan_id INTEGER NOT NULL UNIQUE REFERENCES production_plans(id) ON DELETE CASCADE, + decision VARCHAR(30) NOT NULL DEFAULT 'pending', + checklist JSONB NOT NULL DEFAULT '{}'::jsonb, + notes TEXT, + reviewed_by TEXT, + reviewed_at TIMESTAMPTZ, + created_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP + ); + `); + await pool.query(` ALTER TABLE production_orders ADD COLUMN IF NOT EXISTS production_plan_id INTEGER REFERENCES production_plans(id) ON DELETE SET NULL, diff --git a/backend/routes/productionOrderRoutes.js b/backend/routes/productionOrderRoutes.js index ccebc86..c6a31be 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 { addProductionOrderDefect, createProductionOrders, createProductionWorkstation, importTinyProductCompositionExport, listProductionOrders, listProductionPlans, listProductionWorkstations, updateProductionOrderExecution, updateProductionOrderStatus, updateProductionPlan, updateProductionPlanStatus, updateProductionWorkstation, upsertTinyProductionOrderDetail } = require('../services/productionOrderService'); +const { addProductionOrderDefect, createProductionOrders, createProductionWorkstation, importTinyProductCompositionExport, listProductionOrders, listProductionPlans, listProductionWorkstations, saveProductionPlanReview, updateProductionOrderExecution, updateProductionOrderStatus, updateProductionPlan, updateProductionPlanStatus, updateProductionWorkstation, upsertTinyProductionOrderDetail } = require('../services/productionOrderService'); const router = express.Router(); @@ -75,6 +75,14 @@ router.patch('/production-plans/:id/status', verifyToken, async (req, res) => { } }); +router.put('/production-plans/:id/review', verifyToken, async (req, res) => { + try { + res.json(await saveProductionPlanReview(req.params.id, req.body || {})); + } catch (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 f725ef6..0a13455 100644 --- a/backend/services/productionOrderService.js +++ b/backend/services/productionOrderService.js @@ -351,10 +351,57 @@ const mapProductionPlanRow = (row) => ({ actualCutQuantity: Number(row.actual_cut_quantity || 0), finishedQuantity: Number(row.finished_quantity || 0), statusCounts: row.status_counts || {}, + review: row.review_id ? { + id: Number(row.review_id), + decision: row.review_decision || 'pending', + checklist: row.review_checklist || {}, + notes: row.review_notes || '', + reviewedBy: row.reviewed_by || '', + reviewedAt: row.reviewed_at || null, + updatedAt: row.review_updated_at || null + } : null, createdAt: row.created_at || null, updatedAt: row.updated_at || null }); +const REVIEW_DECISIONS = new Set(['pending', 'approved', 'adjustments_required']); +const REVIEW_CHECKLIST_KEYS = new Set(['materialsReserved', 'cutSpecificationReady', 'sizeAndColorReviewed', 'responsibleAndDateDefined']); + +const normalizeProductionPlanReview = (payload = {}) => { + const decision = normalizeText(payload.decision).toLowerCase() || 'pending'; + if (!REVIEW_DECISIONS.has(decision)) { + const error = new Error('Decisão de revisão inválida.'); + error.statusCode = 400; + throw error; + } + const rawChecklist = payload.checklist && typeof payload.checklist === 'object' ? payload.checklist : {}; + const checklist = {}; + REVIEW_CHECKLIST_KEYS.forEach(key => { checklist[key] = Boolean(rawChecklist[key]); }); + return { decision, checklist, notes: normalizeText(payload.notes), reviewedBy: normalizeText(payload.reviewedBy) }; +}; + +const saveProductionPlanReview = async (id, payload = {}) => { + const review = normalizeProductionPlanReview(payload); + const planExists = await pool.query('SELECT id FROM production_plans WHERE id = $1', [id]); + if (!planExists.rowCount) { + const error = new Error('Plano de produção não encontrado.'); + error.statusCode = 404; + throw error; + } + await pool.query(` + INSERT INTO production_plan_reviews (production_plan_id, decision, checklist, notes, reviewed_by, reviewed_at, updated_at) + VALUES ($1, $2, $3::jsonb, NULLIF($4, ''), NULLIF($5, ''), CURRENT_TIMESTAMP, CURRENT_TIMESTAMP) + ON CONFLICT (production_plan_id) DO UPDATE + SET decision = EXCLUDED.decision, + checklist = EXCLUDED.checklist, + notes = EXCLUDED.notes, + reviewed_by = EXCLUDED.reviewed_by, + reviewed_at = CURRENT_TIMESTAMP, + updated_at = CURRENT_TIMESTAMP + `, [id, review.decision, JSON.stringify(review.checklist), review.notes, review.reviewedBy]); + return getProductionPlanById(id); +}; + const normalizePlanPayload = (payload = {}, fallback = {}) => ({ id: normalizeInteger(payload.id), name: normalizeText(payload.name) || normalizeText(fallback.name), @@ -388,12 +435,16 @@ const getProductionPlanById = async (id, client = pool) => { WHERE production_plan_id = plan.id GROUP BY status ) grouped_statuses - ), '{}'::json) AS status_counts + ), '{}'::json) AS status_counts, + review.id AS review_id, review.decision AS review_decision, + review.checklist AS review_checklist, review.notes AS review_notes, + review.reviewed_by, review.reviewed_at, review.updated_at AS review_updated_at FROM production_plans plan LEFT JOIN production_orders po ON po.production_plan_id = plan.id LEFT JOIN app_users responsible ON responsible.id = plan.responsible_user_id + LEFT JOIN production_plan_reviews review ON review.production_plan_id = plan.id WHERE plan.id = $1 - GROUP BY plan.id, responsible.id; + GROUP BY plan.id, responsible.id, review.id; `, [id]); return result.rows[0] ? mapProductionPlanRow(result.rows[0]) : null; }; @@ -789,11 +840,15 @@ const listProductionPlans = async () => { WHERE production_plan_id = plan.id GROUP BY status ) grouped_statuses - ), '{}'::json) AS status_counts + ), '{}'::json) AS status_counts, + review.id AS review_id, review.decision AS review_decision, + review.checklist AS review_checklist, review.notes AS review_notes, + review.reviewed_by, review.reviewed_at, review.updated_at AS review_updated_at FROM production_plans plan LEFT JOIN production_orders po ON po.production_plan_id = plan.id LEFT JOIN app_users responsible ON responsible.id = plan.responsible_user_id - GROUP BY plan.id, responsible.id + LEFT JOIN production_plan_reviews review ON review.production_plan_id = plan.id + GROUP BY plan.id, responsible.id, review.id ORDER BY COALESCE(plan.planned_start_date, plan.created_at::date) DESC, plan.id DESC; `); return result.rows.map(mapProductionPlanRow); @@ -1942,6 +1997,7 @@ module.exports = { getProductComposition, importTinyProductCompositionExport, listProductionPlans, + saveProductionPlanReview, listProductionWorkstations, listProductCompositions, listProductionOrders, diff --git a/src/dataService.ts b/src/dataService.ts index 52083a2..ca7c234 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, MaterialTransformationStatus, OperationalUser, OrderData, OlistRunDetails, OlistStatus, ProductAnalyticsItem, ProductComposition, ProductCompositionImportSummary, ProductCompositionPayload, ProductDetailsAnalytics, ProductionOrderDefectPayload, ProductionOrderExecutionPayload, ProductionOrderImportData, ProductionOrderItem, ProductionOrderPayload, ProductionOrderStatus, ProductionOrderSummary, ProductionPlan, ProductionPlanPayload, ProductionWorkstation, ProductionWorkstationPayload, RfmAnalytics, SellerAnalyticsItem, SellerDetailsAnalytics, StockData, SupplyFabricPlan, SupplyFabricPlanPayload, SupplyInventoryAdjustmentPayload, SupplyLot, SupplyMaterialDetailsPayload, SupplyMaterialTransformation, SupplyMaterialTransformationPayload, 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, MaterialTransformationStatus, OperationalUser, OrderData, OlistRunDetails, OlistStatus, ProductAnalyticsItem, ProductComposition, ProductCompositionImportSummary, ProductCompositionPayload, ProductDetailsAnalytics, ProductionOrderDefectPayload, ProductionOrderExecutionPayload, ProductionOrderImportData, ProductionOrderItem, ProductionOrderPayload, ProductionOrderStatus, ProductionOrderSummary, ProductionPlan, ProductionPlanPayload, ProductionPlanReviewPayload, ProductionWorkstation, ProductionWorkstationPayload, RfmAnalytics, SellerAnalyticsItem, SellerDetailsAnalytics, StockData, SupplyFabricPlan, SupplyFabricPlanPayload, SupplyInventoryAdjustmentPayload, SupplyLot, SupplyMaterialDetailsPayload, SupplyMaterialTransformation, SupplyMaterialTransformationPayload, SupplyProductionExitPayload, SupplyPurchaseNeed, SupplyPurchaseOrder, SupplyPurchaseOrderPayload, SupplyPurchaseOrderStatus, SupplyReceipt, SupplyReceiptPayload, SupplySummary } from './types'; import { formatDateParam } from './dateRanges'; const API_URL = import.meta.env.VITE_API_URL || '/api'; @@ -281,6 +281,16 @@ export const updateProductionPlanStatus = async (id: number, status: ProductionO return data as ProductionPlan; }; +export const saveProductionPlanReview = async (id: number, payload: ProductionPlanReviewPayload): Promise => { + const response = await authFetch(`/production-plans/${id}/review`, { + 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 salvar a revisão operacional.'); + 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/ProductionPlans.tsx b/src/pages/ProductionPlans.tsx index 53ab2b2..8a0ba0a 100644 --- a/src/pages/ProductionPlans.tsx +++ b/src/pages/ProductionPlans.tsx @@ -1,8 +1,8 @@ import { useCallback, useEffect, useMemo, useState } from 'react'; import { Link } from 'react-router-dom'; -import { ArrowLeft, Check, CheckCircle2, ChevronDown, ClipboardList, Factory, Loader2, PlayCircle, UserRound, type LucideIcon } from 'lucide-react'; -import { fetchOperationalUsers, fetchProductionPlans, updateProductionPlan, updateProductionPlanStatus } from '../dataService'; -import type { OperationalUser, ProductionOrderStatus, ProductionPlan } from '../types'; +import { ArrowLeft, Check, CheckCircle2, ChevronDown, ClipboardCheck, ClipboardList, Factory, Loader2, PlayCircle, UserRound, X, type LucideIcon } from 'lucide-react'; +import { fetchOperationalUsers, fetchProductionPlans, saveProductionPlanReview, updateProductionPlan, updateProductionPlanStatus } from '../dataService'; +import type { OperationalUser, ProductionOrderStatus, ProductionPlan, ProductionPlanReviewDecision } 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' }, @@ -28,6 +28,16 @@ const ProductionPlans = () => { const [busyPlanId, setBusyPlanId] = useState(null); const [operationalUsers, setOperationalUsers] = useState([]); const [responsiblePickerPlanId, setResponsiblePickerPlanId] = useState(null); + const [reviewingPlanId, setReviewingPlanId] = useState(null); + const [reviewForm, setReviewForm] = useState({ + decision: 'pending' as ProductionPlanReviewDecision, + materialsReserved: false, + cutSpecificationReady: false, + sizeAndColorReviewed: false, + responsibleAndDateDefined: false, + notes: '', + reviewedBy: '', + }); const [message, setMessage] = useState(''); const loadPlans = useCallback(async () => { @@ -92,6 +102,53 @@ const ProductionPlans = () => { } }; + const openReview = (plan: ProductionPlan) => { + const checklist = plan.review?.checklist || {}; + setReviewForm({ + decision: plan.review?.decision || 'pending', + materialsReserved: Boolean(checklist.materialsReserved), + cutSpecificationReady: Boolean(checklist.cutSpecificationReady), + sizeAndColorReviewed: Boolean(checklist.sizeAndColorReviewed), + responsibleAndDateDefined: Boolean(checklist.responsibleAndDateDefined), + notes: plan.review?.notes || '', + reviewedBy: plan.review?.reviewedBy || plan.responsibleName || '', + }); + setResponsiblePickerPlanId(null); + setReviewingPlanId(plan.id); + }; + + const submitReview = async (plan: ProductionPlan) => { + setBusyPlanId(plan.id); + setMessage(''); + try { + const updated = await saveProductionPlanReview(plan.id, { + decision: reviewForm.decision, + checklist: { + materialsReserved: reviewForm.materialsReserved, + cutSpecificationReady: reviewForm.cutSpecificationReady, + sizeAndColorReviewed: reviewForm.sizeAndColorReviewed, + responsibleAndDateDefined: reviewForm.responsibleAndDateDefined, + }, + notes: reviewForm.notes, + reviewedBy: reviewForm.reviewedBy, + }); + setPlans(current => current.map(item => item.id === updated.id ? updated : item)); + setReviewingPlanId(null); + setMessage(updated.review?.decision === 'approved' ? `${updated.number} liberado para operação.` : `Revisão operacional de ${updated.number} registrada.`); + } catch (error) { + setMessage(error instanceof Error ? error.message : 'Não foi possível salvar a revisão.'); + } finally { + setBusyPlanId(null); + } + }; + + const reviewDecisionLabel: Record = { pending: 'Em revisão', approved: 'Liberado', adjustments_required: 'Ajustes necessários' }; + const reviewDecisionStyle = (decision?: ProductionPlanReviewDecision) => decision === 'approved' + ? 'border-emerald-400/30 bg-emerald-400/10 text-emerald-300' + : decision === 'adjustments_required' + ? 'border-amber-400/30 bg-amber-400/10 text-amber-300' + : 'border-dark-border bg-dark-input text-dark-muted'; + return (
@@ -122,12 +179,15 @@ const ProductionPlans = () => { {isLoading ?
: plans.length ? (
{plans.map(plan => ( -
-
{plan.number}{plan.statusLabel}

{plan.name}

{plan.responsibleName ? `Responsável: ${plan.responsibleName}` : plan.contractor ? `Responsável: ${plan.contractor}` : 'Sem responsável definido'} · 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()})}
-

Responsável

{responsiblePickerPlanId === plan.id &&
Usuários ativos do Graphs
{operationalUsers.map(user => )}{!operationalUsers.length &&

Nenhum usuário ativo encontrado.

}
{plan.responsibleName && }
}
- +
+
+
{plan.number}{plan.statusLabel}{plan.review && {reviewDecisionLabel[plan.review.decision]}}

{plan.name}

{plan.responsibleName ? `Responsável: ${plan.responsibleName}` : plan.contractor ? `Responsável: ${plan.contractor}` : 'Sem responsável definido'} · 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()})}
+

Responsável

{responsiblePickerPlanId === plan.id &&
Usuários ativos do Graphs
{operationalUsers.map(user => )}{!operationalUsers.length &&

Nenhum usuário ativo encontrado.

}
{plan.responsibleName && }
}
+
+
+ {reviewingPlanId === plan.id &&

Liberação operacional

Registre a decisão antes de avançar o plano. Este registro não muda o estoque nem substitui o recebimento.

{[{ key: 'materialsReserved', label: 'Materiais reservados' }, { key: 'cutSpecificationReady', label: 'Ficha e corte definidos' }, { key: 'sizeAndColorReviewed', label: 'Tamanhos e cores revisados' }, { key: 'responsibleAndDateDefined', label: 'Responsável e data definidos' }].map(item => )}
}
))}
diff --git a/src/types.ts b/src/types.ts index acf524a..1a307f9 100644 --- a/src/types.ts +++ b/src/types.ts @@ -232,10 +232,35 @@ export interface ProductionPlan { actualCutQuantity: number; finishedQuantity: number; statusCounts: Record; + review: ProductionPlanReview | null; createdAt: string | null; updatedAt: string | null; } +export type ProductionPlanReviewDecision = 'pending' | 'approved' | 'adjustments_required'; + +export interface ProductionPlanReview { + id: number; + decision: ProductionPlanReviewDecision; + checklist: { + materialsReserved?: boolean; + cutSpecificationReady?: boolean; + sizeAndColorReviewed?: boolean; + responsibleAndDateDefined?: boolean; + }; + notes: string; + reviewedBy: string; + reviewedAt: string | null; + updatedAt: string | null; +} + +export type ProductionPlanReviewPayload = { + decision: ProductionPlanReviewDecision; + checklist: ProductionPlanReview['checklist']; + notes?: string; + reviewedBy?: string; +}; + export type ProductionPlanPayload = { id?: number; name: string;