Add persistent production plans
This commit is contained in:
@@ -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 (
|
||||||
|
|||||||
@@ -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 || {}));
|
||||||
|
|||||||
@@ -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
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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 />} />
|
||||||
|
|||||||
@@ -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',
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
103
src/pages/ProductionPlans.tsx
Normal file
103
src/pages/ProductionPlans.tsx
Normal 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;
|
||||||
@@ -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 {
|
||||||
|
|||||||
38
src/types.ts
38
src/types.ts
@@ -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';
|
||||||
|
|||||||
Reference in New Issue
Block a user