From 2b7c132300ac29203927e076f792f99479a88823 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Cau=C3=AA=20Faleiros?= Date: Thu, 27 Aug 2026 19:38:08 -0300 Subject: [PATCH] Improve production plan operations --- backend/services/productionOrderService.js | 12 ++--- src/pages/ProductionPlans.tsx | 57 ++++++++++++++++------ 2 files changed, 48 insertions(+), 21 deletions(-) diff --git a/backend/services/productionOrderService.js b/backend/services/productionOrderService.js index e70ef73..832c624 100644 --- a/backend/services/productionOrderService.js +++ b/backend/services/productionOrderService.js @@ -900,9 +900,9 @@ const updateProductionPlanStatus = async (id, status) => { 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, + UPDATE production_plans + SET status = $1::text, + completed_at = CASE WHEN $1::text = 'finished' THEN COALESCE(completed_at, CURRENT_TIMESTAMP) ELSE NULL END, updated_at = CURRENT_TIMESTAMP WHERE id = $2 RETURNING id; @@ -913,9 +913,9 @@ const updateProductionPlanStatus = async (id, status) => { 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, + UPDATE production_orders + SET status = $1::text, + completed_date = CASE WHEN $1::text = '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'); diff --git a/src/pages/ProductionPlans.tsx b/src/pages/ProductionPlans.tsx index 8a0ba0a..f778437 100644 --- a/src/pages/ProductionPlans.tsx +++ b/src/pages/ProductionPlans.tsx @@ -1,6 +1,6 @@ import { useCallback, useEffect, useMemo, useState } from 'react'; import { Link } from 'react-router-dom'; -import { ArrowLeft, Check, CheckCircle2, ChevronDown, ClipboardCheck, ClipboardList, Factory, Loader2, PlayCircle, UserRound, X, type LucideIcon } from 'lucide-react'; +import { ArrowLeft, CalendarDays, Check, CheckCircle2, ChevronDown, ClipboardCheck, ClipboardList, Factory, Loader2, PlayCircle, RefreshCw, UserRound, X, type LucideIcon } from 'lucide-react'; import { fetchOperationalUsers, fetchProductionPlans, saveProductionPlanReview, updateProductionPlan, updateProductionPlanStatus } from '../dataService'; import type { OperationalUser, ProductionOrderStatus, ProductionPlan, ProductionPlanReviewDecision } from '../types'; @@ -25,6 +25,8 @@ const planMetrics: Array<{ label: string; value: string | number; icon: LucideIc const ProductionPlans = () => { const [plans, setPlans] = useState([]); const [isLoading, setIsLoading] = useState(true); + const [isRefreshing, setIsRefreshing] = useState(false); + const [isLoadingOperationalUsers, setIsLoadingOperationalUsers] = useState(false); const [busyPlanId, setBusyPlanId] = useState(null); const [operationalUsers, setOperationalUsers] = useState([]); const [responsiblePickerPlanId, setResponsiblePickerPlanId] = useState(null); @@ -52,7 +54,12 @@ const ProductionPlans = () => { }, []); const loadOperationalUsers = useCallback(async () => { - setOperationalUsers(await fetchOperationalUsers()); + setIsLoadingOperationalUsers(true); + try { + setOperationalUsers(await fetchOperationalUsers()); + } finally { + setIsLoadingOperationalUsers(false); + } }, []); useEffect(() => { @@ -63,6 +70,20 @@ const ProductionPlans = () => { 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 refreshPlans = async () => { + setIsRefreshing(true); + try { + await Promise.all([loadPlans(), loadOperationalUsers()]); + } finally { + setIsRefreshing(false); + } + }; + + const toggleResponsiblePicker = (planId: number) => { + setResponsiblePickerPlanId(current => current === planId ? null : planId); + if (!operationalUsers.length) void loadOperationalUsers(); + }; + const movePlan = async (plan: ProductionPlan, status: ProductionOrderStatus) => { if (status === plan.status) return; setBusyPlanId(plan.id); @@ -150,14 +171,15 @@ const ProductionPlans = () => { : 'border-dark-border bg-dark-input text-dark-muted'; 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.

+

PCP

+

Planos de Produção

+

Acompanhe cada frente de produção, o responsável e o estágio das OPs sem perder o histórico do plano.

- +
@@ -168,25 +190,30 @@ const ProductionPlans = () => { ? activePlans.reduce((total, plan) => total + plan.orderCount, 0) : formatNumber(totalOpenQuantity); const MetricIcon = metric.icon; - return

{metric.label}

{value}

; + const description = metric.label === 'Planos ativos' ? 'Frentes em acompanhamento' : metric.label === 'OPs planejadas' ? 'Ordens vinculadas aos planos ativos' : 'Quantidade ainda em operação'; + return

{metric.label}

{value}

{description}

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

+
+

Fila de planos

Mover um plano atualiza as OPs locais ativas. OPs finalizadas ou canceladas preservam o histórico.

{plans.length} {plans.length === 1 ? 'plano' : 'planos'}
{isLoading ?
: plans.length ? (
{plans.map(plan => (
-
-
{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 && }
}
-
+
+
{plan.number}{plan.statusLabel}{plan.review && {reviewDecisionLabel[plan.review.decision]}}

{plan.name}

{plan.notes &&

{plan.notes}

}
+
+
+

Escopo

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

Corte previsto pelo plano

+

Andamento

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()})}
+

Prazo

{formatDate(plan.expectedDate)}

Início: {formatDate(plan.plannedStartDate)}

+

Responsável

{plan.contractor || 'Sem facção definida'}

+
+ {responsiblePickerPlanId === plan.id &&

Atribuir responsável

Selecione um usuário ativo do Graphs para acompanhar este plano.

{isLoadingOperationalUsers ?
Carregando usuários...
: operationalUsers.map(user => )}{!isLoadingOperationalUsers && !operationalUsers.length &&

Nenhum usuário ativo encontrado. Cadastre ou reative um usuário para atribuir o plano.

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