From c259ddea29bf5187ef02970f1e36c2ca4834c119 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Cau=C3=AA=20Faleiros?= Date: Tue, 25 Aug 2026 13:40:52 -0300 Subject: [PATCH] Assign production plans to Graphs users --- backend/db.js | 7 +++ backend/routes/userRoutes.js | 12 ++++- backend/services/productionOrderService.js | 46 ++++++++++++++----- backend/services/userService.js | 31 +++++++++++++ src/dataService.ts | 14 +++++- src/pages/Cutting.tsx | 51 ++++++++++++++++++---- src/pages/ProductionPlans.tsx | 2 +- src/types.ts | 8 ++++ 8 files changed, 147 insertions(+), 24 deletions(-) diff --git a/backend/db.js b/backend/db.js index 595743c..e6fd74f 100644 --- a/backend/db.js +++ b/backend/db.js @@ -792,6 +792,13 @@ const initDB = async () => { ALTER COLUMN updated_at SET DEFAULT CURRENT_TIMESTAMP; `).catch(() => {}); + // A production plan is operated by a Graphs user. The ID link will let + // role-based assignments be added without changing historical plans. + await pool.query(` + ALTER TABLE production_plans + ADD COLUMN IF NOT EXISTS responsible_user_id INTEGER REFERENCES app_users(id) ON DELETE SET NULL; + `).catch(() => {}); + await pool.query(` ALTER TABLE stock_campaign_queue ALTER COLUMN created_at TYPE TIMESTAMPTZ USING created_at AT TIME ZONE 'America/Sao_Paulo', diff --git a/backend/routes/userRoutes.js b/backend/routes/userRoutes.js index 8e66c6c..dd0bf4c 100644 --- a/backend/routes/userRoutes.js +++ b/backend/routes/userRoutes.js @@ -1,9 +1,17 @@ const express = require('express'); -const { verifySuperAdmin } = require('../auth'); -const { createUser, deleteUser, listUsers, updateUser } = require('../services/userService'); +const { verifySuperAdmin, verifyToken } = require('../auth'); +const { createUser, deleteUser, listActiveOperationalUsers, listUsers, updateUser } = require('../services/userService'); const router = express.Router(); +router.get('/users/operational', verifyToken, async (req, res, next) => { + try { + res.json({ users: await listActiveOperationalUsers() }); + } catch (error) { + next(error); + } +}); + router.get('/users', verifySuperAdmin, async (req, res, next) => { try { const users = await listUsers(); diff --git a/backend/services/productionOrderService.js b/backend/services/productionOrderService.js index c7493f2..4dd0c4c 100644 --- a/backend/services/productionOrderService.js +++ b/backend/services/productionOrderService.js @@ -1,4 +1,5 @@ const { pool } = require('../db'); +const { getActiveOperationalUserById } = require('./userService'); const STATUS_LABELS = { open: 'Em aberto', @@ -330,6 +331,8 @@ const mapProductionPlanRow = (row) => ({ status: normalizeStatus(row.status), statusLabel: STATUS_LABELS[normalizeStatus(row.status)] || row.status || 'Em aberto', contractor: row.contractor || '', + responsibleUserId: row.responsible_user_id === null || row.responsible_user_id === undefined ? null : Number(row.responsible_user_id), + responsibleName: row.responsible_name || '', plannedStartDate: formatDate(row.planned_start_date), expectedDate: formatDate(row.expected_date), completedAt: row.completed_at || null, @@ -348,6 +351,9 @@ const normalizePlanPayload = (payload = {}, fallback = {}) => ({ id: normalizeInteger(payload.id), name: normalizeText(payload.name) || normalizeText(fallback.name), contractor: normalizeText(payload.contractor), + responsibleUserId: payload.responsibleUserId === undefined + ? normalizeInteger(fallback.responsibleUserId) + : normalizeInteger(payload.responsibleUserId), plannedStartDate: normalizeDateParam(payload.plannedStartDate || fallback.plannedStartDate), expectedDate: normalizeDateParam(payload.expectedDate || fallback.expectedDate), notes: normalizeText(payload.notes), @@ -357,7 +363,8 @@ const normalizePlanPayload = (payload = {}, fallback = {}) => ({ const getProductionPlanById = async (id, client = pool) => { const result = await client.query(` SELECT - plan.id, plan.number, plan.name, plan.status, plan.contractor, + plan.id, plan.number, plan.name, plan.status, plan.contractor, plan.responsible_user_id, + responsible.name AS responsible_name, 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, @@ -375,8 +382,9 @@ const getProductionPlanById = async (id, client = pool) => { ), '{}'::json) AS status_counts 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 WHERE plan.id = $1 - GROUP BY plan.id; + GROUP BY plan.id, responsible.id; `, [id]); return result.rows[0] ? mapProductionPlanRow(result.rows[0]) : null; }; @@ -394,11 +402,17 @@ const createOrGetProductionPlan = async (client, payload, fallback) => { } if (!plan.name) return null; + if (plan.responsibleUserId && !await getActiveOperationalUserById(plan.responsibleUserId, client)) { + const error = new Error('O responsável selecionado não está disponível.'); + error.statusCode = 400; + throw error; + } + 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) + INSERT INTO production_plans (name, status, contractor, responsible_user_id, planned_start_date, expected_date, notes, source) + VALUES ($1, 'open', NULLIF($2, ''), $3, $4, $5, NULLIF($6, ''), $7) RETURNING id; - `, [plan.name, plan.contractor, plan.plannedStartDate, plan.expectedDate, plan.notes, plan.source]); + `, [plan.name, plan.contractor, plan.responsibleUserId, 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); @@ -734,7 +748,8 @@ const listProductionOrders = async (filters = {}) => { const listProductionPlans = async () => { const result = await pool.query(` SELECT - plan.id, plan.number, plan.name, plan.status, plan.contractor, + plan.id, plan.number, plan.name, plan.status, plan.contractor, plan.responsible_user_id, + responsible.name AS responsible_name, 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, @@ -752,7 +767,8 @@ const listProductionPlans = async () => { ), '{}'::json) AS status_counts FROM production_plans plan LEFT JOIN production_orders po ON po.production_plan_id = plan.id - GROUP BY plan.id + LEFT JOIN app_users responsible ON responsible.id = plan.responsible_user_id + GROUP BY plan.id, responsible.id ORDER BY COALESCE(plan.planned_start_date, plan.created_at::date) DESC, plan.id DESC; `); return result.rows.map(mapProductionPlanRow); @@ -765,17 +781,23 @@ const updateProductionPlan = async (id, payload = {}) => { error.statusCode = 400; throw error; } + if (plan.responsibleUserId && !await getActiveOperationalUserById(plan.responsibleUserId)) { + const error = new Error('O responsável selecionado não está disponível.'); + 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, ''), + responsible_user_id = $3, + planned_start_date = $4, + expected_date = $5, + notes = NULLIF($6, ''), updated_at = CURRENT_TIMESTAMP - WHERE id = $6 + WHERE id = $7 RETURNING id; - `, [plan.name, plan.contractor, plan.plannedStartDate, plan.expectedDate, plan.notes, id]); + `, [plan.name, plan.contractor, plan.responsibleUserId, plan.plannedStartDate, plan.expectedDate, plan.notes, id]); if (!result.rowCount) { const error = new Error('Plano de produção não encontrado.'); error.statusCode = 404; diff --git a/backend/services/userService.js b/backend/services/userService.js index 5ba7def..2244453 100644 --- a/backend/services/userService.js +++ b/backend/services/userService.js @@ -59,6 +59,35 @@ const listUsers = async () => { return result.rows.map(publicUserFields); }; +// This deliberately exposes only the identity needed to assign operational +// responsibility. It is available to signed-in Graphs users, unlike the full +// user-management list which remains super-admin only. +const listActiveOperationalUsers = async () => { + const result = await pool.query( + `SELECT id, name + FROM app_users + WHERE is_active = TRUE + ORDER BY LOWER(name), id` + ); + + return result.rows.map((row) => ({ + id: Number(row.id), + name: row.name + })); +}; + +const getActiveOperationalUserById = async (id, client = pool) => { + const result = await client.query( + `SELECT id, name + FROM app_users + WHERE id = $1 AND is_active = TRUE + LIMIT 1`, + [id] + ); + + return result.rows[0] || null; +}; + const createUser = async ({ name, email, password }) => { const normalizedName = String(name || '').trim(); const normalizedEmail = normalizeEmail(email); @@ -200,6 +229,8 @@ module.exports = { findUserById, findUserByEmail, generatePassword, + getActiveOperationalUserById, + listActiveOperationalUsers, listUsers, normalizeEmail, publicUserFields, diff --git a/src/dataService.ts b/src/dataService.ts index af55590..44aae44 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, ProductionPlan, ProductionPlanPayload, 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, OperationalUser, 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'; @@ -1070,6 +1070,18 @@ export const fetchUsers = async (): Promise => { } }; +export const fetchOperationalUsers = async (): Promise => { + try { + const response = await authFetch('/users/operational'); + if (!response.ok) return []; + const data = await response.json() as { users?: OperationalUser[] }; + return data.users || []; + } catch (error) { + console.error('Fetch operational users failed', error); + return []; + } +}; + export const createUser = async (payload: { name: string; email: string; password?: string }): Promise => { const response = await authFetch('/users', { method: 'POST', diff --git a/src/pages/Cutting.tsx b/src/pages/Cutting.tsx index 91b87a7..590a80e 100644 --- a/src/pages/Cutting.tsx +++ b/src/pages/Cutting.tsx @@ -1,13 +1,13 @@ import { useEffect, useMemo, useState } from 'react'; import { Link, useOutletContext, useSearchParams } from 'react-router-dom'; -import { AlertTriangle, ArrowLeft, ArrowRight, ClipboardList, Download, Eye, Layers3, Pencil, RotateCcw, Ruler, Save as SaveIcon, Scissors, Search, Settings2, X } from 'lucide-react'; +import { AlertTriangle, ArrowLeft, ArrowRight, Check, ChevronDown, ClipboardList, Download, Eye, Layers3, Pencil, RotateCcw, Ruler, Save as SaveIcon, Scissors, Search, Settings2, UserRound, X } from 'lucide-react'; import DateRangePicker from '../components/DateRangePicker'; import PaginationControls from '../components/PaginationControls'; import ProductColorBadge from '../components/ProductColorBadge'; import RefreshStatus from '../components/RefreshStatus'; import { CUT_FAMILY_RULES, buildCutPlan, buildOpenProductionByProductId, type CutFamilyKey, type CutIssue, type CutPlanSkuRow, type CutProductOverride } from '../analytics/cutting'; -import { createProductionOrders, exportToCSV, fetchCuttingSettings, fetchProductAnalytics, fetchProductCompositions, fetchProductionOrders, fetchProductionOrderImportData, fetchStock, fetchSupplySummary, saveCuttingSettings } from '../dataService'; -import type { CuttingSettings, DateRange, ProductAnalyticsItem, ProductComposition, ProductionOrderItem, ProductionYieldRecommendation, StockData, SupplyLot } from '../types'; +import { createProductionOrders, exportToCSV, fetchCuttingSettings, fetchOperationalUsers, fetchProductAnalytics, fetchProductCompositions, fetchProductionOrders, fetchProductionOrderImportData, fetchStock, fetchSupplySummary, saveCuttingSettings } from '../dataService'; +import type { CuttingSettings, DateRange, OperationalUser, ProductAnalyticsItem, ProductComposition, ProductionOrderItem, ProductionYieldRecommendation, StockData, SupplyLot } from '../types'; import { getPlanningStock } from '../planningStock'; type CutFilter = 'need' | 'all' | 'issues' | 'covered'; @@ -169,7 +169,10 @@ const Cutting = () => { const [previewItemsPerPage, setPreviewItemsPerPage] = useState(10); const [selectedPreviewRowIds, setSelectedPreviewRowIds] = useState([]); const [lotSelections, setLotSelections] = useState>({}); - const [productionPlanForm, setProductionPlanForm] = useState({ name: '', contractor: '', expectedDate: '', notes: '' }); + const [productionPlanForm, setProductionPlanForm] = useState({ name: '', responsibleUserId: null as number | null, expectedDate: '', notes: '' }); + const [operationalUsers, setOperationalUsers] = useState([]); + const [isLoadingOperationalUsers, setIsLoadingOperationalUsers] = useState(false); + const [isResponsiblePickerOpen, setIsResponsiblePickerOpen] = useState(false); const [isPlanDetailsOpen, setIsPlanDetailsOpen] = useState(false); const [editingCutRow, setEditingCutRow] = useState(null); const [quickCutEdit, setQuickCutEdit] = useState({}); @@ -395,6 +398,9 @@ const Cutting = () => { const orderPreviewTotalUnits = useMemo(() => ( selectedPreviewRows.reduce((total, row) => total + row.suggestedCutQuantity, 0) ), [selectedPreviewRows]); + const selectedResponsibleUser = useMemo(() => ( + operationalUsers.find((user) => user.id === productionPlanForm.responsibleUserId) || null + ), [operationalUsers, productionPlanForm.responsibleUserId]); const cutPlanReference = useMemo(() => ( `Plano de corte ${formatDateKey(dateRange.start)} a ${formatDateKey(dateRange.end)} · ${targetCoverageDays} dias` @@ -670,9 +676,12 @@ const Cutting = () => { setPreviewPage(1); setSelectedPreviewRowIds(rowsAvailableForOrder.map(row => row.id)); setLotSelections({}); - setProductionPlanForm({ name: cutPlanReference, contractor: '', expectedDate: '', notes: '' }); + setProductionPlanForm({ name: cutPlanReference, responsibleUserId: null, expectedDate: '', notes: '' }); setIsPlanDetailsOpen(false); + setIsResponsiblePickerOpen(false); setPreviewStep('selection'); + setIsLoadingOperationalUsers(true); + void fetchOperationalUsers().then(setOperationalUsers).finally(() => setIsLoadingOperationalUsers(false)); setIsOrderPreviewOpen(true); }; @@ -755,7 +764,7 @@ const Cutting = () => { } })), selectedLotReservations, { name: productionPlanForm.name.trim() || cutPlanReference, - contractor: productionPlanForm.contractor.trim(), + responsibleUserId: productionPlanForm.responsibleUserId, plannedStartDate: issueDate, expectedDate: productionPlanForm.expectedDate || null, notes: productionPlanForm.notes.trim(), @@ -934,12 +943,38 @@ const Cutting = () => { +
+ Responsável + + {isResponsiblePickerOpen &&
+
Usuários ativos do Graphs
+
+ {isLoadingOperationalUsers ?

Carregando usuários...

: operationalUsers.length ? operationalUsers.map(user => ( + + )) :

Nenhum usuário ativo encontrado.

} +
+ {selectedResponsibleUser && } +
} +
- {isPlanDetailsOpen &&
- + {isPlanDetailsOpen &&
} diff --git a/src/pages/ProductionPlans.tsx b/src/pages/ProductionPlans.tsx index bfb47f4..fcce0f4 100644 --- a/src/pages/ProductionPlans.tsx +++ b/src/pages/ProductionPlans.tsx @@ -93,7 +93,7 @@ const ProductionPlans = () => {
{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}

}
+
{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()})}
diff --git a/src/types.ts b/src/types.ts index a2c476f..c6a6453 100644 --- a/src/types.ts +++ b/src/types.ts @@ -181,6 +181,8 @@ export interface ProductionPlan { status: ProductionOrderStatus; statusLabel: string; contractor: string; + responsibleUserId: number | null; + responsibleName: string; plannedStartDate: string | null; expectedDate: string | null; completedAt: string | null; @@ -199,6 +201,7 @@ export type ProductionPlanPayload = { id?: number; name: string; contractor?: string; + responsibleUserId?: number | null; plannedStartDate?: string | null; expectedDate?: string | null; notes?: string; @@ -626,6 +629,11 @@ export interface ManagedUser { updatedAt: string; } +export interface OperationalUser { + id: number; + name: string; +} + export interface CreateUserResult { user: ManagedUser; temporaryPassword?: string;