Add persistent production plans

This commit is contained in:
Cauê Faleiros
2026-08-25 11:47:07 -03:00
parent 21d9960145
commit 85afba4e5b
10 changed files with 493 additions and 16 deletions

View File

@@ -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(` await pool.query(`
CREATE TABLE IF NOT EXISTS production_order_markers ( CREATE TABLE IF NOT EXISTS production_order_markers (
id SERIAL PRIMARY KEY, id SERIAL PRIMARY KEY,
@@ -447,6 +472,7 @@ const initDB = async () => {
CREATE TABLE IF NOT EXISTS cut_plan_lot_reservations ( CREATE TABLE IF NOT EXISTS cut_plan_lot_reservations (
id SERIAL PRIMARY KEY, id SERIAL PRIMARY KEY,
cut_plan_reference TEXT NOT NULL, 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, supply_lot_id INTEGER NOT NULL REFERENCES supply_stock_lots(id) ON DELETE RESTRICT,
material_key VARCHAR(255) NOT NULL, material_key VARCHAR(255) NOT NULL,
quantity NUMERIC(14, 4) NOT NULL CHECK (quantity > 0), 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_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_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(` await pool.query(`
CREATE TABLE IF NOT EXISTS supply_movements ( CREATE TABLE IF NOT EXISTS supply_movements (

View File

@@ -1,6 +1,6 @@
const express = require('express'); const express = require('express');
const { authenticateAPIKey, verifyToken } = require('../auth'); 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(); const router = express.Router();
@@ -17,13 +17,40 @@ router.post('/production-orders', verifyToken, async (req, res) => {
try { try {
const orders = Array.isArray(req.body?.orders) ? req.body.orders : []; const orders = Array.isArray(req.body?.orders) ? req.body.orders : [];
const lotReservations = Array.isArray(req.body?.lotReservations) ? req.body.lotReservations : []; 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) { } catch (error) {
console.error('Error creating production orders:', error); console.error('Error creating production orders:', error);
res.status(error.statusCode || 500).json({ error: error.message || 'Internal Server 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) => { router.post('/production-orders/tiny-sync', authenticateAPIKey, async (req, res) => {
try { try {
res.status(201).json(await upsertTinyProductionOrderDetail(req.body || {})); res.status(201).json(await upsertTinyProductionOrderDetail(req.body || {}));

View File

@@ -276,6 +276,11 @@ const mapProductionOrderRow = (row) => {
number: row.number || '', number: row.number || '',
status, status,
statusLabel: STATUS_LABELS[status] || row.status || 'Em aberto', 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 || '', orderReference: row.order_reference || '',
issueDate: formatDate(row.issue_date), issueDate: formatDate(row.issue_date),
expectedDate: formatDate(row.expected_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 getOrderById = async (id, client = pool) => {
const result = await client.query(` const result = await client.query(`
SELECT SELECT
@@ -312,6 +398,11 @@ const getOrderById = async (id, client = pool) => {
po.tiny_id, po.tiny_id,
po.number, po.number,
po.status, 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.order_reference,
po.issue_date, po.issue_date,
po.expected_date, po.expected_date,
@@ -385,7 +476,10 @@ const getOrderById = async (id, client = pool) => {
) )
FROM cut_plan_lot_reservations reservation FROM cut_plan_lot_reservations reservation
JOIN supply_stock_lots lot ON lot.id = reservation.supply_lot_id 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') AND reservation.status IN ('active', 'consumed')
), ),
'[]'::json '[]'::json
@@ -410,6 +504,7 @@ const getOrderById = async (id, client = pool) => {
'[]'::json '[]'::json
) as steps ) as steps
FROM production_orders po FROM production_orders po
LEFT JOIN production_plans plan ON plan.id = po.production_plan_id
WHERE po.id = $1; WHERE po.id = $1;
`, [id]); `, [id]);
@@ -422,6 +517,11 @@ const baseProductionOrderSelect = `
po.tiny_id, po.tiny_id,
po.number, po.number,
po.status, 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.order_reference,
po.issue_date, po.issue_date,
po.expected_date, po.expected_date,
@@ -495,7 +595,10 @@ const baseProductionOrderSelect = `
) )
FROM cut_plan_lot_reservations reservation FROM cut_plan_lot_reservations reservation
JOIN supply_stock_lots lot ON lot.id = reservation.supply_lot_id 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') AND reservation.status IN ('active', 'consumed')
), ),
'[]'::json '[]'::json
@@ -520,6 +623,7 @@ const baseProductionOrderSelect = `
'[]'::json '[]'::json
) as steps ) as steps
FROM production_orders po FROM production_orders po
LEFT JOIN production_plans plan ON plan.id = po.production_plan_id
`; `;
const listProductionOrders = async (filters = {}) => { const listProductionOrders = async (filters = {}) => {
@@ -576,13 +680,113 @@ const listProductionOrders = async (filters = {}) => {
return { orders, counts }; 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(); const client = await pool.connect();
try { try {
await client.query('BEGIN'); await client.query('BEGIN');
const created = []; const created = [];
const skipped = []; 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) { for (const order of orders) {
const productDescription = normalizeText(order.productDescription); const productDescription = normalizeText(order.productDescription);
@@ -615,6 +819,8 @@ const createProductionOrders = async (orders = [], lotReservations = []) => {
tiny_id, tiny_id,
number, number,
status, status,
production_plan_id,
contractor,
order_reference, order_reference,
issue_date, issue_date,
expected_date, expected_date,
@@ -637,11 +843,15 @@ const createProductionOrders = async (orders = [], lotReservations = []) => {
$7, $7,
$8, $8,
$9, $9,
$10::jsonb $10,
$11,
$12::jsonb
) )
RETURNING id; RETURNING id;
`, [ `, [
normalizeStatus(order.status || 'open'), normalizeStatus(order.status || 'open'),
plan?.id || null,
normalizeText(order.contractor) || plan?.contractor || null,
orderReference, orderReference,
normalizeDateParam(order.issueDate) || formatDate(new Date()), normalizeDateParam(order.issueDate) || formatDate(new Date()),
normalizeDateParam(order.expectedDate), normalizeDateParam(order.expectedDate),
@@ -734,15 +944,15 @@ const createProductionOrders = async (orders = [], lotReservations = []) => {
} }
await client.query(` await client.query(`
INSERT INTO cut_plan_lot_reservations ( 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); VALUES ($1, $2, $3, $4, $5, 0, 'active', CURRENT_TIMESTAMP);
`, [cutPlanReference, lotId, materialKey, quantity]); `, [cutPlanReference, plan?.id || null, lotId, materialKey, quantity]);
} }
} }
await client.query('COMMIT'); await client.query('COMMIT');
return { created, skipped }; return { created, skipped, plan };
} catch (error) { } catch (error) {
await client.query('ROLLBACK'); await client.query('ROLLBACK');
throw error; throw error;
@@ -1534,12 +1744,15 @@ module.exports = {
deleteManualProductComposition, deleteManualProductComposition,
getProductComposition, getProductComposition,
importTinyProductCompositionExport, importTinyProductCompositionExport,
listProductionPlans,
listProductCompositions, listProductCompositions,
listProductionOrders, listProductionOrders,
normalizeStatus, normalizeStatus,
saveManualProductComposition, saveManualProductComposition,
upsertTinyProductComposition, upsertTinyProductComposition,
upsertTinyProductionOrderDetail, upsertTinyProductionOrderDetail,
updateProductionPlan,
updateProductionPlanStatus,
updateProductionOrderExecution, updateProductionOrderExecution,
updateProductionOrderStatus updateProductionOrderStatus
}; };

View File

@@ -12,6 +12,7 @@ const Replenishment = React.lazy(() => import('./pages/Replenishment'));
const Cutting = React.lazy(() => import('./pages/Cutting')); const Cutting = React.lazy(() => import('./pages/Cutting'));
const PlanningIssues = React.lazy(() => import('./pages/PlanningIssues')); const PlanningIssues = React.lazy(() => import('./pages/PlanningIssues'));
const ProductionOrders = React.lazy(() => import('./pages/ProductionOrders')); const ProductionOrders = React.lazy(() => import('./pages/ProductionOrders'));
const ProductionPlans = React.lazy(() => import('./pages/ProductionPlans'));
const Supplies = React.lazy(() => import('./pages/Supplies')); const Supplies = React.lazy(() => import('./pages/Supplies'));
const Clients = React.lazy(() => import('./pages/Clients')); const Clients = React.lazy(() => import('./pages/Clients'));
const ClientDetails = React.lazy(() => import('./pages/ClientDetails')); const ClientDetails = React.lazy(() => import('./pages/ClientDetails'));
@@ -64,6 +65,7 @@ function App() {
<Route path="stock" element={<Navigate to="/products" replace />} /> <Route path="stock" element={<Navigate to="/products" replace />} />
<Route path="stock-alerts" element={<Navigate to="/products" replace />} /> <Route path="stock-alerts" element={<Navigate to="/products" replace />} />
<Route path="production-orders" element={<ProductionOrders />} /> <Route path="production-orders" element={<ProductionOrders />} />
<Route path="production-plans" element={<ProductionPlans />} />
<Route path="clients" element={<Clients />} /> <Route path="clients" element={<Clients />} />
<Route path="clients/:clientToken" element={<ClientDetails />} /> <Route path="clients/:clientToken" element={<ClientDetails />} />
<Route path="sellers" element={<Sellers />} /> <Route path="sellers" element={<Sellers />} />

View File

@@ -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'; import { formatDateParam } from './dateRanges';
const API_URL = import.meta.env.VITE_API_URL || '/api'; const API_URL = import.meta.env.VITE_API_URL || '/api';
@@ -239,11 +239,11 @@ export const fetchProductionOrders = async (
}, options); }, options);
}; };
export const createProductionOrders = async (orders: ProductionOrderPayload[], lotReservations: CutPlanLotReservationPayload[] = []): Promise<CreateProductionOrdersResult> => { export const createProductionOrders = async (orders: ProductionOrderPayload[], lotReservations: CutPlanLotReservationPayload[] = [], plan?: ProductionPlanPayload): Promise<CreateProductionOrdersResult> => {
const response = await authFetch('/production-orders', { const response = await authFetch('/production-orders', {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, 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.'); 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(); return await response.json();
}; };
export const fetchProductionPlans = async (): Promise<ProductionPlan[]> => {
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<ProductionPlan[]>;
};
export const updateProductionPlan = async (id: number, payload: ProductionPlanPayload): Promise<ProductionPlan> => {
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<ProductionPlan> => {
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<ProductionOrderItem> => { export const updateProductionOrderStatus = async (id: number, status: ProductionOrderStatus): Promise<ProductionOrderItem> => {
const response = await authFetch(`/production-orders/${id}/status`, { const response = await authFetch(`/production-orders/${id}/status`, {
method: 'PATCH', method: 'PATCH',

View File

@@ -167,6 +167,7 @@ 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 [editingCutRow, setEditingCutRow] = useState<CutPlanSkuRow | null>(null); const [editingCutRow, setEditingCutRow] = useState<CutPlanSkuRow | null>(null);
const [quickCutEdit, setQuickCutEdit] = useState<CutProductOverride>({}); const [quickCutEdit, setQuickCutEdit] = useState<CutProductOverride>({});
const [isQuickCutEditSaving, setIsQuickCutEditSaving] = useState(false); const [isQuickCutEditSaving, setIsQuickCutEditSaving] = useState(false);
@@ -666,6 +667,7 @@ 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: '' });
setIsOrderPreviewOpen(true); setIsOrderPreviewOpen(true);
}; };
@@ -746,11 +748,18 @@ const Cutting = () => {
availableQuantity: row.availableQuantity, availableQuantity: row.availableQuantity,
issues: row.issues 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 }); const refreshedOrders = await fetchProductionOrders(allProductionOrdersRange, undefined, { force: true });
setProductionOrders(refreshedOrders.orders); 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); setIsOrderPreviewOpen(false);
} catch (error) { } catch (error) {
setGenerationMessage(error instanceof Error ? error.message : 'Não foi possível gerar as ordens de produção.'); 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]) => <div key={label} className="p-4"><p className="text-[10px] font-bold uppercase tracking-widest text-dark-muted">{label}</p><p className={`mt-1 text-xl font-bold ${tone}`}>{value}</p></div>)} ].map(([label, value, tone]) => <div key={label} className="p-4"><p className="text-[10px] font-bold uppercase tracking-widest text-dark-muted">{label}</p><p className={`mt-1 text-xl font-bold ${tone}`}>{value}</p></div>)}
</div> </div>
<div className="border-b border-dark-border bg-dark-input/25 p-4">
<div className="mb-3">
<p className="text-xs font-bold uppercase tracking-widest text-brand-primary">Plano de produção</p>
<p className="mt-1 text-xs font-semibold text-dark-muted">Estas OPs serão gravadas dentro de um único plano PCP. O plano pode avançar todas as OPs ativas juntas.</p>
</div>
<div className="grid grid-cols-1 gap-3 md:grid-cols-2 xl:grid-cols-4">
<label className="text-xs font-bold text-dark-muted">Nome do plano
<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-card 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">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-card 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-card 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 operacional
<input value={productionPlanForm.notes} onChange={(event) => setProductionPlanForm(current => ({ ...current, notes: event.target.value }))} placeholder="Lotes, prioridade, entrega..." className="mt-1 h-10 w-full rounded-lg border border-dark-border bg-dark-card px-3 text-sm font-semibold text-dark-text outline-none placeholder:text-dark-muted focus:border-brand-primary" />
</label>
</div>
</div>
<div className="flex flex-col gap-3 border-b border-dark-border bg-dark-input/40 px-4 py-3 sm:flex-row sm:items-center sm:justify-between"> <div className="flex flex-col gap-3 border-b border-dark-border bg-dark-input/40 px-4 py-3 sm:flex-row sm:items-center sm:justify-between">
<p className="text-xs font-semibold text-dark-muted">Todos os SKUs abaixo estão aptos para gerar OP. A seleção permanece ao trocar de página.</p> <p className="text-xs font-semibold text-dark-muted">Todos os SKUs abaixo estão aptos para gerar OP. A seleção permanece ao trocar de página.</p>
<div className="flex flex-wrap gap-2"> <div className="flex flex-wrap gap-2">

View File

@@ -422,6 +422,7 @@ const ProductionOrders = () => {
</Link> </Link>
<h1 className="text-2xl font-bold text-dark-text">Ordens de Produção</h1> <h1 className="text-2xl font-bold text-dark-text">Ordens de Produção</h1>
<p className="mt-2 text-dark-muted font-medium">Acompanhe as ordens locais geradas pelo plano de corte.</p> <p className="mt-2 text-dark-muted font-medium">Acompanhe as ordens locais geradas pelo plano de corte.</p>
<Link to="/production-plans" className="mt-3 inline-flex items-center gap-2 text-sm font-bold text-brand-primary transition-colors hover:text-brand-primary/80"><ClipboardList className="h-4 w-4" />Ver planos de produção</Link>
</div> </div>
<DateRangePicker <DateRangePicker
dateRange={dateRange} dateRange={dateRange}
@@ -608,6 +609,7 @@ const ProductionOrders = () => {
<div className="mt-1 flex flex-wrap items-center gap-x-3 gap-y-1 text-[10px] font-semibold text-dark-muted"> <div className="mt-1 flex flex-wrap items-center gap-x-3 gap-y-1 text-[10px] font-semibold text-dark-muted">
<span className="font-mono">{order.productSku || 'Sem SKU'}</span> <span className="font-mono">{order.productSku || 'Sem SKU'}</span>
<span>{formatDate(order.issueDate)}</span> <span>{formatDate(order.issueDate)}</span>
{order.productionPlanNumber && <Link to="/production-plans" className="font-mono text-brand-primary hover:underline">{order.productionPlanNumber}</Link>}
{order.orderReference && <span>{order.orderReference}</span>} {order.orderReference && <span>{order.orderReference}</span>}
</div> </div>
</td> </td>

View File

@@ -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<ProductionPlan[]>([]);
const [isLoading, setIsLoading] = useState(true);
const [busyPlanId, setBusyPlanId] = useState<number | null>(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 (
<div className="space-y-6">
<div className="flex flex-col gap-4 lg:flex-row lg:items-end lg:justify-between">
<div>
<Link to="/production-orders" className="mb-3 inline-flex items-center gap-2 text-sm font-bold text-zinc-500 transition-colors hover:text-zinc-900 dark:text-dark-muted dark:hover:text-dark-text"><ArrowLeft className="h-4 w-4" />Ordens de Produção</Link>
<h1 className="text-2xl font-bold text-zinc-900 dark:text-dark-text">Planos de Produção</h1>
<p className="mt-2 font-medium text-zinc-500 dark:text-dark-muted">Controle PCP acima das fichas: cada plano agrupa OPs, materiais reservados e a movimentação da operação.</p>
</div>
<button type="button" onClick={() => void loadPlans()} className="inline-flex h-10 items-center justify-center rounded-xl border border-dark-border bg-dark-card px-4 text-sm font-bold text-dark-text transition-colors hover:border-brand-primary cursor-pointer">Atualizar</button>
</div>
<div className="grid grid-cols-1 gap-4 md:grid-cols-3">
{[
['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 <div key={String(label)} className="rounded-2xl border border-dark-border bg-dark-card p-5 shadow-sm"><div className="flex items-center justify-between"><p className="text-sm font-bold text-dark-muted">{label}</p><MetricIcon className={`h-5 w-5 ${tone}`} /></div><p className="mt-3 text-3xl font-bold text-dark-text">{value}</p></div>;
})}
</div>
{message && <div className="rounded-xl border border-dark-border bg-dark-card px-4 py-3 text-sm font-bold text-dark-muted">{message}</div>}
<div className="overflow-hidden rounded-2xl border border-dark-border bg-dark-card shadow-sm">
<div className="border-b border-dark-border px-5 py-4"><h2 className="font-bold text-dark-text">Fila de planos</h2><p className="mt-1 text-xs font-semibold text-dark-muted">Mover um plano atualiza todas as OPs locais que continuam ativas; OPs concluídas e canceladas preservam seu histórico.</p></div>
{isLoading ? <div className="flex min-h-56 items-center justify-center text-brand-primary"><Loader2 className="h-7 w-7 animate-spin" /></div> : plans.length ? (
<div className="divide-y divide-dark-border">
{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 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="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>
<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>
</div>
))}
</div>
) : <div className="flex min-h-56 flex-col items-center justify-center p-8 text-center"><CheckCircle2 className="h-8 w-8 text-brand-primary" /><h3 className="mt-3 font-bold text-dark-text">Nenhum plano confirmado</h3><p className="mt-1 text-sm font-semibold text-dark-muted">Gere OPs a partir do Plano de Corte para criar o primeiro plano PCP.</p></div>}
</div>
</div>
);
};
export default ProductionPlans;

View File

@@ -2667,7 +2667,12 @@ const PurchaseOrdersScreen = () => {
const [nextOrders, nextSuppliers] = await Promise.all([fetchSupplyPurchaseOrders(), fetchSupplySuppliers()]); const [nextOrders, nextSuppliers] = await Promise.all([fetchSupplyPurchaseOrders(), fetchSupplySuppliers()]);
setOrders(nextOrders); setSuppliers(nextSuppliers); 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<HTMLFormElement>) => { const submit = async (event: FormEvent<HTMLFormElement>) => {
event.preventDefault(); setBusy(true); setMessage(''); event.preventDefault(); setBusy(true); setMessage('');
try { try {

View File

@@ -96,6 +96,11 @@ export interface ProductionOrderItem {
number: string; number: string;
status: ProductionOrderStatus; status: ProductionOrderStatus;
statusLabel: string; statusLabel: string;
productionPlanId: number | null;
productionPlanNumber: string;
productionPlanName: string;
productionPlanStatus: string;
contractor: string;
orderReference: string; orderReference: string;
issueDate: string | null; issueDate: string | null;
expectedDate: string | null; expectedDate: string | null;
@@ -156,6 +161,7 @@ export type ProductionOrderPayload = {
quantity: number; quantity: number;
unit?: string; unit?: string;
integrationStatus?: string; integrationStatus?: string;
contractor?: string;
components?: Array<{ components?: Array<{
componentTinyId?: string; componentTinyId?: string;
componentSku?: string; componentSku?: string;
@@ -168,6 +174,37 @@ export type ProductionOrderPayload = {
metadata?: Record<string, unknown>; metadata?: Record<string, unknown>;
}; };
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<string, number>;
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 = { export type CutPlanLotReservationPayload = {
lotId: number; lotId: number;
materialKey: string; materialKey: string;
@@ -189,6 +226,7 @@ export interface CreateProductionOrdersResult {
productDescription?: string; productDescription?: string;
reason: string; reason: string;
}>; }>;
plan: ProductionPlan | null;
} }
export type CutFamilyKey = 'BLCS' | 'BLOS' | 'BLMC' | 'BLPM' | 'BLRM' | 'OUTROS'; export type CutFamilyKey = 'BLCS' | 'BLOS' | 'BLMC' | 'BLPM' | 'BLRM' | 'OUTROS';