Assign production plans to Graphs users
This commit is contained in:
@@ -792,6 +792,13 @@ const initDB = async () => {
|
|||||||
ALTER COLUMN updated_at SET DEFAULT CURRENT_TIMESTAMP;
|
ALTER COLUMN updated_at SET DEFAULT CURRENT_TIMESTAMP;
|
||||||
`).catch(() => {});
|
`).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(`
|
await pool.query(`
|
||||||
ALTER TABLE stock_campaign_queue
|
ALTER TABLE stock_campaign_queue
|
||||||
ALTER COLUMN created_at TYPE TIMESTAMPTZ USING created_at AT TIME ZONE 'America/Sao_Paulo',
|
ALTER COLUMN created_at TYPE TIMESTAMPTZ USING created_at AT TIME ZONE 'America/Sao_Paulo',
|
||||||
|
|||||||
@@ -1,9 +1,17 @@
|
|||||||
const express = require('express');
|
const express = require('express');
|
||||||
const { verifySuperAdmin } = require('../auth');
|
const { verifySuperAdmin, verifyToken } = require('../auth');
|
||||||
const { createUser, deleteUser, listUsers, updateUser } = require('../services/userService');
|
const { createUser, deleteUser, listActiveOperationalUsers, listUsers, updateUser } = require('../services/userService');
|
||||||
|
|
||||||
const router = express.Router();
|
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) => {
|
router.get('/users', verifySuperAdmin, async (req, res, next) => {
|
||||||
try {
|
try {
|
||||||
const users = await listUsers();
|
const users = await listUsers();
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
const { pool } = require('../db');
|
const { pool } = require('../db');
|
||||||
|
const { getActiveOperationalUserById } = require('./userService');
|
||||||
|
|
||||||
const STATUS_LABELS = {
|
const STATUS_LABELS = {
|
||||||
open: 'Em aberto',
|
open: 'Em aberto',
|
||||||
@@ -330,6 +331,8 @@ const mapProductionPlanRow = (row) => ({
|
|||||||
status: normalizeStatus(row.status),
|
status: normalizeStatus(row.status),
|
||||||
statusLabel: STATUS_LABELS[normalizeStatus(row.status)] || row.status || 'Em aberto',
|
statusLabel: STATUS_LABELS[normalizeStatus(row.status)] || row.status || 'Em aberto',
|
||||||
contractor: row.contractor || '',
|
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),
|
plannedStartDate: formatDate(row.planned_start_date),
|
||||||
expectedDate: formatDate(row.expected_date),
|
expectedDate: formatDate(row.expected_date),
|
||||||
completedAt: row.completed_at || null,
|
completedAt: row.completed_at || null,
|
||||||
@@ -348,6 +351,9 @@ const normalizePlanPayload = (payload = {}, fallback = {}) => ({
|
|||||||
id: normalizeInteger(payload.id),
|
id: normalizeInteger(payload.id),
|
||||||
name: normalizeText(payload.name) || normalizeText(fallback.name),
|
name: normalizeText(payload.name) || normalizeText(fallback.name),
|
||||||
contractor: normalizeText(payload.contractor),
|
contractor: normalizeText(payload.contractor),
|
||||||
|
responsibleUserId: payload.responsibleUserId === undefined
|
||||||
|
? normalizeInteger(fallback.responsibleUserId)
|
||||||
|
: normalizeInteger(payload.responsibleUserId),
|
||||||
plannedStartDate: normalizeDateParam(payload.plannedStartDate || fallback.plannedStartDate),
|
plannedStartDate: normalizeDateParam(payload.plannedStartDate || fallback.plannedStartDate),
|
||||||
expectedDate: normalizeDateParam(payload.expectedDate || fallback.expectedDate),
|
expectedDate: normalizeDateParam(payload.expectedDate || fallback.expectedDate),
|
||||||
notes: normalizeText(payload.notes),
|
notes: normalizeText(payload.notes),
|
||||||
@@ -357,7 +363,8 @@ const normalizePlanPayload = (payload = {}, fallback = {}) => ({
|
|||||||
const getProductionPlanById = async (id, client = pool) => {
|
const getProductionPlanById = async (id, client = pool) => {
|
||||||
const result = await client.query(`
|
const result = await client.query(`
|
||||||
SELECT
|
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.planned_start_date, plan.expected_date, plan.completed_at,
|
||||||
plan.notes, plan.source, plan.created_at, plan.updated_at,
|
plan.notes, plan.source, plan.created_at, plan.updated_at,
|
||||||
COUNT(po.id)::int AS order_count,
|
COUNT(po.id)::int AS order_count,
|
||||||
@@ -375,8 +382,9 @@ const getProductionPlanById = async (id, client = pool) => {
|
|||||||
), '{}'::json) AS status_counts
|
), '{}'::json) AS status_counts
|
||||||
FROM production_plans plan
|
FROM production_plans plan
|
||||||
LEFT JOIN production_orders po ON po.production_plan_id = plan.id
|
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
|
WHERE plan.id = $1
|
||||||
GROUP BY plan.id;
|
GROUP BY plan.id, responsible.id;
|
||||||
`, [id]);
|
`, [id]);
|
||||||
return result.rows[0] ? mapProductionPlanRow(result.rows[0]) : null;
|
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.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(`
|
const inserted = await client.query(`
|
||||||
INSERT INTO production_plans (name, status, contractor, planned_start_date, expected_date, notes, source)
|
INSERT INTO production_plans (name, status, contractor, responsible_user_id, planned_start_date, expected_date, notes, source)
|
||||||
VALUES ($1, 'open', NULLIF($2, ''), $3, $4, NULLIF($5, ''), $6)
|
VALUES ($1, 'open', NULLIF($2, ''), $3, $4, $5, NULLIF($6, ''), $7)
|
||||||
RETURNING id;
|
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);
|
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]);
|
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);
|
return getProductionPlanById(id, client);
|
||||||
@@ -734,7 +748,8 @@ const listProductionOrders = async (filters = {}) => {
|
|||||||
const listProductionPlans = async () => {
|
const listProductionPlans = async () => {
|
||||||
const result = await pool.query(`
|
const result = await pool.query(`
|
||||||
SELECT
|
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.planned_start_date, plan.expected_date, plan.completed_at,
|
||||||
plan.notes, plan.source, plan.created_at, plan.updated_at,
|
plan.notes, plan.source, plan.created_at, plan.updated_at,
|
||||||
COUNT(po.id)::int AS order_count,
|
COUNT(po.id)::int AS order_count,
|
||||||
@@ -752,7 +767,8 @@ const listProductionPlans = async () => {
|
|||||||
), '{}'::json) AS status_counts
|
), '{}'::json) AS status_counts
|
||||||
FROM production_plans plan
|
FROM production_plans plan
|
||||||
LEFT JOIN production_orders po ON po.production_plan_id = plan.id
|
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;
|
ORDER BY COALESCE(plan.planned_start_date, plan.created_at::date) DESC, plan.id DESC;
|
||||||
`);
|
`);
|
||||||
return result.rows.map(mapProductionPlanRow);
|
return result.rows.map(mapProductionPlanRow);
|
||||||
@@ -765,17 +781,23 @@ const updateProductionPlan = async (id, payload = {}) => {
|
|||||||
error.statusCode = 400;
|
error.statusCode = 400;
|
||||||
throw error;
|
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(`
|
const result = await pool.query(`
|
||||||
UPDATE production_plans
|
UPDATE production_plans
|
||||||
SET name = $1,
|
SET name = $1,
|
||||||
contractor = NULLIF($2, ''),
|
contractor = NULLIF($2, ''),
|
||||||
planned_start_date = $3,
|
responsible_user_id = $3,
|
||||||
expected_date = $4,
|
planned_start_date = $4,
|
||||||
notes = NULLIF($5, ''),
|
expected_date = $5,
|
||||||
|
notes = NULLIF($6, ''),
|
||||||
updated_at = CURRENT_TIMESTAMP
|
updated_at = CURRENT_TIMESTAMP
|
||||||
WHERE id = $6
|
WHERE id = $7
|
||||||
RETURNING id;
|
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) {
|
if (!result.rowCount) {
|
||||||
const error = new Error('Plano de produção não encontrado.');
|
const error = new Error('Plano de produção não encontrado.');
|
||||||
error.statusCode = 404;
|
error.statusCode = 404;
|
||||||
|
|||||||
@@ -59,6 +59,35 @@ const listUsers = async () => {
|
|||||||
return result.rows.map(publicUserFields);
|
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 createUser = async ({ name, email, password }) => {
|
||||||
const normalizedName = String(name || '').trim();
|
const normalizedName = String(name || '').trim();
|
||||||
const normalizedEmail = normalizeEmail(email);
|
const normalizedEmail = normalizeEmail(email);
|
||||||
@@ -200,6 +229,8 @@ module.exports = {
|
|||||||
findUserById,
|
findUserById,
|
||||||
findUserByEmail,
|
findUserByEmail,
|
||||||
generatePassword,
|
generatePassword,
|
||||||
|
getActiveOperationalUserById,
|
||||||
|
listActiveOperationalUsers,
|
||||||
listUsers,
|
listUsers,
|
||||||
normalizeEmail,
|
normalizeEmail,
|
||||||
publicUserFields,
|
publicUserFields,
|
||||||
|
|||||||
@@ -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';
|
import { formatDateParam } from './dateRanges';
|
||||||
|
|
||||||
const API_URL = import.meta.env.VITE_API_URL || '/api';
|
const API_URL = import.meta.env.VITE_API_URL || '/api';
|
||||||
@@ -1070,6 +1070,18 @@ export const fetchUsers = async (): Promise<ManagedUser[]> => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const fetchOperationalUsers = async (): Promise<OperationalUser[]> => {
|
||||||
|
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<CreateUserResult> => {
|
export const createUser = async (payload: { name: string; email: string; password?: string }): Promise<CreateUserResult> => {
|
||||||
const response = await authFetch('/users', {
|
const response = await authFetch('/users', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
|
|||||||
@@ -1,13 +1,13 @@
|
|||||||
import { useEffect, useMemo, useState } from 'react';
|
import { useEffect, useMemo, useState } from 'react';
|
||||||
import { Link, useOutletContext, useSearchParams } from 'react-router-dom';
|
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 DateRangePicker from '../components/DateRangePicker';
|
||||||
import PaginationControls from '../components/PaginationControls';
|
import PaginationControls from '../components/PaginationControls';
|
||||||
import ProductColorBadge from '../components/ProductColorBadge';
|
import ProductColorBadge from '../components/ProductColorBadge';
|
||||||
import RefreshStatus from '../components/RefreshStatus';
|
import RefreshStatus from '../components/RefreshStatus';
|
||||||
import { CUT_FAMILY_RULES, buildCutPlan, buildOpenProductionByProductId, type CutFamilyKey, type CutIssue, type CutPlanSkuRow, type CutProductOverride } from '../analytics/cutting';
|
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 { createProductionOrders, exportToCSV, fetchCuttingSettings, fetchOperationalUsers, fetchProductAnalytics, fetchProductCompositions, fetchProductionOrders, fetchProductionOrderImportData, fetchStock, fetchSupplySummary, saveCuttingSettings } from '../dataService';
|
||||||
import type { CuttingSettings, DateRange, ProductAnalyticsItem, ProductComposition, ProductionOrderItem, ProductionYieldRecommendation, StockData, SupplyLot } from '../types';
|
import type { CuttingSettings, DateRange, OperationalUser, ProductAnalyticsItem, ProductComposition, ProductionOrderItem, ProductionYieldRecommendation, StockData, SupplyLot } from '../types';
|
||||||
import { getPlanningStock } from '../planningStock';
|
import { getPlanningStock } from '../planningStock';
|
||||||
|
|
||||||
type CutFilter = 'need' | 'all' | 'issues' | 'covered';
|
type CutFilter = 'need' | 'all' | 'issues' | 'covered';
|
||||||
@@ -169,7 +169,10 @@ const Cutting = () => {
|
|||||||
const [previewItemsPerPage, setPreviewItemsPerPage] = useState(10);
|
const [previewItemsPerPage, setPreviewItemsPerPage] = useState(10);
|
||||||
const [selectedPreviewRowIds, setSelectedPreviewRowIds] = useState<string[]>([]);
|
const [selectedPreviewRowIds, setSelectedPreviewRowIds] = useState<string[]>([]);
|
||||||
const [lotSelections, setLotSelections] = useState<Record<string, { lotId: string; quantity: string }>>({});
|
const [lotSelections, setLotSelections] = useState<Record<string, { lotId: string; quantity: string }>>({});
|
||||||
const [productionPlanForm, setProductionPlanForm] = useState({ name: '', contractor: '', expectedDate: '', notes: '' });
|
const [productionPlanForm, setProductionPlanForm] = useState({ name: '', responsibleUserId: null as number | null, expectedDate: '', notes: '' });
|
||||||
|
const [operationalUsers, setOperationalUsers] = useState<OperationalUser[]>([]);
|
||||||
|
const [isLoadingOperationalUsers, setIsLoadingOperationalUsers] = useState(false);
|
||||||
|
const [isResponsiblePickerOpen, setIsResponsiblePickerOpen] = useState(false);
|
||||||
const [isPlanDetailsOpen, setIsPlanDetailsOpen] = useState(false);
|
const [isPlanDetailsOpen, setIsPlanDetailsOpen] = useState(false);
|
||||||
const [editingCutRow, setEditingCutRow] = useState<CutPlanSkuRow | null>(null);
|
const [editingCutRow, setEditingCutRow] = useState<CutPlanSkuRow | null>(null);
|
||||||
const [quickCutEdit, setQuickCutEdit] = useState<CutProductOverride>({});
|
const [quickCutEdit, setQuickCutEdit] = useState<CutProductOverride>({});
|
||||||
@@ -395,6 +398,9 @@ const Cutting = () => {
|
|||||||
const orderPreviewTotalUnits = useMemo(() => (
|
const orderPreviewTotalUnits = useMemo(() => (
|
||||||
selectedPreviewRows.reduce((total, row) => total + row.suggestedCutQuantity, 0)
|
selectedPreviewRows.reduce((total, row) => total + row.suggestedCutQuantity, 0)
|
||||||
), [selectedPreviewRows]);
|
), [selectedPreviewRows]);
|
||||||
|
const selectedResponsibleUser = useMemo(() => (
|
||||||
|
operationalUsers.find((user) => user.id === productionPlanForm.responsibleUserId) || null
|
||||||
|
), [operationalUsers, productionPlanForm.responsibleUserId]);
|
||||||
|
|
||||||
const cutPlanReference = useMemo(() => (
|
const cutPlanReference = useMemo(() => (
|
||||||
`Plano de corte ${formatDateKey(dateRange.start)} a ${formatDateKey(dateRange.end)} · ${targetCoverageDays} dias`
|
`Plano de corte ${formatDateKey(dateRange.start)} a ${formatDateKey(dateRange.end)} · ${targetCoverageDays} dias`
|
||||||
@@ -670,9 +676,12 @@ const Cutting = () => {
|
|||||||
setPreviewPage(1);
|
setPreviewPage(1);
|
||||||
setSelectedPreviewRowIds(rowsAvailableForOrder.map(row => row.id));
|
setSelectedPreviewRowIds(rowsAvailableForOrder.map(row => row.id));
|
||||||
setLotSelections({});
|
setLotSelections({});
|
||||||
setProductionPlanForm({ name: cutPlanReference, contractor: '', expectedDate: '', notes: '' });
|
setProductionPlanForm({ name: cutPlanReference, responsibleUserId: null, expectedDate: '', notes: '' });
|
||||||
setIsPlanDetailsOpen(false);
|
setIsPlanDetailsOpen(false);
|
||||||
|
setIsResponsiblePickerOpen(false);
|
||||||
setPreviewStep('selection');
|
setPreviewStep('selection');
|
||||||
|
setIsLoadingOperationalUsers(true);
|
||||||
|
void fetchOperationalUsers().then(setOperationalUsers).finally(() => setIsLoadingOperationalUsers(false));
|
||||||
setIsOrderPreviewOpen(true);
|
setIsOrderPreviewOpen(true);
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -755,7 +764,7 @@ const Cutting = () => {
|
|||||||
}
|
}
|
||||||
})), selectedLotReservations, {
|
})), selectedLotReservations, {
|
||||||
name: productionPlanForm.name.trim() || cutPlanReference,
|
name: productionPlanForm.name.trim() || cutPlanReference,
|
||||||
contractor: productionPlanForm.contractor.trim(),
|
responsibleUserId: productionPlanForm.responsibleUserId,
|
||||||
plannedStartDate: issueDate,
|
plannedStartDate: issueDate,
|
||||||
expectedDate: productionPlanForm.expectedDate || null,
|
expectedDate: productionPlanForm.expectedDate || null,
|
||||||
notes: productionPlanForm.notes.trim(),
|
notes: productionPlanForm.notes.trim(),
|
||||||
@@ -934,12 +943,38 @@ const Cutting = () => {
|
|||||||
<label className="block min-w-0 flex-1 text-xs font-bold text-dark-muted">Plano de produção
|
<label className="block min-w-0 flex-1 text-xs font-bold text-dark-muted">Plano de produção
|
||||||
<input value={productionPlanForm.name} onChange={(event) => setProductionPlanForm(current => ({ ...current, name: event.target.value }))} className="mt-1 h-10 w-full rounded-lg border border-dark-border bg-dark-input px-3 text-sm font-semibold text-dark-text outline-none focus:border-brand-primary" />
|
<input value={productionPlanForm.name} onChange={(event) => setProductionPlanForm(current => ({ ...current, name: event.target.value }))} className="mt-1 h-10 w-full rounded-lg border border-dark-border bg-dark-input px-3 text-sm font-semibold text-dark-text outline-none focus:border-brand-primary" />
|
||||||
</label>
|
</label>
|
||||||
|
<div className="relative shrink-0 text-xs font-bold text-dark-muted">
|
||||||
|
<span>Responsável</span>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setIsResponsiblePickerOpen(current => !current)}
|
||||||
|
className="mt-1 flex h-10 min-w-[220px] items-center justify-between gap-3 rounded-lg border border-dark-border bg-dark-input px-3 text-left text-sm font-semibold text-dark-text transition-colors hover:border-brand-primary cursor-pointer"
|
||||||
|
aria-expanded={isResponsiblePickerOpen}
|
||||||
|
>
|
||||||
|
<span className="flex min-w-0 items-center gap-2">
|
||||||
|
<UserRound className="h-4 w-4 shrink-0 text-brand-primary" />
|
||||||
|
<span className="truncate">{selectedResponsibleUser?.name || 'Selecionar responsável'}</span>
|
||||||
|
</span>
|
||||||
|
<ChevronDown className={`h-4 w-4 shrink-0 text-dark-muted transition-transform ${isResponsiblePickerOpen ? 'rotate-180' : ''}`} />
|
||||||
|
</button>
|
||||||
|
{isResponsiblePickerOpen && <div className="absolute right-0 z-20 mt-2 w-full min-w-[280px] overflow-hidden rounded-xl border border-dark-border bg-dark-card shadow-xl">
|
||||||
|
<div className="border-b border-dark-border px-3 py-2 text-[10px] font-bold uppercase tracking-wider text-dark-muted">Usuários ativos do Graphs</div>
|
||||||
|
<div className="max-h-56 overflow-y-auto p-1.5">
|
||||||
|
{isLoadingOperationalUsers ? <p className="px-2 py-3 text-xs font-semibold text-dark-muted">Carregando usuários...</p> : operationalUsers.length ? operationalUsers.map(user => (
|
||||||
|
<button key={user.id} type="button" onClick={() => { setProductionPlanForm(current => ({ ...current, responsibleUserId: user.id })); setIsResponsiblePickerOpen(false); }} className="flex w-full items-center justify-between gap-3 rounded-lg px-2.5 py-2 text-left text-sm font-semibold text-dark-text transition-colors hover:bg-dark-input cursor-pointer">
|
||||||
|
<span className="truncate">{user.name}</span>
|
||||||
|
{productionPlanForm.responsibleUserId === user.id && <Check className="h-4 w-4 shrink-0 text-brand-primary" />}
|
||||||
|
</button>
|
||||||
|
)) : <p className="px-2 py-3 text-xs font-semibold text-dark-muted">Nenhum usuário ativo encontrado.</p>}
|
||||||
|
</div>
|
||||||
|
{selectedResponsibleUser && <button type="button" onClick={() => { setProductionPlanForm(current => ({ ...current, responsibleUserId: null })); setIsResponsiblePickerOpen(false); }} className="w-full border-t border-dark-border px-3 py-2 text-left text-xs font-bold text-dark-muted transition-colors hover:bg-dark-input hover:text-dark-text cursor-pointer">Remover responsável</button>}
|
||||||
|
</div>}
|
||||||
|
</div>
|
||||||
<button type="button" onClick={() => setIsPlanDetailsOpen(current => !current)} className="inline-flex h-10 shrink-0 items-center justify-center rounded-lg border border-dark-border bg-dark-card px-3 text-xs font-bold text-dark-muted transition-colors hover:border-brand-primary hover:text-dark-text cursor-pointer">
|
<button type="button" onClick={() => setIsPlanDetailsOpen(current => !current)} className="inline-flex h-10 shrink-0 items-center justify-center rounded-lg border border-dark-border bg-dark-card px-3 text-xs font-bold text-dark-muted transition-colors hover:border-brand-primary hover:text-dark-text cursor-pointer">
|
||||||
{isPlanDetailsOpen ? 'Ocultar detalhes' : 'Adicionar detalhes'}
|
{isPlanDetailsOpen ? 'Ocultar detalhes' : 'Adicionar detalhes'}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
{isPlanDetailsOpen && <div className="mt-3 grid grid-cols-1 gap-3 border-t border-dark-border pt-3 md:grid-cols-3">
|
{isPlanDetailsOpen && <div className="mt-3 grid grid-cols-1 gap-3 border-t border-dark-border pt-3 md:grid-cols-2">
|
||||||
<label className="text-xs font-bold text-dark-muted">Facção / responsável<input value={productionPlanForm.contractor} onChange={(event) => setProductionPlanForm(current => ({ ...current, contractor: event.target.value }))} placeholder="Opcional" className="mt-1 h-10 w-full rounded-lg border border-dark-border bg-dark-input px-3 text-sm font-semibold text-dark-text outline-none placeholder:text-dark-muted focus:border-brand-primary" /></label>
|
|
||||||
<label className="text-xs font-bold text-dark-muted">Previsão de conclusão<input type="date" value={productionPlanForm.expectedDate} onChange={(event) => setProductionPlanForm(current => ({ ...current, expectedDate: event.target.value }))} className="mt-1 h-10 w-full rounded-lg border border-dark-border bg-dark-input px-3 text-sm font-semibold text-dark-text outline-none focus:border-brand-primary" /></label>
|
<label className="text-xs font-bold text-dark-muted">Previsão de conclusão<input type="date" value={productionPlanForm.expectedDate} onChange={(event) => setProductionPlanForm(current => ({ ...current, expectedDate: event.target.value }))} className="mt-1 h-10 w-full rounded-lg border border-dark-border bg-dark-input px-3 text-sm font-semibold text-dark-text outline-none focus:border-brand-primary" /></label>
|
||||||
<label className="text-xs font-bold text-dark-muted">Observação<input value={productionPlanForm.notes} onChange={(event) => setProductionPlanForm(current => ({ ...current, notes: event.target.value }))} placeholder="Opcional" className="mt-1 h-10 w-full rounded-lg border border-dark-border bg-dark-input px-3 text-sm font-semibold text-dark-text outline-none placeholder:text-dark-muted focus:border-brand-primary" /></label>
|
<label className="text-xs font-bold text-dark-muted">Observação<input value={productionPlanForm.notes} onChange={(event) => setProductionPlanForm(current => ({ ...current, notes: event.target.value }))} placeholder="Opcional" className="mt-1 h-10 w-full rounded-lg border border-dark-border bg-dark-input px-3 text-sm font-semibold text-dark-text outline-none placeholder:text-dark-muted focus:border-brand-primary" /></label>
|
||||||
</div>}
|
</div>}
|
||||||
|
|||||||
@@ -93,7 +93,7 @@ const ProductionPlans = () => {
|
|||||||
<div className="divide-y divide-dark-border">
|
<div className="divide-y divide-dark-border">
|
||||||
{plans.map(plan => (
|
{plans.map(plan => (
|
||||||
<div key={plan.id} className="grid gap-4 p-5 xl:grid-cols-[minmax(260px,1.4fr)_minmax(150px,.7fr)_minmax(180px,.7fr)_180px] xl:items-center">
|
<div key={plan.id} className="grid gap-4 p-5 xl:grid-cols-[minmax(260px,1.4fr)_minmax(150px,.7fr)_minmax(180px,.7fr)_180px] xl:items-center">
|
||||||
<div className="min-w-0"><div className="flex flex-wrap items-center gap-2"><span className="font-mono text-xs font-bold text-brand-primary">{plan.number}</span><span className={`rounded-full border px-2.5 py-1 text-xs font-bold ${statusStyle(plan.status)}`}>{plan.statusLabel}</span></div><p className="mt-2 truncate font-bold text-dark-text" title={plan.name}>{plan.name}</p><p className="mt-1 text-xs font-semibold text-dark-muted">{plan.contractor ? `Facção: ${plan.contractor}` : 'Sem facção definida'} · previsão {formatDate(plan.expectedDate)}</p>{plan.notes && <p className="mt-1 truncate text-xs font-semibold text-dark-muted" title={plan.notes}>{plan.notes}</p>}</div>
|
<div className="min-w-0"><div className="flex flex-wrap items-center gap-2"><span className="font-mono text-xs font-bold text-brand-primary">{plan.number}</span><span className={`rounded-full border px-2.5 py-1 text-xs font-bold ${statusStyle(plan.status)}`}>{plan.statusLabel}</span></div><p className="mt-2 truncate font-bold text-dark-text" title={plan.name}>{plan.name}</p><p className="mt-1 text-xs font-semibold text-dark-muted">{plan.responsibleName ? `Responsável: ${plan.responsibleName}` : plan.contractor ? `Responsável: ${plan.contractor}` : 'Sem responsável definido'} · previsão {formatDate(plan.expectedDate)}</p>{plan.notes && <p className="mt-1 truncate text-xs font-semibold text-dark-muted" title={plan.notes}>{plan.notes}</p>}</div>
|
||||||
<div className="text-sm"><p className="text-xs font-bold uppercase tracking-wider text-dark-muted">Escopo</p><p className="mt-1 font-bold text-dark-text">{plan.orderCount} {plan.orderCount === 1 ? 'OP' : 'OPs'} · {formatNumber(plan.totalQuantity)} un.</p><p className="mt-1 text-xs font-semibold text-dark-muted">Cortado {formatNumber(plan.actualCutQuantity)} · final {formatNumber(plan.finishedQuantity)}</p></div>
|
<div className="text-sm"><p className="text-xs font-bold uppercase tracking-wider text-dark-muted">Escopo</p><p className="mt-1 font-bold text-dark-text">{plan.orderCount} {plan.orderCount === 1 ? 'OP' : 'OPs'} · {formatNumber(plan.totalQuantity)} un.</p><p className="mt-1 text-xs font-semibold text-dark-muted">Cortado {formatNumber(plan.actualCutQuantity)} · final {formatNumber(plan.finishedQuantity)}</p></div>
|
||||||
<div className="flex flex-wrap gap-1.5">{statusOptions.filter(option => Number(plan.statusCounts[option.value] || 0) > 0).map(option => <span key={option.value} className={`rounded-full border px-2 py-1 text-xs font-bold ${statusStyle(option.value)}`}>{plan.statusCounts[option.value]} {option.label.toLowerCase()}</span>)}</div>
|
<div className="flex flex-wrap gap-1.5">{statusOptions.filter(option => Number(plan.statusCounts[option.value] || 0) > 0).map(option => <span key={option.value} className={`rounded-full border px-2 py-1 text-xs font-bold ${statusStyle(option.value)}`}>{plan.statusCounts[option.value]} {option.label.toLowerCase()}</span>)}</div>
|
||||||
<label className="text-xs font-bold text-dark-muted">Mover plano<select value={plan.status} disabled={busyPlanId === plan.id} onChange={(event) => void movePlan(plan, event.target.value)} className="mt-1 h-10 w-full rounded-lg border border-dark-border bg-dark-input px-3 text-sm font-bold text-dark-text outline-none focus:border-brand-primary disabled:opacity-60 cursor-pointer"><option value={plan.status}>{busyPlanId === plan.id ? 'Atualizando...' : plan.statusLabel}</option>{statusOptions.filter(option => option.value !== plan.status).map(option => <option key={option.value} value={option.value}>{option.label}</option>)}</select></label>
|
<label className="text-xs font-bold text-dark-muted">Mover plano<select value={plan.status} disabled={busyPlanId === plan.id} onChange={(event) => void movePlan(plan, event.target.value)} className="mt-1 h-10 w-full rounded-lg border border-dark-border bg-dark-input px-3 text-sm font-bold text-dark-text outline-none focus:border-brand-primary disabled:opacity-60 cursor-pointer"><option value={plan.status}>{busyPlanId === plan.id ? 'Atualizando...' : plan.statusLabel}</option>{statusOptions.filter(option => option.value !== plan.status).map(option => <option key={option.value} value={option.value}>{option.label}</option>)}</select></label>
|
||||||
|
|||||||
@@ -181,6 +181,8 @@ export interface ProductionPlan {
|
|||||||
status: ProductionOrderStatus;
|
status: ProductionOrderStatus;
|
||||||
statusLabel: string;
|
statusLabel: string;
|
||||||
contractor: string;
|
contractor: string;
|
||||||
|
responsibleUserId: number | null;
|
||||||
|
responsibleName: string;
|
||||||
plannedStartDate: string | null;
|
plannedStartDate: string | null;
|
||||||
expectedDate: string | null;
|
expectedDate: string | null;
|
||||||
completedAt: string | null;
|
completedAt: string | null;
|
||||||
@@ -199,6 +201,7 @@ export type ProductionPlanPayload = {
|
|||||||
id?: number;
|
id?: number;
|
||||||
name: string;
|
name: string;
|
||||||
contractor?: string;
|
contractor?: string;
|
||||||
|
responsibleUserId?: number | null;
|
||||||
plannedStartDate?: string | null;
|
plannedStartDate?: string | null;
|
||||||
expectedDate?: string | null;
|
expectedDate?: string | null;
|
||||||
notes?: string;
|
notes?: string;
|
||||||
@@ -626,6 +629,11 @@ export interface ManagedUser {
|
|||||||
updatedAt: string;
|
updatedAt: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface OperationalUser {
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
}
|
||||||
|
|
||||||
export interface CreateUserResult {
|
export interface CreateUserResult {
|
||||||
user: ManagedUser;
|
user: ManagedUser;
|
||||||
temporaryPassword?: string;
|
temporaryPassword?: string;
|
||||||
|
|||||||
Reference in New Issue
Block a user