Compare commits
27 Commits
e8a684b0ab
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9d99f2387b | ||
|
|
4122e21dc2 | ||
|
|
c5edaec7d3 | ||
|
|
9f4db67f4f | ||
|
|
b4b21fb21b | ||
|
|
ba136b9d78 | ||
|
|
5a23817f4c | ||
|
|
35f3938f1c | ||
|
|
56ce81850b | ||
|
|
6b3f84a666 | ||
|
|
bc6d53b840 | ||
|
|
5e742feb60 | ||
|
|
9592b2eb7d | ||
|
|
314288147a | ||
|
|
acf542f2d5 | ||
|
|
7ae230ed2e | ||
|
|
c77bc752ae | ||
|
|
4b66e91442 | ||
|
|
a938925904 | ||
|
|
9960fb7986 | ||
|
|
2b7c132300 | ||
|
|
ef29365932 | ||
|
|
64e5fefcb4 | ||
|
|
d623688263 | ||
|
|
e62793273b | ||
|
|
5d697f8f8a | ||
|
|
ed750ac4ce |
@@ -161,7 +161,7 @@ yield: 4.8 units/kg
|
|||||||
* The production-order screen uses a compact list with inline expansion rather than a side detail page. Each OP has one status control (`Em aberto`, `Em andamento`, `Finalizada`, `Cancelada`), and expanded details show product markers, composition, and material consumption context. Material consumption is an OP-level action, relevant once production has started rather than a permanent page-level form.
|
* The production-order screen uses a compact list with inline expansion rather than a side detail page. Each OP has one status control (`Em aberto`, `Em andamento`, `Finalizada`, `Cancelada`), and expanded details show product markers, composition, and material consumption context. Material consumption is an OP-level action, relevant once production has started rather than a permanent page-level form.
|
||||||
|
|
||||||
### 7.2 Olist V3 connection and composition synchronisation
|
### 7.2 Olist V3 connection and composition synchronisation
|
||||||
* Graphs has a Super Admin **Olist** monitor at `/#/admin/olist`. The connection action, manual sync, reconnection, stop action, live status, run history, run logs, and affected-product view are all on this page; Cadastros does not own the Olist connection flow.
|
* Graphs has an **Administração** section available to every signed-in user. Its **Olist** monitor at `/#/admin/olist` owns the connection action, manual sync, reconnection, stop action, live status, run history, run logs, and affected-product view; Cadastros does not own the Olist connection flow. The same section includes user management at `/#/admin/users`.
|
||||||
* OAuth uses Olist/Tiny V3 with an authorization code and refresh token. Tokens are encrypted before storage in `olist_connections`; access tokens are refreshed automatically before expiry. The monitor shows the token expiry as an operational status, not an indication that the connection has failed.
|
* OAuth uses Olist/Tiny V3 with an authorization code and refresh token. Tokens are encrypted before storage in `olist_connections`; access tokens are refreshed automatically before expiry. The monitor shows the token expiry as an operational status, not an indication that the connection has failed.
|
||||||
* Required production variables are `OLIST_CLIENT_ID`, `OLIST_CLIENT_SECRET`, `OLIST_REDIRECT_URI`, `OLIST_FRONTEND_URL`, `OLIST_TOKEN_ENCRYPTION_KEY`, and `OLIST_SYNC_ENABLED=true`. The encryption key must be stable (a 32-byte base64 key or 64-character hex key): changing it makes already stored tokens unreadable and requires reconnection.
|
* Required production variables are `OLIST_CLIENT_ID`, `OLIST_CLIENT_SECRET`, `OLIST_REDIRECT_URI`, `OLIST_FRONTEND_URL`, `OLIST_TOKEN_ENCRYPTION_KEY`, and `OLIST_SYNC_ENABLED=true`. The encryption key must be stable (a 32-byte base64 key or 64-character hex key): changing it makes already stored tokens unreadable and requires reconnection.
|
||||||
* The OAuth callback route is `GET /api/olist/oauth/callback`; `OLIST_REDIRECT_URI` must be exactly that publicly reachable backend URL. After authorization Graphs redirects to `/#/admin/olist` on `OLIST_FRONTEND_URL`.
|
* The OAuth callback route is `GET /api/olist/oauth/callback`; `OLIST_REDIRECT_URI` must be exactly that publicly reachable backend URL. After authorization Graphs redirects to `/#/admin/olist` on `OLIST_FRONTEND_URL`.
|
||||||
|
|||||||
@@ -5,6 +5,7 @@
|
|||||||
"main": "index.js",
|
"main": "index.js",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"start": "node index.js",
|
"start": "node index.js",
|
||||||
|
"seed:supplies-demo": "node scripts/seedSuppliesDemo.js",
|
||||||
"test": "node --test"
|
"test": "node --test"
|
||||||
},
|
},
|
||||||
"keywords": [],
|
"keywords": [],
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
const express = require('express');
|
const express = require('express');
|
||||||
const { verifySuperAdmin } = require('../auth');
|
const { verifyToken } = require('../auth');
|
||||||
const {
|
const {
|
||||||
completeAuthorization,
|
completeAuthorization,
|
||||||
createAuthorizationUrl,
|
createAuthorizationUrl,
|
||||||
@@ -13,7 +13,7 @@ const { exportMissingProductionOrderDataCsv, importFinalizedProductionOrderCsv,
|
|||||||
|
|
||||||
const router = express.Router();
|
const router = express.Router();
|
||||||
|
|
||||||
router.get('/olist/status', verifySuperAdmin, async (req, res, next) => {
|
router.get('/olist/status', verifyToken, async (req, res, next) => {
|
||||||
try {
|
try {
|
||||||
res.json(await getOlistStatus({
|
res.json(await getOlistStatus({
|
||||||
runsPage: req.query.runsPage,
|
runsPage: req.query.runsPage,
|
||||||
@@ -24,7 +24,7 @@ router.get('/olist/status', verifySuperAdmin, async (req, res, next) => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
router.post('/olist/authorization-url', verifySuperAdmin, async (req, res, next) => {
|
router.post('/olist/authorization-url', verifyToken, async (req, res, next) => {
|
||||||
try {
|
try {
|
||||||
res.json({ url: await createAuthorizationUrl() });
|
res.json({ url: await createAuthorizationUrl() });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -32,7 +32,7 @@ router.post('/olist/authorization-url', verifySuperAdmin, async (req, res, next)
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
router.post('/olist/sync', verifySuperAdmin, async (req, res, next) => {
|
router.post('/olist/sync', verifyToken, async (req, res, next) => {
|
||||||
try {
|
try {
|
||||||
const result = await startOlistSync({
|
const result = await startOlistSync({
|
||||||
trigger: 'manual',
|
trigger: 'manual',
|
||||||
@@ -44,7 +44,7 @@ router.post('/olist/sync', verifySuperAdmin, async (req, res, next) => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
router.post('/olist/sync/stop', verifySuperAdmin, async (req, res, next) => {
|
router.post('/olist/sync/stop', verifyToken, async (req, res, next) => {
|
||||||
try {
|
try {
|
||||||
res.status(202).json(await requestOlistSyncStop());
|
res.status(202).json(await requestOlistSyncStop());
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -52,7 +52,7 @@ router.post('/olist/sync/stop', verifySuperAdmin, async (req, res, next) => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
router.get('/olist/production-order-imports', verifySuperAdmin, async (req, res, next) => {
|
router.get('/olist/production-order-imports', verifyToken, async (req, res, next) => {
|
||||||
try {
|
try {
|
||||||
res.json(await listProductionOrderImportData({
|
res.json(await listProductionOrderImportData({
|
||||||
runsPage: req.query.runsPage,
|
runsPage: req.query.runsPage,
|
||||||
@@ -63,7 +63,7 @@ router.get('/olist/production-order-imports', verifySuperAdmin, async (req, res,
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
router.get('/olist/production-order-imports/pending-csv', verifySuperAdmin, async (req, res, next) => {
|
router.get('/olist/production-order-imports/pending-csv', verifyToken, async (req, res, next) => {
|
||||||
try {
|
try {
|
||||||
res.json(await exportMissingProductionOrderDataCsv());
|
res.json(await exportMissingProductionOrderDataCsv());
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -71,7 +71,7 @@ router.get('/olist/production-order-imports/pending-csv', verifySuperAdmin, asyn
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
router.post('/olist/production-order-imports', verifySuperAdmin, async (req, res, next) => {
|
router.post('/olist/production-order-imports', verifyToken, async (req, res, next) => {
|
||||||
try {
|
try {
|
||||||
res.status(201).json(await importFinalizedProductionOrderCsv(req.body || {}));
|
res.status(201).json(await importFinalizedProductionOrderCsv(req.body || {}));
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -79,7 +79,7 @@ router.post('/olist/production-order-imports', verifySuperAdmin, async (req, res
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
router.get('/olist/runs/:runId', verifySuperAdmin, async (req, res, next) => {
|
router.get('/olist/runs/:runId', verifyToken, async (req, res, next) => {
|
||||||
try {
|
try {
|
||||||
res.json(await getOlistRunDetails(req.params.runId, {
|
res.json(await getOlistRunDetails(req.params.runId, {
|
||||||
eventsPage: req.query.eventsPage,
|
eventsPage: req.query.eventsPage,
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
const express = require('express');
|
const express = require('express');
|
||||||
const { verifySuperAdmin, verifyToken } = require('../auth');
|
const { verifyToken } = require('../auth');
|
||||||
const { createUser, deleteUser, listActiveOperationalUsers, listUsers, updateUser } = require('../services/userService');
|
const { createUser, deleteUser, listActiveOperationalUsers, listUsers, updateUser } = require('../services/userService');
|
||||||
|
|
||||||
const router = express.Router();
|
const router = express.Router();
|
||||||
@@ -12,7 +12,7 @@ router.get('/users/operational', verifyToken, async (req, res, next) => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
router.get('/users', verifySuperAdmin, async (req, res, next) => {
|
router.get('/users', verifyToken, async (req, res, next) => {
|
||||||
try {
|
try {
|
||||||
const users = await listUsers();
|
const users = await listUsers();
|
||||||
res.json({ users });
|
res.json({ users });
|
||||||
@@ -21,7 +21,7 @@ router.get('/users', verifySuperAdmin, async (req, res, next) => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
router.post('/users', verifySuperAdmin, async (req, res, next) => {
|
router.post('/users', verifyToken, async (req, res, next) => {
|
||||||
try {
|
try {
|
||||||
const { name, email, password } = req.body || {};
|
const { name, email, password } = req.body || {};
|
||||||
const { user, password: userPassword, generatedPassword } = await createUser({ name, email, password });
|
const { user, password: userPassword, generatedPassword } = await createUser({ name, email, password });
|
||||||
@@ -40,7 +40,7 @@ router.post('/users', verifySuperAdmin, async (req, res, next) => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
router.patch('/users/:id', verifySuperAdmin, async (req, res, next) => {
|
router.patch('/users/:id', verifyToken, async (req, res, next) => {
|
||||||
try {
|
try {
|
||||||
const user = await updateUser(req.params.id, req.body || {});
|
const user = await updateUser(req.params.id, req.body || {});
|
||||||
res.json({ user });
|
res.json({ user });
|
||||||
@@ -49,7 +49,7 @@ router.patch('/users/:id', verifySuperAdmin, async (req, res, next) => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
router.delete('/users/:id', verifySuperAdmin, async (req, res, next) => {
|
router.delete('/users/:id', verifyToken, async (req, res, next) => {
|
||||||
try {
|
try {
|
||||||
await deleteUser(req.params.id);
|
await deleteUser(req.params.id);
|
||||||
res.status(204).send();
|
res.status(204).send();
|
||||||
|
|||||||
543
backend/scripts/seedSuppliesDemo.js
Normal file
543
backend/scripts/seedSuppliesDemo.js
Normal file
@@ -0,0 +1,543 @@
|
|||||||
|
const { pool, initDB } = require('../db');
|
||||||
|
|
||||||
|
// This seed is deliberately isolated from Tiny/Olist data. It only creates or
|
||||||
|
// refreshes records with LOCAL-DEMO / DEMONSTRAÇÃO identifiers, so it is safe
|
||||||
|
// to run again in a local environment when the operational screens need data.
|
||||||
|
const DEMO = 'DEMONSTRAÇÃO';
|
||||||
|
|
||||||
|
const normalizeMaterialKey = (value) => String(value || '')
|
||||||
|
.normalize('NFD')
|
||||||
|
.replace(/[\u0300-\u036f]/g, '')
|
||||||
|
.toLowerCase()
|
||||||
|
.trim()
|
||||||
|
.replace(/\s+/g, ' ');
|
||||||
|
|
||||||
|
const today = new Date().toISOString().slice(0, 10);
|
||||||
|
const offsetDate = (days) => {
|
||||||
|
const date = new Date();
|
||||||
|
date.setDate(date.getDate() + days);
|
||||||
|
return date.toISOString().slice(0, 10);
|
||||||
|
};
|
||||||
|
|
||||||
|
const getCategoryId = async (client, name) => {
|
||||||
|
const result = await client.query('SELECT id FROM catalog_categories WHERE name = $1', [name]);
|
||||||
|
return result.rows[0]?.id || null;
|
||||||
|
};
|
||||||
|
|
||||||
|
const upsertCatalogProduct = async (client, product) => {
|
||||||
|
const categoryId = await getCategoryId(client, product.category);
|
||||||
|
const result = await client.query(`
|
||||||
|
INSERT INTO catalog_products (type, sku, name, category_id, color, sizes, notes, updated_at)
|
||||||
|
VALUES ($1, $2, $3, $4, $5, $6, $7, CURRENT_TIMESTAMP)
|
||||||
|
ON CONFLICT (sku) DO UPDATE
|
||||||
|
SET type = EXCLUDED.type,
|
||||||
|
name = EXCLUDED.name,
|
||||||
|
category_id = EXCLUDED.category_id,
|
||||||
|
color = EXCLUDED.color,
|
||||||
|
sizes = EXCLUDED.sizes,
|
||||||
|
notes = EXCLUDED.notes,
|
||||||
|
updated_at = CURRENT_TIMESTAMP
|
||||||
|
RETURNING id;
|
||||||
|
`, [
|
||||||
|
product.type,
|
||||||
|
product.sku,
|
||||||
|
product.name,
|
||||||
|
categoryId,
|
||||||
|
product.color || null,
|
||||||
|
product.sizes || [],
|
||||||
|
`${DEMO} — registro local para visualizar o fluxo de Suprimentos.`
|
||||||
|
]);
|
||||||
|
return Number(result.rows[0].id);
|
||||||
|
};
|
||||||
|
|
||||||
|
const findCatalogProduct = async (client, sku) => {
|
||||||
|
const result = await client.query('SELECT id FROM catalog_products WHERE sku = $1', [sku]);
|
||||||
|
return result.rows[0] ? Number(result.rows[0].id) : null;
|
||||||
|
};
|
||||||
|
|
||||||
|
const upsertConsumptionReference = async (client, reference) => {
|
||||||
|
const existing = await client.query(`
|
||||||
|
SELECT id
|
||||||
|
FROM consumption_references
|
||||||
|
WHERE product_id = $1
|
||||||
|
AND COALESCE(material_product_id, 0) = COALESCE($2, 0)
|
||||||
|
AND COALESCE(color, '') = COALESCE($3, '')
|
||||||
|
AND COALESCE(source, 'manual') = 'demonstration'
|
||||||
|
LIMIT 1;
|
||||||
|
`, [reference.productId, reference.materialProductId, reference.color || null]);
|
||||||
|
|
||||||
|
const values = [
|
||||||
|
reference.productId,
|
||||||
|
reference.materialProductId,
|
||||||
|
reference.color || null,
|
||||||
|
reference.generalYield || null,
|
||||||
|
reference.consumptionQuantity || null,
|
||||||
|
reference.consumptionUnit || null
|
||||||
|
];
|
||||||
|
if (existing.rows[0]) {
|
||||||
|
await client.query(`
|
||||||
|
UPDATE consumption_references
|
||||||
|
SET general_yield = $4,
|
||||||
|
consumption_quantity = $5,
|
||||||
|
consumption_unit = $6,
|
||||||
|
updated_at = CURRENT_TIMESTAMP
|
||||||
|
WHERE id = $7;
|
||||||
|
`, [...values, existing.rows[0].id]);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await client.query(`
|
||||||
|
INSERT INTO consumption_references (
|
||||||
|
product_id, material_product_id, color, general_yield,
|
||||||
|
consumption_quantity, consumption_unit, source
|
||||||
|
) VALUES ($1, $2, $3, $4, $5, $6, 'demonstration');
|
||||||
|
`, values);
|
||||||
|
};
|
||||||
|
|
||||||
|
const upsertPurchaseOrder = async (client, order) => {
|
||||||
|
const result = await client.query(`
|
||||||
|
INSERT INTO supply_purchase_orders (number, supplier, status, expected_date, notes, updated_at)
|
||||||
|
VALUES ($1, $2, $3, $4, $5, CURRENT_TIMESTAMP)
|
||||||
|
ON CONFLICT (number) DO UPDATE
|
||||||
|
SET supplier = EXCLUDED.supplier,
|
||||||
|
status = EXCLUDED.status,
|
||||||
|
expected_date = EXCLUDED.expected_date,
|
||||||
|
notes = EXCLUDED.notes,
|
||||||
|
updated_at = CURRENT_TIMESTAMP
|
||||||
|
RETURNING id;
|
||||||
|
`, [order.number, order.supplier, order.status, order.expectedDate, order.notes]);
|
||||||
|
const id = Number(result.rows[0].id);
|
||||||
|
for (const item of order.items) {
|
||||||
|
await client.query(`
|
||||||
|
INSERT INTO supply_purchase_order_items (purchase_order_id, material, quantity, unit, unit_cost)
|
||||||
|
SELECT $1, $2, $3, $4, $5
|
||||||
|
WHERE NOT EXISTS (
|
||||||
|
SELECT 1 FROM supply_purchase_order_items
|
||||||
|
WHERE purchase_order_id = $1 AND material = $2
|
||||||
|
);
|
||||||
|
`, [id, item.material, item.quantity, item.unit, item.unitCost]);
|
||||||
|
}
|
||||||
|
return id;
|
||||||
|
};
|
||||||
|
|
||||||
|
const upsertReceipt = async (client, receipt) => {
|
||||||
|
const existing = await client.query('SELECT id FROM supply_receipts WHERE invoice = $1 LIMIT 1', [receipt.invoice]);
|
||||||
|
if (existing.rows[0]) {
|
||||||
|
await client.query(`
|
||||||
|
UPDATE supply_receipts
|
||||||
|
SET category = $1, product = $2, quantity = $3, unit = $4,
|
||||||
|
supplier = $5, notes = $6, status = $7, purchase_order_id = $8,
|
||||||
|
approved_at = CASE WHEN $7 = 'approved' THEN COALESCE(approved_at, CURRENT_TIMESTAMP) ELSE NULL END,
|
||||||
|
updated_at = CURRENT_TIMESTAMP
|
||||||
|
WHERE id = $9;
|
||||||
|
`, [receipt.category, receipt.product, receipt.quantity, receipt.unit, receipt.supplier, receipt.notes, receipt.status, receipt.purchaseOrderId, existing.rows[0].id]);
|
||||||
|
return Number(existing.rows[0].id);
|
||||||
|
}
|
||||||
|
const result = await client.query(`
|
||||||
|
INSERT INTO supply_receipts (
|
||||||
|
category, product, quantity, unit, supplier, invoice, notes, status,
|
||||||
|
purchase_order_id, approved_at
|
||||||
|
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9,
|
||||||
|
CASE WHEN $8 = 'approved' THEN CURRENT_TIMESTAMP ELSE NULL END)
|
||||||
|
RETURNING id;
|
||||||
|
`, [receipt.category, receipt.product, receipt.quantity, receipt.unit, receipt.supplier, receipt.invoice, receipt.notes, receipt.status, receipt.purchaseOrderId]);
|
||||||
|
return Number(result.rows[0].id);
|
||||||
|
};
|
||||||
|
|
||||||
|
const ensureLotAndMovement = async (client, receiptId) => {
|
||||||
|
const receipt = await client.query(`
|
||||||
|
SELECT id, category, product, quantity, unit, supplier, invoice
|
||||||
|
FROM supply_receipts
|
||||||
|
WHERE id = $1 AND status = 'approved';
|
||||||
|
`, [receiptId]);
|
||||||
|
if (!receipt.rows[0]) return null;
|
||||||
|
const row = receipt.rows[0];
|
||||||
|
const lot = await client.query(`
|
||||||
|
INSERT INTO supply_stock_lots (receipt_id, category, product, quantity, unit, supplier, invoice, status)
|
||||||
|
SELECT $1, $2, $3, $4, $5, $6, $7, 'active'
|
||||||
|
WHERE NOT EXISTS (SELECT 1 FROM supply_stock_lots WHERE receipt_id = $1)
|
||||||
|
RETURNING id;
|
||||||
|
`, [row.id, row.category, row.product, row.quantity, row.unit, row.supplier, row.invoice]);
|
||||||
|
const lotId = lot.rows[0]?.id || (await client.query('SELECT id FROM supply_stock_lots WHERE receipt_id = $1', [row.id])).rows[0]?.id;
|
||||||
|
if (!lotId) return null;
|
||||||
|
await client.query(`
|
||||||
|
INSERT INTO supply_movements (receipt_id, lot_id, type, category, product, quantity, unit, reason)
|
||||||
|
SELECT $1, $2, 'receipt', $3, $4, $5, $6, $7
|
||||||
|
WHERE NOT EXISTS (
|
||||||
|
SELECT 1 FROM supply_movements
|
||||||
|
WHERE receipt_id = $1 AND type = 'receipt'
|
||||||
|
);
|
||||||
|
`, [row.id, lotId, row.category, row.product, row.quantity, row.unit, `${DEMO} — entrada aprovada para visualização.`]);
|
||||||
|
return Number(lotId);
|
||||||
|
};
|
||||||
|
|
||||||
|
const upsertProductionPlan = async (client, plan, responsibleUserId) => {
|
||||||
|
const result = await client.query(`
|
||||||
|
INSERT INTO production_plans (
|
||||||
|
number, name, status, contractor, responsible_user_id, responsible_name,
|
||||||
|
planned_start_date, expected_date, notes, source
|
||||||
|
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, 'cut_plan')
|
||||||
|
ON CONFLICT (number) DO UPDATE
|
||||||
|
SET name = EXCLUDED.name,
|
||||||
|
status = EXCLUDED.status,
|
||||||
|
contractor = EXCLUDED.contractor,
|
||||||
|
responsible_user_id = EXCLUDED.responsible_user_id,
|
||||||
|
responsible_name = EXCLUDED.responsible_name,
|
||||||
|
planned_start_date = EXCLUDED.planned_start_date,
|
||||||
|
expected_date = EXCLUDED.expected_date,
|
||||||
|
notes = EXCLUDED.notes,
|
||||||
|
updated_at = CURRENT_TIMESTAMP
|
||||||
|
RETURNING id;
|
||||||
|
`, [
|
||||||
|
plan.number, plan.name, plan.status, plan.contractor, responsibleUserId,
|
||||||
|
plan.responsibleName, plan.plannedStartDate, plan.expectedDate, plan.notes
|
||||||
|
]);
|
||||||
|
return Number(result.rows[0].id);
|
||||||
|
};
|
||||||
|
|
||||||
|
const upsertProductionOrder = async (client, order) => {
|
||||||
|
const existing = await client.query('SELECT id FROM production_orders WHERE number = $1 LIMIT 1', [order.number]);
|
||||||
|
const values = [
|
||||||
|
order.status, order.planId, order.contractor, order.orderReference, order.issueDate,
|
||||||
|
order.expectedDate, order.productSku, order.productDescription, order.quantity, order.unit,
|
||||||
|
order.notes, order.supplier, order.lotCode, order.rollQuantity, order.fabricKg,
|
||||||
|
order.ribKg, order.yieldPiecesPerKg, order.actualCutQuantity, order.rejectedQuantity,
|
||||||
|
order.finishedQuantity, order.qualityNotes, order.cutLayers, order.markerReference
|
||||||
|
];
|
||||||
|
if (existing.rows[0]) {
|
||||||
|
await client.query(`
|
||||||
|
UPDATE production_orders
|
||||||
|
SET status = $1, production_plan_id = $2, contractor = $3, order_reference = $4,
|
||||||
|
issue_date = $5, expected_date = $6, product_sku = $7, product_description = $8,
|
||||||
|
quantity = $9, unit = $10, notes = $11, supplier = $12, lot_code = $13,
|
||||||
|
roll_quantity = $14, fabric_kg = $15, rib_kg = $16, yield_pieces_per_kg = $17,
|
||||||
|
actual_cut_quantity = $18, rejected_quantity = $19, finished_quantity = $20,
|
||||||
|
quality_notes = $21, cut_layers = $22, marker_reference = $23,
|
||||||
|
updated_at = CURRENT_TIMESTAMP
|
||||||
|
WHERE id = $24;
|
||||||
|
`, [...values, existing.rows[0].id]);
|
||||||
|
return Number(existing.rows[0].id);
|
||||||
|
}
|
||||||
|
const result = await client.query(`
|
||||||
|
INSERT INTO production_orders (
|
||||||
|
number, status, production_plan_id, contractor, order_reference, issue_date,
|
||||||
|
expected_date, product_sku, product_description, quantity, unit, notes, supplier,
|
||||||
|
lot_code, roll_quantity, fabric_kg, rib_kg, yield_pieces_per_kg,
|
||||||
|
actual_cut_quantity, rejected_quantity, finished_quantity, quality_notes,
|
||||||
|
cut_layers, marker_reference
|
||||||
|
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13,
|
||||||
|
$14, $15, $16, $17, $18, $19, $20, $21, $22, $23, $24)
|
||||||
|
RETURNING id;
|
||||||
|
`, [order.number, ...values]);
|
||||||
|
return Number(result.rows[0].id);
|
||||||
|
};
|
||||||
|
|
||||||
|
const ensureOrderDetails = async (client, orderId, details) => {
|
||||||
|
for (const marker of details.markers) {
|
||||||
|
await client.query(`
|
||||||
|
INSERT INTO production_order_markers (production_order_id, label, color)
|
||||||
|
SELECT $1, $2, $3
|
||||||
|
WHERE NOT EXISTS (
|
||||||
|
SELECT 1 FROM production_order_markers WHERE production_order_id = $1 AND label = $2
|
||||||
|
);
|
||||||
|
`, [orderId, marker.label, marker.color]);
|
||||||
|
}
|
||||||
|
for (const component of details.components) {
|
||||||
|
await client.query(`
|
||||||
|
INSERT INTO production_order_components (production_order_id, component_sku, component_name, quantity_per_unit, total_quantity, unit)
|
||||||
|
SELECT $1, $2, $3, $4, $5, $6
|
||||||
|
WHERE NOT EXISTS (
|
||||||
|
SELECT 1 FROM production_order_components
|
||||||
|
WHERE production_order_id = $1 AND component_name = $3
|
||||||
|
);
|
||||||
|
`, [orderId, component.sku, component.name, component.quantityPerUnit, component.totalQuantity, component.unit]);
|
||||||
|
}
|
||||||
|
for (const step of details.steps) {
|
||||||
|
await client.query(`
|
||||||
|
INSERT INTO production_order_steps (production_order_id, step_number, name, start_date, end_date, status, color)
|
||||||
|
SELECT $1, $2, $3, $4, $5, $6, $7
|
||||||
|
WHERE NOT EXISTS (
|
||||||
|
SELECT 1 FROM production_order_steps
|
||||||
|
WHERE production_order_id = $1 AND step_number = $2
|
||||||
|
);
|
||||||
|
`, [orderId, step.number, step.name, step.startDate, step.endDate, step.status, step.color]);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
await initDB();
|
||||||
|
const client = await pool.connect();
|
||||||
|
try {
|
||||||
|
await client.query('BEGIN');
|
||||||
|
|
||||||
|
const materials = {
|
||||||
|
fabric: await upsertCatalogProduct(client, {
|
||||||
|
type: 'raw_material', sku: 'LOCAL-DEMO-MAT-MALHA-PRETA', category: 'Malha',
|
||||||
|
name: 'DEMO-MALHA-300 MALHA 30/1 COR PRETO', color: 'Preto'
|
||||||
|
}),
|
||||||
|
rib: await upsertCatalogProduct(client, {
|
||||||
|
type: 'raw_material', sku: 'LOCAL-DEMO-MAT-RIBANA-PRETA', category: 'Aviamentos',
|
||||||
|
name: 'DEMO-0201 RIBANA 2X1 COR PRETO', color: 'Preto'
|
||||||
|
}),
|
||||||
|
hoodieFabric: await upsertCatalogProduct(client, {
|
||||||
|
type: 'raw_material', sku: 'LOCAL-DEMO-MAT-MOLETOM-GRAFITE', category: 'Malha',
|
||||||
|
name: 'DEMO-MOLETOM 3 CABOS COR GRAFITE', color: 'Grafite'
|
||||||
|
}),
|
||||||
|
labelM: await upsertCatalogProduct(client, {
|
||||||
|
type: 'raw_material', sku: 'LOCAL-DEMO-MAT-ETIQUETA-M', category: 'Embalagens',
|
||||||
|
name: 'DEMO-ETIQUETA TAMANHO M', sizes: ['M']
|
||||||
|
}),
|
||||||
|
labelG: await upsertCatalogProduct(client, {
|
||||||
|
type: 'raw_material', sku: 'LOCAL-DEMO-MAT-ETIQUETA-G', category: 'Embalagens',
|
||||||
|
name: 'DEMO-ETIQUETA TAMANHO G', sizes: ['G']
|
||||||
|
})
|
||||||
|
};
|
||||||
|
|
||||||
|
const finishedProducts = {
|
||||||
|
teeM: await findCatalogProduct(client, 'LOCAL-DEMO-UX-TEE-M'),
|
||||||
|
teeG: await findCatalogProduct(client, 'LOCAL-DEMO-UX-TEE-G'),
|
||||||
|
hoodieM: await findCatalogProduct(client, 'LOCAL-DEMO-UX-HOODIE-M')
|
||||||
|
};
|
||||||
|
if (!Object.values(finishedProducts).every(Boolean)) {
|
||||||
|
throw new Error('Os produtos locais de demonstração esperados não foram encontrados.');
|
||||||
|
}
|
||||||
|
|
||||||
|
await Promise.all([
|
||||||
|
upsertConsumptionReference(client, { productId: finishedProducts.teeM, materialProductId: materials.fabric, color: 'Preto', consumptionQuantity: 0.185, consumptionUnit: 'kg' }),
|
||||||
|
upsertConsumptionReference(client, { productId: finishedProducts.teeM, materialProductId: materials.rib, color: 'Preto', consumptionQuantity: 0.01, consumptionUnit: 'kg' }),
|
||||||
|
upsertConsumptionReference(client, { productId: finishedProducts.teeM, materialProductId: materials.labelM, consumptionQuantity: 1, consumptionUnit: 'un.' }),
|
||||||
|
upsertConsumptionReference(client, { productId: finishedProducts.teeG, materialProductId: materials.fabric, color: 'Preto', consumptionQuantity: 0.2, consumptionUnit: 'kg' }),
|
||||||
|
upsertConsumptionReference(client, { productId: finishedProducts.teeG, materialProductId: materials.rib, color: 'Preto', consumptionQuantity: 0.011, consumptionUnit: 'kg' }),
|
||||||
|
upsertConsumptionReference(client, { productId: finishedProducts.teeG, materialProductId: materials.labelG, consumptionQuantity: 1, consumptionUnit: 'un.' }),
|
||||||
|
upsertConsumptionReference(client, { productId: finishedProducts.hoodieM, materialProductId: materials.hoodieFabric, color: 'Grafite', consumptionQuantity: 0.75, consumptionUnit: 'kg' }),
|
||||||
|
upsertConsumptionReference(client, { productId: finishedProducts.hoodieM, materialProductId: materials.rib, color: 'Grafite', consumptionQuantity: 0.045, consumptionUnit: 'kg' }),
|
||||||
|
upsertConsumptionReference(client, { productId: finishedProducts.hoodieM, materialProductId: materials.labelM, consumptionQuantity: 1, consumptionUnit: 'un.' })
|
||||||
|
]);
|
||||||
|
|
||||||
|
const supplier = `${DEMO} Têxteis`;
|
||||||
|
const materialDetails = [
|
||||||
|
['DEMO-MALHA-300 MALHA 30/1 COR PRETO', supplier, 'Malha reservada para a semana de demonstração.'],
|
||||||
|
['DEMO-0201 RIBANA 2X1 COR PRETO', supplier, 'Aviamento de gola e acabamento.'],
|
||||||
|
['DEMO-MOLETOM 3 CABOS COR GRAFITE', `${DEMO} Malharia`, 'Material para o plano de moletom.']
|
||||||
|
];
|
||||||
|
for (const [material, preferredSupplier, notes] of materialDetails) {
|
||||||
|
await client.query(`
|
||||||
|
INSERT INTO supply_material_details (material_key, display_name, preferred_supplier, notes, updated_at)
|
||||||
|
VALUES ($1, $2, $3, $4, CURRENT_TIMESTAMP)
|
||||||
|
ON CONFLICT (material_key) DO UPDATE
|
||||||
|
SET display_name = EXCLUDED.display_name,
|
||||||
|
preferred_supplier = EXCLUDED.preferred_supplier,
|
||||||
|
notes = EXCLUDED.notes,
|
||||||
|
updated_at = CURRENT_TIMESTAMP;
|
||||||
|
`, [normalizeMaterialKey(material), material, preferredSupplier, notes]);
|
||||||
|
}
|
||||||
|
|
||||||
|
const fabricPlans = [
|
||||||
|
['DEMO-MALHA-300 MALHA 30/1 COR PRETO', 'Preto', 120, supplier, 'Crítico'],
|
||||||
|
['DEMO-0201 RIBANA 2X1 COR PRETO', 'Preto', 24, supplier, 'Atenção'],
|
||||||
|
['DEMO-MOLETOM 3 CABOS COR GRAFITE', 'Grafite', 90, `${DEMO} Malharia`, 'Normal']
|
||||||
|
];
|
||||||
|
for (const [material, color, quantity, planSupplier, priority] of fabricPlans) {
|
||||||
|
await client.query(`
|
||||||
|
INSERT INTO supply_fabric_plans (material, color, quantity_kg, supplier, priority, status, updated_at)
|
||||||
|
SELECT $1, $2, $3, $4, $5, 'active', CURRENT_TIMESTAMP
|
||||||
|
WHERE NOT EXISTS (
|
||||||
|
SELECT 1 FROM supply_fabric_plans
|
||||||
|
WHERE material = $1 AND COALESCE(color, '') = COALESCE($2, '') AND status = 'active'
|
||||||
|
);
|
||||||
|
`, [material, color, quantity, planSupplier, priority]);
|
||||||
|
}
|
||||||
|
|
||||||
|
const poApproved = await upsertPurchaseOrder(client, {
|
||||||
|
number: 'OC-DEMO-0001', supplier, status: 'approved', expectedDate: offsetDate(-2),
|
||||||
|
notes: `${DEMO} — compra aprovada; o recebimento está disponível para conferência.`,
|
||||||
|
items: [{ material: 'DEMO-MALHA-300 MALHA 30/1 COR PRETO', quantity: 120, unit: 'kg', unitCost: 29.9 }]
|
||||||
|
});
|
||||||
|
const poSent = await upsertPurchaseOrder(client, {
|
||||||
|
number: 'OC-DEMO-0002', supplier, status: 'sent', expectedDate: offsetDate(4),
|
||||||
|
notes: `${DEMO} — pedido enviado ao fornecedor e aguardando chegada.`,
|
||||||
|
items: [{ material: 'DEMO-0201 RIBANA 2X1 COR PRETO', quantity: 24, unit: 'kg', unitCost: 34.5 }]
|
||||||
|
});
|
||||||
|
const poFinancial = await upsertPurchaseOrder(client, {
|
||||||
|
number: 'OC-DEMO-0003', supplier: `${DEMO} Etiquetas`, status: 'pending_financial', expectedDate: offsetDate(6),
|
||||||
|
notes: `${DEMO} — aguardando aprovação financeira; não libera saldo físico.`,
|
||||||
|
items: [{ material: 'DEMO-ETIQUETA TAMANHO M', quantity: 500, unit: 'un.', unitCost: 0.28 }]
|
||||||
|
});
|
||||||
|
|
||||||
|
const receiptIds = [];
|
||||||
|
receiptIds.push(await upsertReceipt(client, {
|
||||||
|
category: 'Malha', product: 'DEMO-MALHA-300 MALHA 30/1 COR PRETO', quantity: 120, unit: 'kg',
|
||||||
|
supplier, invoice: 'DEMO-NF-004', status: 'approved', purchaseOrderId: poApproved,
|
||||||
|
notes: `${DEMO} — recebimento conferido e saldo liberado.`
|
||||||
|
}));
|
||||||
|
receiptIds.push(await upsertReceipt(client, {
|
||||||
|
category: 'Aviamentos', product: 'DEMO-0201 RIBANA 2X1 COR PRETO', quantity: 24, unit: 'kg',
|
||||||
|
supplier, invoice: 'DEMO-NF-005', status: 'pending', purchaseOrderId: poSent,
|
||||||
|
notes: `${DEMO} — aguardando conferência física e aprovação da compra.`
|
||||||
|
}));
|
||||||
|
receiptIds.push(await upsertReceipt(client, {
|
||||||
|
category: 'Embalagens', product: 'DEMO-ETIQUETA TAMANHO M', quantity: 500, unit: 'un.',
|
||||||
|
supplier: `${DEMO} Etiquetas`, invoice: 'DEMO-NF-006', status: 'pending', purchaseOrderId: poFinancial,
|
||||||
|
notes: `${DEMO} — saldo bloqueado até a aprovação financeira da OC.`
|
||||||
|
}));
|
||||||
|
const lotIds = [];
|
||||||
|
for (const receiptId of receiptIds) {
|
||||||
|
const lotId = await ensureLotAndMovement(client, receiptId);
|
||||||
|
if (lotId) lotIds.push(lotId);
|
||||||
|
}
|
||||||
|
|
||||||
|
const transformations = [
|
||||||
|
['DEMO-LT-2408-01', 'tingimento', 'DEMO-MALHA-300 CRUA', 260, 'DEMO-MALHA-300 MALHA 30/1', 'Preto', 2, 254.8, `${DEMO} Tinturaria`, 'Atenção', 'in_progress', `${DEMO} — lote em tingimento.`],
|
||||||
|
['DEMO-LT-2408-02', 'acabamento', 'DEMO-MOLETOM 3 CABOS', 96, 'DEMO-MOLETOM 3 CABOS', 'Grafite', 1.5, 94.56, `${DEMO} Malharia`, 'Normal', 'planned', `${DEMO} — acabamento programado.`],
|
||||||
|
['DEMO-LT-2408-00', 'malharia', 'DEMO-FIO 30/1 PRETO', 180, 'DEMO-MALHA-300 MALHA 30/1', 'Preto', 3, 174.6, `${DEMO} Malharia`, 'Normal', 'completed', `${DEMO} — lote concluído para rastreabilidade.`]
|
||||||
|
];
|
||||||
|
for (const transformation of transformations) {
|
||||||
|
await client.query(`
|
||||||
|
INSERT INTO supply_material_transformations (
|
||||||
|
batch_code, stage, input_material, input_quantity_kg, output_material,
|
||||||
|
output_color, expected_loss_percent, expected_output_kg, supplier,
|
||||||
|
priority, status, notes, updated_at
|
||||||
|
) SELECT $1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, CURRENT_TIMESTAMP
|
||||||
|
WHERE NOT EXISTS (
|
||||||
|
SELECT 1 FROM supply_material_transformations
|
||||||
|
WHERE batch_code = $1 AND stage = $2
|
||||||
|
);
|
||||||
|
`, transformation);
|
||||||
|
}
|
||||||
|
|
||||||
|
const user = await client.query(`
|
||||||
|
SELECT id, name FROM app_users WHERE is_active = TRUE ORDER BY id LIMIT 1;
|
||||||
|
`);
|
||||||
|
const responsibleUserId = user.rows[0]?.id || null;
|
||||||
|
const responsibleName = user.rows[0]?.name || 'Operação Graphs';
|
||||||
|
const planCut = await upsertProductionPlan(client, {
|
||||||
|
number: 'PP-DEMO-0001', name: `${DEMO} | Corte preto — semana operacional`, status: 'cutting',
|
||||||
|
contractor: `${DEMO} Confecção`, responsibleName, plannedStartDate: offsetDate(-1), expectedDate: offsetDate(3),
|
||||||
|
notes: `${DEMO} — plano liberado com lote e risco definidos.`
|
||||||
|
}, responsibleUserId);
|
||||||
|
const planHoodie = await upsertProductionPlan(client, {
|
||||||
|
number: 'PP-DEMO-0002', name: `${DEMO} | Moletom grafite — reposição`, status: 'open',
|
||||||
|
contractor: `${DEMO} Confecção`, responsibleName, plannedStartDate: offsetDate(3), expectedDate: offsetDate(8),
|
||||||
|
notes: `${DEMO} — aguardando liberação da etapa de malha.`
|
||||||
|
}, responsibleUserId);
|
||||||
|
await client.query(`
|
||||||
|
INSERT INTO production_plan_reviews (production_plan_id, decision, checklist, notes, reviewed_by, reviewed_at)
|
||||||
|
VALUES ($1, 'approved', $2::jsonb, $3, $4, CURRENT_TIMESTAMP)
|
||||||
|
ON CONFLICT (production_plan_id) DO UPDATE
|
||||||
|
SET decision = EXCLUDED.decision,
|
||||||
|
checklist = EXCLUDED.checklist,
|
||||||
|
notes = EXCLUDED.notes,
|
||||||
|
reviewed_by = EXCLUDED.reviewed_by,
|
||||||
|
reviewed_at = EXCLUDED.reviewed_at,
|
||||||
|
updated_at = CURRENT_TIMESTAMP;
|
||||||
|
`, [planCut, JSON.stringify({ material: true, yield: true, responsible: true, deadline: true }), `${DEMO} — checklist operacional concluído.`, responsibleName]);
|
||||||
|
|
||||||
|
const orderM = await upsertProductionOrder(client, {
|
||||||
|
number: 'OP-DEMO-0001', status: 'cutting', planId: planCut, contractor: `${DEMO} Confecção`,
|
||||||
|
orderReference: 'DEMO-CORTE-35', issueDate: offsetDate(-1), expectedDate: offsetDate(2),
|
||||||
|
productSku: 'LOCAL-DEMO-UX-TEE-M', productDescription: 'DEMO UX CAMISETA PRETA TAMANHO M',
|
||||||
|
quantity: 180, unit: 'UN', notes: `${DEMO} — acompanhar aproveitamento de risco.`, supplier,
|
||||||
|
lotCode: 'DEMO-NF-004', rollQuantity: 3, fabricKg: 33.3, ribKg: 1.8, yieldPiecesPerKg: 5.4,
|
||||||
|
actualCutQuantity: 150, rejectedQuantity: 2, finishedQuantity: 0,
|
||||||
|
qualityNotes: `${DEMO} — dois recortes separados para revisão de tecido.`, cutLayers: 12,
|
||||||
|
markerReference: 'RISCO-DEMO-M-12'
|
||||||
|
});
|
||||||
|
const orderG = await upsertProductionOrder(client, {
|
||||||
|
number: 'OP-DEMO-0002', status: 'sewing', planId: planCut, contractor: `${DEMO} Confecção`,
|
||||||
|
orderReference: 'DEMO-CORTE-35', issueDate: today, expectedDate: offsetDate(3),
|
||||||
|
productSku: 'LOCAL-DEMO-UX-TEE-G', productDescription: 'DEMO UX CAMISETA PRETA TAMANHO G',
|
||||||
|
quantity: 140, unit: 'UN', notes: `${DEMO} — lote encaminhado para costura.`, supplier,
|
||||||
|
lotCode: 'DEMO-NF-004', rollQuantity: 2, fabricKg: 28, ribKg: 1.54, yieldPiecesPerKg: 5,
|
||||||
|
actualCutQuantity: 140, rejectedQuantity: 1, finishedQuantity: 92,
|
||||||
|
qualityNotes: `${DEMO} — conferir gola nas primeiras peças.`, cutLayers: 9,
|
||||||
|
markerReference: 'RISCO-DEMO-G-09'
|
||||||
|
});
|
||||||
|
const orderHoodie = await upsertProductionOrder(client, {
|
||||||
|
number: 'OP-DEMO-0003', status: 'review', planId: planHoodie, contractor: `${DEMO} Confecção`,
|
||||||
|
orderReference: 'DEMO-MOLETOM-35', issueDate: offsetDate(2), expectedDate: offsetDate(8),
|
||||||
|
productSku: 'LOCAL-DEMO-UX-HOODIE-M', productDescription: 'DEMO UX MOLETOM GRAFITE TAMANHO M',
|
||||||
|
quantity: 60, unit: 'UN', notes: `${DEMO} — amostra segue para revisão antes da liberação total.`, supplier: `${DEMO} Malharia`,
|
||||||
|
lotCode: 'DEMO-LT-2408-02', rollQuantity: 2, fabricKg: 45, ribKg: 2.7, yieldPiecesPerKg: 1.33,
|
||||||
|
actualCutQuantity: 60, rejectedQuantity: 2, finishedQuantity: 52,
|
||||||
|
qualityNotes: `${DEMO} — registrar avarias por posto de trabalho.`, cutLayers: 6,
|
||||||
|
markerReference: 'RISCO-DEMO-MOLETOM-06'
|
||||||
|
});
|
||||||
|
|
||||||
|
await ensureOrderDetails(client, orderM, {
|
||||||
|
markers: [{ label: 'Risco M — 12 camadas', color: '#fbbf24' }],
|
||||||
|
components: [
|
||||||
|
{ sku: 'LOCAL-DEMO-MAT-MALHA-PRETA', name: 'DEMO-MALHA-300 MALHA 30/1 COR PRETO', quantityPerUnit: 0.185, totalQuantity: 33.3, unit: 'kg' },
|
||||||
|
{ sku: 'LOCAL-DEMO-MAT-RIBANA-PRETA', name: 'DEMO-0201 RIBANA 2X1 COR PRETO', quantityPerUnit: 0.01, totalQuantity: 1.8, unit: 'kg' }
|
||||||
|
],
|
||||||
|
steps: [
|
||||||
|
{ number: 1, name: 'Separação do lote', startDate: offsetDate(-1), endDate: offsetDate(-1), status: 'finished', color: '#22c55e' },
|
||||||
|
{ number: 2, name: 'Corte', startDate: today, endDate: offsetDate(1), status: 'in_progress', color: '#fbbf24' },
|
||||||
|
{ number: 3, name: 'Costura', startDate: offsetDate(1), endDate: offsetDate(2), status: 'pending', color: '#38bdf8' }
|
||||||
|
]
|
||||||
|
});
|
||||||
|
await ensureOrderDetails(client, orderG, {
|
||||||
|
markers: [{ label: 'Risco G — 9 camadas', color: '#38bdf8' }],
|
||||||
|
components: [{ sku: 'LOCAL-DEMO-MAT-MALHA-PRETA', name: 'DEMO-MALHA-300 MALHA 30/1 COR PRETO', quantityPerUnit: 0.2, totalQuantity: 28, unit: 'kg' }],
|
||||||
|
steps: [
|
||||||
|
{ number: 1, name: 'Corte', startDate: offsetDate(-1), endDate: today, status: 'finished', color: '#22c55e' },
|
||||||
|
{ number: 2, name: 'Costura', startDate: today, endDate: offsetDate(2), status: 'in_progress', color: '#38bdf8' },
|
||||||
|
{ number: 3, name: 'Revisão', startDate: offsetDate(2), endDate: offsetDate(3), status: 'pending', color: '#a78bfa' }
|
||||||
|
]
|
||||||
|
});
|
||||||
|
await ensureOrderDetails(client, orderHoodie, {
|
||||||
|
markers: [{ label: 'Risco moletom — 6 camadas', color: '#a78bfa' }],
|
||||||
|
components: [{ sku: 'LOCAL-DEMO-MAT-MOLETOM-GRAFITE', name: 'DEMO-MOLETOM 3 CABOS COR GRAFITE', quantityPerUnit: 0.75, totalQuantity: 45, unit: 'kg' }],
|
||||||
|
steps: [
|
||||||
|
{ number: 1, name: 'Corte', startDate: offsetDate(2), endDate: offsetDate(3), status: 'finished', color: '#22c55e' },
|
||||||
|
{ number: 2, name: 'Costura', startDate: offsetDate(3), endDate: offsetDate(6), status: 'finished', color: '#38bdf8' },
|
||||||
|
{ number: 3, name: 'Revisão', startDate: offsetDate(6), endDate: offsetDate(8), status: 'in_progress', color: '#a78bfa' }
|
||||||
|
]
|
||||||
|
});
|
||||||
|
|
||||||
|
const lotForReservation = lotIds[0];
|
||||||
|
if (lotForReservation) {
|
||||||
|
await client.query(`
|
||||||
|
INSERT INTO cut_plan_lot_reservations (
|
||||||
|
cut_plan_reference, production_plan_id, supply_lot_id, material_key,
|
||||||
|
quantity, consumed_quantity, status
|
||||||
|
) SELECT 'DEMO-CORTE-35', $1, $2, $3, 61.3, 0, 'active'
|
||||||
|
WHERE NOT EXISTS (
|
||||||
|
SELECT 1 FROM cut_plan_lot_reservations
|
||||||
|
WHERE cut_plan_reference = 'DEMO-CORTE-35' AND supply_lot_id = $2
|
||||||
|
);
|
||||||
|
`, [planCut, lotForReservation, normalizeMaterialKey('DEMO-MALHA-300 MALHA 30/1 COR PRETO')]);
|
||||||
|
}
|
||||||
|
|
||||||
|
const workstations = await client.query('SELECT id, code FROM production_workstations');
|
||||||
|
const workstationByCode = new Map(workstations.rows.map(row => [row.code, row.id]));
|
||||||
|
const defects = [
|
||||||
|
[orderM, workstationByCode.get('cutting'), 'Furo na malha', 'Tecido', 2, 'DEMO-NF-004', `${DEMO} — conferir rolo antes de seguir o corte.`],
|
||||||
|
[orderG, workstationByCode.get('sewing'), 'Costura desalinhada', 'Costura', 1, 'DEMO-NF-004', `${DEMO} — ajuste solicitado ao posto de costura.`],
|
||||||
|
[orderHoodie, workstationByCode.get('review'), 'Mancha de acabamento', 'Acabamento', 2, 'DEMO-LT-2408-02', `${DEMO} — lote separado para avaliação de qualidade.`]
|
||||||
|
];
|
||||||
|
for (const defect of defects) {
|
||||||
|
await client.query(`
|
||||||
|
INSERT INTO production_order_defects (
|
||||||
|
production_order_id, workstation_id, defect_type, origin, quantity, lot_code, notes
|
||||||
|
) SELECT $1, $2, $3, $4, $5, $6, $7
|
||||||
|
WHERE NOT EXISTS (
|
||||||
|
SELECT 1 FROM production_order_defects
|
||||||
|
WHERE production_order_id = $1 AND defect_type = $3 AND lot_code = $6
|
||||||
|
);
|
||||||
|
`, defect);
|
||||||
|
}
|
||||||
|
|
||||||
|
await client.query('COMMIT');
|
||||||
|
console.log('Demonstração de Suprimentos criada/atualizada com sucesso.');
|
||||||
|
} catch (error) {
|
||||||
|
await client.query('ROLLBACK');
|
||||||
|
throw error;
|
||||||
|
} finally {
|
||||||
|
client.release();
|
||||||
|
await pool.end();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
main().catch((error) => {
|
||||||
|
console.error('Não foi possível criar os dados de demonstração:', error.message);
|
||||||
|
process.exitCode = 1;
|
||||||
|
});
|
||||||
@@ -560,8 +560,50 @@ const getOrderById = async (id, client = pool) => {
|
|||||||
)
|
)
|
||||||
ORDER BY component.id
|
ORDER BY component.id
|
||||||
)
|
)
|
||||||
FROM production_order_components component
|
FROM (
|
||||||
WHERE component.production_order_id = po.id
|
SELECT
|
||||||
|
order_component.id,
|
||||||
|
order_component.component_tiny_id,
|
||||||
|
order_component.component_sku,
|
||||||
|
order_component.component_name,
|
||||||
|
order_component.quantity_per_unit,
|
||||||
|
order_component.total_quantity,
|
||||||
|
order_component.unit
|
||||||
|
FROM production_order_components order_component
|
||||||
|
WHERE order_component.production_order_id = po.id
|
||||||
|
|
||||||
|
UNION ALL
|
||||||
|
|
||||||
|
SELECT
|
||||||
|
-composition_component.id AS id,
|
||||||
|
composition_component.component_tiny_id,
|
||||||
|
composition_component.component_sku,
|
||||||
|
composition_component.component_name,
|
||||||
|
composition_component.quantity_per_unit,
|
||||||
|
composition_component.quantity_per_unit * po.quantity AS total_quantity,
|
||||||
|
composition_component.unit
|
||||||
|
FROM product_composition_components composition_component
|
||||||
|
JOIN product_compositions composition
|
||||||
|
ON composition.id = (
|
||||||
|
SELECT matched_composition.id
|
||||||
|
FROM product_compositions matched_composition
|
||||||
|
WHERE matched_composition.source IN ('manual', 'tiny_olist_v3')
|
||||||
|
AND (
|
||||||
|
matched_composition.finished_tiny_product_id = po.product_sku
|
||||||
|
OR matched_composition.finished_tiny_product_id = po.tiny_id
|
||||||
|
OR matched_composition.external_source_id = po.product_sku
|
||||||
|
OR matched_composition.external_source_id = po.tiny_id
|
||||||
|
OR matched_composition.finished_product_sku = UPPER(po.product_sku)
|
||||||
|
)
|
||||||
|
ORDER BY CASE matched_composition.source WHEN 'manual' THEN 0 ELSE 1 END, matched_composition.id
|
||||||
|
LIMIT 1
|
||||||
|
)
|
||||||
|
WHERE NOT EXISTS (
|
||||||
|
SELECT 1
|
||||||
|
FROM production_order_components order_component
|
||||||
|
WHERE order_component.production_order_id = po.id
|
||||||
|
)
|
||||||
|
) component
|
||||||
),
|
),
|
||||||
'[]'::json
|
'[]'::json
|
||||||
) as components,
|
) as components,
|
||||||
@@ -700,8 +742,50 @@ const baseProductionOrderSelect = `
|
|||||||
)
|
)
|
||||||
ORDER BY component.id
|
ORDER BY component.id
|
||||||
)
|
)
|
||||||
FROM production_order_components component
|
FROM (
|
||||||
WHERE component.production_order_id = po.id
|
SELECT
|
||||||
|
order_component.id,
|
||||||
|
order_component.component_tiny_id,
|
||||||
|
order_component.component_sku,
|
||||||
|
order_component.component_name,
|
||||||
|
order_component.quantity_per_unit,
|
||||||
|
order_component.total_quantity,
|
||||||
|
order_component.unit
|
||||||
|
FROM production_order_components order_component
|
||||||
|
WHERE order_component.production_order_id = po.id
|
||||||
|
|
||||||
|
UNION ALL
|
||||||
|
|
||||||
|
SELECT
|
||||||
|
-composition_component.id AS id,
|
||||||
|
composition_component.component_tiny_id,
|
||||||
|
composition_component.component_sku,
|
||||||
|
composition_component.component_name,
|
||||||
|
composition_component.quantity_per_unit,
|
||||||
|
composition_component.quantity_per_unit * po.quantity AS total_quantity,
|
||||||
|
composition_component.unit
|
||||||
|
FROM product_composition_components composition_component
|
||||||
|
JOIN product_compositions composition
|
||||||
|
ON composition.id = (
|
||||||
|
SELECT matched_composition.id
|
||||||
|
FROM product_compositions matched_composition
|
||||||
|
WHERE matched_composition.source IN ('manual', 'tiny_olist_v3')
|
||||||
|
AND (
|
||||||
|
matched_composition.finished_tiny_product_id = po.product_sku
|
||||||
|
OR matched_composition.finished_tiny_product_id = po.tiny_id
|
||||||
|
OR matched_composition.external_source_id = po.product_sku
|
||||||
|
OR matched_composition.external_source_id = po.tiny_id
|
||||||
|
OR matched_composition.finished_product_sku = UPPER(po.product_sku)
|
||||||
|
)
|
||||||
|
ORDER BY CASE matched_composition.source WHEN 'manual' THEN 0 ELSE 1 END, matched_composition.id
|
||||||
|
LIMIT 1
|
||||||
|
)
|
||||||
|
WHERE NOT EXISTS (
|
||||||
|
SELECT 1
|
||||||
|
FROM production_order_components order_component
|
||||||
|
WHERE order_component.production_order_id = po.id
|
||||||
|
)
|
||||||
|
) component
|
||||||
),
|
),
|
||||||
'[]'::json
|
'[]'::json
|
||||||
) as components,
|
) as components,
|
||||||
@@ -800,6 +884,10 @@ const listProductionOrders = async (filters = {}) => {
|
|||||||
)`);
|
)`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (filters.activeOnly === 'true') {
|
||||||
|
where.push(`po.status IN ('open', 'cutting', 'sewing', 'review', 'in_progress')`);
|
||||||
|
}
|
||||||
|
|
||||||
const result = await pool.query(`
|
const result = await pool.query(`
|
||||||
${baseProductionOrderSelect}
|
${baseProductionOrderSelect}
|
||||||
${where.length ? `WHERE ${where.join(' AND ')}` : ''}
|
${where.length ? `WHERE ${where.join(' AND ')}` : ''}
|
||||||
@@ -900,9 +988,9 @@ const updateProductionPlanStatus = async (id, status) => {
|
|||||||
try {
|
try {
|
||||||
await client.query('BEGIN');
|
await client.query('BEGIN');
|
||||||
const planResult = await client.query(`
|
const planResult = await client.query(`
|
||||||
UPDATE production_plans
|
UPDATE production_plans
|
||||||
SET status = $1,
|
SET status = $1::text,
|
||||||
completed_at = CASE WHEN $1 = 'finished' THEN COALESCE(completed_at, CURRENT_TIMESTAMP) ELSE NULL END,
|
completed_at = CASE WHEN $1::text = 'finished' THEN COALESCE(completed_at, CURRENT_TIMESTAMP) ELSE NULL END,
|
||||||
updated_at = CURRENT_TIMESTAMP
|
updated_at = CURRENT_TIMESTAMP
|
||||||
WHERE id = $2
|
WHERE id = $2
|
||||||
RETURNING id;
|
RETURNING id;
|
||||||
@@ -913,9 +1001,9 @@ const updateProductionPlanStatus = async (id, status) => {
|
|||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
await client.query(`
|
await client.query(`
|
||||||
UPDATE production_orders
|
UPDATE production_orders
|
||||||
SET status = $1,
|
SET status = $1::text,
|
||||||
completed_date = CASE WHEN $1 = 'finished' THEN COALESCE(completed_date, CURRENT_DATE) ELSE completed_date END,
|
completed_date = CASE WHEN $1::text = 'finished' THEN COALESCE(completed_date, CURRENT_DATE) ELSE completed_date END,
|
||||||
updated_at = CURRENT_TIMESTAMP
|
updated_at = CURRENT_TIMESTAMP
|
||||||
WHERE production_plan_id = $2
|
WHERE production_plan_id = $2
|
||||||
AND status NOT IN ('finished', 'canceled');
|
AND status NOT IN ('finished', 'canceled');
|
||||||
@@ -1195,7 +1283,9 @@ const isSellableComposition = (order) => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const getTinyProductIdFromCompositionReference = (tinyId) => (
|
const getTinyProductIdFromCompositionReference = (tinyId) => (
|
||||||
tinyId.slice(TINY_OLIST_V3_COMPOSITION_PREFIX.length)
|
tinyId.startsWith(TINY_OLIST_V3_COMPOSITION_PREFIX)
|
||||||
|
? tinyId.slice(TINY_OLIST_V3_COMPOSITION_PREFIX.length)
|
||||||
|
: tinyId
|
||||||
);
|
);
|
||||||
|
|
||||||
const normalizeCompositionComponents = (components) => {
|
const normalizeCompositionComponents = (components) => {
|
||||||
@@ -1291,6 +1381,7 @@ const findProductCompositionsBySource = async (source, productId, client = pool)
|
|||||||
AND (
|
AND (
|
||||||
$2 = ''
|
$2 = ''
|
||||||
OR composition.finished_tiny_product_id = $2
|
OR composition.finished_tiny_product_id = $2
|
||||||
|
OR composition.external_source_id = $2
|
||||||
OR composition.finished_product_sku = UPPER($2)
|
OR composition.finished_product_sku = UPPER($2)
|
||||||
)
|
)
|
||||||
ORDER BY composition.finished_product_sku, composition.finished_product_description;
|
ORDER BY composition.finished_product_sku, composition.finished_product_description;
|
||||||
|
|||||||
@@ -62,8 +62,7 @@ const listUsers = async () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
// This deliberately exposes only the identity needed to assign operational
|
// This deliberately exposes only the identity needed to assign operational
|
||||||
// responsibility. It is available to signed-in Graphs users, unlike the full
|
// responsibility. It is available to every signed-in Graphs user.
|
||||||
// user-management list which remains super-admin only.
|
|
||||||
const listActiveOperationalUsers = async () => {
|
const listActiveOperationalUsers = async () => {
|
||||||
const result = await pool.query(
|
const result = await pool.query(
|
||||||
`SELECT id, name
|
`SELECT id, name
|
||||||
|
|||||||
@@ -208,7 +208,7 @@ const withFakeDatabase = async (run) => {
|
|||||||
if (normalizedSql.includes('FROM product_compositions composition')) {
|
if (normalizedSql.includes('FROM product_compositions composition')) {
|
||||||
const compositions = state.compositions.filter(item => (
|
const compositions = state.compositions.filter(item => (
|
||||||
item.source === params[0]
|
item.source === params[0]
|
||||||
&& (params[1] === '' || item.finished_tiny_product_id === params[1] || item.finished_product_sku === String(params[1]).toUpperCase())
|
&& (params[1] === '' || item.finished_tiny_product_id === params[1] || item.external_source_id === params[1] || item.finished_product_sku === String(params[1]).toUpperCase())
|
||||||
));
|
));
|
||||||
return {
|
return {
|
||||||
rows: compositions.map(composition => ({
|
rows: compositions.map(composition => ({
|
||||||
@@ -342,6 +342,36 @@ test('product composition detail lookup returns stored components', async () =>
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('product composition detail lookup supports an exported composition linked by external product ID', async () => {
|
||||||
|
await withFakeDatabase(async () => {
|
||||||
|
const response = await invokeHandler(tinyCompositionImportHandler, {
|
||||||
|
body: {
|
||||||
|
compositions: [{
|
||||||
|
externalSourceId: '919483307',
|
||||||
|
finishedProductSku: 'BLCS.PTO.G',
|
||||||
|
finishedProductDescription: 'BASE LISA CAMISETA COR PRETO TAMANHO - G',
|
||||||
|
finishedProductUnit: 'UN',
|
||||||
|
finishedTinyProductId: '',
|
||||||
|
components: [{
|
||||||
|
componentSku: 'ETIQ.T.G',
|
||||||
|
componentName: 'ETIQUETA DE TAMANHO G',
|
||||||
|
quantityPerUnit: 1,
|
||||||
|
unit: 'UN'
|
||||||
|
}]
|
||||||
|
}]
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.equal(response.statusCode, 201);
|
||||||
|
|
||||||
|
const detail = await invokeHandler(productCompositionHandler, { params: { productId: '919483307' } });
|
||||||
|
assert.equal(detail.statusCode, 200);
|
||||||
|
assert.equal(detail.body.composition.finishedProductSku, 'BLCS.PTO.G');
|
||||||
|
assert.equal(detail.body.composition.finishedTinyProductId, '919483307');
|
||||||
|
assert.equal(detail.body.composition.components.length, 1);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
test('product composition export endpoint returns every stored composition', async () => {
|
test('product composition export endpoint returns every stored composition', async () => {
|
||||||
await withFakeDatabase(async () => {
|
await withFakeDatabase(async () => {
|
||||||
await invokeHandler(tinySyncHandler, { body: compositionPayload() });
|
await invokeHandler(tinySyncHandler, { body: compositionPayload() });
|
||||||
|
|||||||
175
guia-operacional-suprimentos-graphs.pdf
Normal file
175
guia-operacional-suprimentos-graphs.pdf
Normal file
@@ -0,0 +1,175 @@
|
|||||||
|
%PDF-1.4
|
||||||
|
%“Œ‹ž ReportLab Generated PDF document (opensource)
|
||||||
|
1 0 obj
|
||||||
|
<<
|
||||||
|
/F1 2 0 R /F2 3 0 R /F3 4 0 R
|
||||||
|
>>
|
||||||
|
endobj
|
||||||
|
2 0 obj
|
||||||
|
<<
|
||||||
|
/BaseFont /Helvetica /Encoding /WinAnsiEncoding /Name /F1 /Subtype /Type1 /Type /Font
|
||||||
|
>>
|
||||||
|
endobj
|
||||||
|
3 0 obj
|
||||||
|
<<
|
||||||
|
/BaseFont /Helvetica-Bold /Encoding /WinAnsiEncoding /Name /F2 /Subtype /Type1 /Type /Font
|
||||||
|
>>
|
||||||
|
endobj
|
||||||
|
4 0 obj
|
||||||
|
<<
|
||||||
|
/BaseFont /Symbol /Name /F3 /Subtype /Type1 /Type /Font
|
||||||
|
>>
|
||||||
|
endobj
|
||||||
|
5 0 obj
|
||||||
|
<<
|
||||||
|
/Contents 14 0 R /MediaBox [ 0 0 595.2756 841.8898 ] /Parent 13 0 R /Resources <<
|
||||||
|
/Font 1 0 R /ProcSet [ /PDF /Text /ImageB /ImageC /ImageI ]
|
||||||
|
>> /Rotate 0 /Trans <<
|
||||||
|
|
||||||
|
>>
|
||||||
|
/Type /Page
|
||||||
|
>>
|
||||||
|
endobj
|
||||||
|
6 0 obj
|
||||||
|
<<
|
||||||
|
/Contents 15 0 R /MediaBox [ 0 0 595.2756 841.8898 ] /Parent 13 0 R /Resources <<
|
||||||
|
/Font 1 0 R /ProcSet [ /PDF /Text /ImageB /ImageC /ImageI ]
|
||||||
|
>> /Rotate 0 /Trans <<
|
||||||
|
|
||||||
|
>>
|
||||||
|
/Type /Page
|
||||||
|
>>
|
||||||
|
endobj
|
||||||
|
7 0 obj
|
||||||
|
<<
|
||||||
|
/Contents 16 0 R /MediaBox [ 0 0 595.2756 841.8898 ] /Parent 13 0 R /Resources <<
|
||||||
|
/Font 1 0 R /ProcSet [ /PDF /Text /ImageB /ImageC /ImageI ]
|
||||||
|
>> /Rotate 0 /Trans <<
|
||||||
|
|
||||||
|
>>
|
||||||
|
/Type /Page
|
||||||
|
>>
|
||||||
|
endobj
|
||||||
|
8 0 obj
|
||||||
|
<<
|
||||||
|
/Contents 17 0 R /MediaBox [ 0 0 595.2756 841.8898 ] /Parent 13 0 R /Resources <<
|
||||||
|
/Font 1 0 R /ProcSet [ /PDF /Text /ImageB /ImageC /ImageI ]
|
||||||
|
>> /Rotate 0 /Trans <<
|
||||||
|
|
||||||
|
>>
|
||||||
|
/Type /Page
|
||||||
|
>>
|
||||||
|
endobj
|
||||||
|
9 0 obj
|
||||||
|
<<
|
||||||
|
/Contents 18 0 R /MediaBox [ 0 0 595.2756 841.8898 ] /Parent 13 0 R /Resources <<
|
||||||
|
/Font 1 0 R /ProcSet [ /PDF /Text /ImageB /ImageC /ImageI ]
|
||||||
|
>> /Rotate 0 /Trans <<
|
||||||
|
|
||||||
|
>>
|
||||||
|
/Type /Page
|
||||||
|
>>
|
||||||
|
endobj
|
||||||
|
10 0 obj
|
||||||
|
<<
|
||||||
|
/Contents 19 0 R /MediaBox [ 0 0 595.2756 841.8898 ] /Parent 13 0 R /Resources <<
|
||||||
|
/Font 1 0 R /ProcSet [ /PDF /Text /ImageB /ImageC /ImageI ]
|
||||||
|
>> /Rotate 0 /Trans <<
|
||||||
|
|
||||||
|
>>
|
||||||
|
/Type /Page
|
||||||
|
>>
|
||||||
|
endobj
|
||||||
|
11 0 obj
|
||||||
|
<<
|
||||||
|
/PageMode /UseNone /Pages 13 0 R /Type /Catalog
|
||||||
|
>>
|
||||||
|
endobj
|
||||||
|
12 0 obj
|
||||||
|
<<
|
||||||
|
/Author (\(anonymous\)) /CreationDate (D:20260827153832-03'00') /Creator (\(unspecified\)) /Keywords () /ModDate (D:20260827153832-03'00') /Producer (ReportLab PDF Library - \(opensource\))
|
||||||
|
/Subject (\(unspecified\)) /Title (\(anonymous\)) /Trapped /False
|
||||||
|
>>
|
||||||
|
endobj
|
||||||
|
13 0 obj
|
||||||
|
<<
|
||||||
|
/Count 6 /Kids [ 5 0 R 6 0 R 7 0 R 8 0 R 9 0 R 10 0 R ] /Type /Pages
|
||||||
|
>>
|
||||||
|
endobj
|
||||||
|
14 0 obj
|
||||||
|
<<
|
||||||
|
/Filter [ /ASCII85Decode /FlateDecode ] /Length 1327
|
||||||
|
>>
|
||||||
|
stream
|
||||||
|
Gatm:9iL(3&A@7.oJ&E26B^F@eXkDr?tYB9?ma[=.(0a^fp7ce=ap@Y'ZY2+W1@@r&7Whb<qgsE]?PlcLP>bR%IhC>c7Ttg%Z;&U5m/aAiF4NgT!?+080Xgn1.Z[Y,\I_WS)$dI1/9eD.>KlW8BbW0%A%3c9$1ZGKQ/Hf5@I-Ln.b58f(aY;-%`E*6psV4,RdGg-*(JIBh!`)1uH;D6]Cpk6;@PWc8(^-c-a%PNHn"'TnYBJJkMQl_P.%%Gpd6k*4Q8L_oJogIWb\P*,]XI^7BcQEqAu%qDVM=!A$FNBq1;Ro$.10?i:TE%H%'6QF1Tkj.J,GAQAWk_A9lgiCjioiCj[UD@\Z7Rmi!AN"(;K0pNVa+p_*U7'\6&nGp?W+!5Jc,q\15s(lEP\S5;UFijd*ASbMJ6G!nE/(Ni2>g88qr.8*%fBHOEW)]a4WChB?%=>6bFP_?d`YQ.*50qfKU47teMVlL*#*k[h<Qual4nkr`#Ec-D1"3aeN(5>90dDp[nk_q?"3sC=7##t50pL#hP-(MiTnt_Ln:8UfGSH7M=[))Qr*bW&4$$APaZE7/)2Yhe8:Tk3.@bd:+N(cD($M!m.WYca.?jr4lZD5AEp_XL'ZWF[l(HRlJR`8^J:ZGL%/b`_'ajfk].@5h=sP?ee-CIuO"@">FPa"Nla!ifGkHmi9-fUm1]X)6<a.GEHHb5CI1&M9]Rr_K>,acX-8a!FY6atFl9"N4ltSW;raK0nP5%Hu"Q<Po8.l*@Of-.@!*3,"Ji/.Q>#i8(/p^5V8cW5+-TG(\k72:7[0jduB:%"snH4i9iR+>@\A\e5)])Vms3&=IIP&=Pe]l__p3ho5RVrg-j]Vdp3U4+0U&,u1NE[9)&*cesm81f4,6?D+iA=lRLn<Jmp6n2F`pXSh`*&U$+L."BkDVY`7L[":bt&3Am)3#pj;@;8p`:L)knCY;9W+0iS(CoYg$BoNN+MUl6'=t.l6fRJO^5TZ"$t%H'_Dl^Zh=iAB^p!kIM)qbO%9G&8#5.Hnp"PV$J-3n@q^%^25!cH0"9q;T\H($4!q<AlNa8[Rf\A3lP1nn[5dj/Z5gdrg0.gO7TFC$Zs?0!j"pO1[g(Tt/(`EaLTXQbb\O6dj88AH`8D,G;.882aHk_`f=giRN32ti;P>2)auu'JCGTZ\EtJ+!&/8;_Tmk$2KR,Ean%b-E[2lrF33:X%jT2hD/+_Q^'!G9dA&cRRO_F?tLi7s\T(,mX1mX?9&SA[$aY5Y#=c$gBKLq=5*f$.;^NUV8/2D0*cnZ1@KZ:@AnF3Pm'&hn"I,?5a_NXqnDZ,r.,Kmmr&qelPC'!0q~>endstream
|
||||||
|
endobj
|
||||||
|
15 0 obj
|
||||||
|
<<
|
||||||
|
/Filter [ /ASCII85Decode /FlateDecode ] /Length 2210
|
||||||
|
>>
|
||||||
|
stream
|
||||||
|
Gb!;e?$G$]&q0MXQq)"Sbn/fgV!M%FPBKa2HgK>+>@&EZ;NR%E"-BZ]If8+(-'E:Zf?%nD-T\LX&d2f!B'u4@"N>VBn@,?PIU(9YT*,HlX"B/b8nmrK]j@7X3"u1OkqZEho2s@%:Em>?1-C?HObgt3(DI1<b[lNQ\P+`;gRVbJ0eAO9WkoC3`.F>;#qmu:ku)f\=Sc+.VN%IBY&U>eF_gq?B_@rt5Hka7iH<9S3WB.J`$3pi,&#(?g#$2]V:m>4@1fJ>f<@_A="2S"C@Kh6\p0]Tg"$ieU8q[ArAI4\9>4X-f!M!a'=Q:`iUT+dH:b]t<*A*>gnG=MVT.l;GXYq#G[-VqJc#<%mB"gH4L4Q!-4nI)_L((-<ESEl:%T-5i[>b>RF@cFo?1:D8/%X`d3?UqIU=jDo_ZsIFdSo?'Np[?,0-%5DB><TLgg;IJ;&qmSB`GBMem-YO<Vl7'P6MaQ5$0:2MAPoQ^'SQ7?jTnRaVGS3)/-L0I=L1h$]noLTQ`3"Qk=,BYVjbR;p92+nYU[6KgkK8>KMKp1ct$=<'$.Ll^uj+caHNR!;joanI.FT,3!P2.@2F:Pf&.mVTsoFg_iY4CC<[^sIru"ZC'-o8X3ikeHil%mKq,^ppXO*[(I3(\Zn@i).S^.*3:96u8\SmkWbKH(6Ik&C#[5Xa1j)is!?i)85NFe!*<dSFJ9UbG4/*"%7meCkI\0_$YiWg[X3Nd-N5mi`:Jt(t1],6b:)W71H0PcI@_FD$m&\5-kW,CK3jrT0B)+cFlsCjN`9(`j@t.$5DKY17fEG3-2.?.6"s.FYtGX=!a%1?!Vg*($RecFMhW"V`Sj[.3/B&iGrW_VB4hoi::Ul5ACQcQj&$Ce+Ue3$ZF7L.B?:JmJ67RPf`SI>]:o6gCXK,1"]S5K#KPfgS%e`JX,tqojC$s>BY3ud/g[s-G``T;cB\76T&#UdC"H<V"@XM7emi#F:FEoFA)kuO(.nbe*C5eW\=XGnY%auVHVg@?.\u.#/N+PQ4Oun?bPndI2j-jbX^daR:X5/:o^rk\=IU,(]8d_(nnj^5:7FIN@`F`@!3V_s)9JP\f;e$SI@P>fmS;f`$1\3r,eCCdVM6XcX/6a2\q>;&1\^-d@Nee[;RVumcf=c!^ZX4@#-pK9EcYgs*VY5IsCDM<f@h`LPi$J0:32`;stc;JUA?WY9P2lDPq7116U@/\i.p"WI7k$5jqnZ%X8:VR1KiqcM-3rTtP+(`#dsuiNZ6H#&88V(3Lm[8@8aSkWTE,i+sH9%>86jF]@C\#K>51+/irA9VD=!i=WbTPSsrYlrq$0F6Xa?.TLEVi?3sd.WG`SXJ*IW1$u#R2en#G=HZ8b#kFW+NE0J^GP:W^i<qsHCLpU.<Vrn@:rIh+YbTneTbu,bcX?nf,2@B!6^Ag\*j:0ON3u$^dn]q,\3\dA,6amK&$/'B8Kl0JU1dlC][ZtVU]a_cW^+)`1eF)DI6^\b&J%K+.RjV(8TDjM*qb96>uHi(/Laq"E=cGsC-Goqj;dRHpFIUZf='!p3%f&r$RXrgZ.:H/IJPa&N)F\(#MYmV@5dK:1mF=cs*bV0E$W4Bd:_(b<Mg3?-_=hV!\oH6n-B6mor'[\YV1*(.!'Tq-,+qdguqWlqgf#9],54qhO;fAD2`CLD:YT>K;B+76@%Gbb&EBdL6%N.UBgX7fE)'Fc?O5&-CF#!FpAW=[O6S^l5K7GPhU9Zp3cLEhoJQ]M2f6I$=&6<H7UdZ2U?iL,"*,%MU]oQA:PT$6B63p5@k/7mol"F7MIiu2%.uZ/hAb"q<Trs--HVR4b$m3T7aA$/@dqO]r+M^O>=tnB8HpT=0s*Sd&0!PcLpk;k1.b"N=&0N%^ffF/d'q7^@]GrTTH^c9jsH.X$'MeWk?k)f-YS^qZAL`#'\kjO*^AUC[clpMKcZ,"-n>Y@8NEcoS!.18BdndRSTg13ZQZDm]2']0b3'BWR89/TlFe;=a0Vcl$mlVH07CXWE6),Z_,#8d_pPSKUNc\h(#SckIaRg^8p#/dJ9eWd[t<+pUPAopm7/ECQ8bFd=SZug)k(_5qV"^X7'6H2f0C3>!BG`1@WbJZ=O">i1n'c(fVuT&+33ld.&,jM%CnOnUQC_j1nNC0p[2B))uB:ZY=HIF:$i9-@(efD`jZ46?j*D(DP.eVnF9Al,R-Fke?sji12Ea@>T7>\N3pZH8lG%8%V*#rrmnQo`P~>endstream
|
||||||
|
endobj
|
||||||
|
16 0 obj
|
||||||
|
<<
|
||||||
|
/Filter [ /ASCII85Decode /FlateDecode ] /Length 1991
|
||||||
|
>>
|
||||||
|
stream
|
||||||
|
Gb!SmbAu>q']&X:mS_0Z$t#a)A<hK;S@3TM[4?):$<#e*#oXpdf3dEV61LXN[c=0+S;p()"FpKt\\L3AE'QTnJbtr!7,Q8ReIMZa+jJUQ`*Q./?g7I`dFD*uCDkI2=Z#XNRO""3:89P86a[a*_(5o6$\E%)Nq""4@WZ*<8*DbmL)JH&`t0Bt;][-AG_"1HB#;tgdnfY"<g#*#d\pCdQ+"%:V7NU);2[jW:[jD:il):o?deiu:[K0/rUct\(]*YIUe*Q#RpHqjP8SZ+GijeB`KP!U"['BuO+dmo;_O#l32%#`0Tt!?d3ZOG!0.cNOV6NFA4h]f)e\q>%m&u@2dJDi?4oY73(<Lf73d<;69=Z2C3Bl2f,6X;LQ.JQP`s.ATV"s*7+m:PbcG>`RJ_-$=*;RV/Z.F]`HW$8hP=Yo@tG!sr"oj]Omc!Xbj_G(h9aq#H\c959P5s)[h1?`?QCc.ZFhTR-K,*$p,U_!N$nULL4O%1?<?-o"3(GSjs,1P1@DMI\iA([_q7$e).kX!]\.k("Ci/;A3qs7VW1D+K^K6ZaJZ'WA;H_kXI?%V["s6+OM$ln*u0f7.TV/Hc<ld;6rhuOoHCG9<am@,\.o?>fK-Id?1U`kP"S3'kO?G=Rqe*<aO*V_V8@uYmXpK18,jXs;iRd,<gWunheklF8n*HQb"U8FX04DF;AtZA7Nqp;\P]fP1=?V]=qO#!C+ECJ,^0\l@A_2I7PbU^<P"+S8Cu@c-ACPgWO2f.%r`(%>(L#N)>MOcE>)E!WQj:_g^ChK<Xc7W6m43(1eh1cDQO3iQje?Q[PBoIi>9-[O$&`B#Wi-80hM.]``&]-599jE?\8fWpKP'TaQUK@qnjn5YLq`J3bWW>Z]E)$F-e09`T3]GN2P-Ug,sk5:VO-K^T0fc["4kg^PV<0piL).-\44g6eR+1Rgt_>rI2Db!umhY!boDY*id!DgY`AnLSrPn;ol<-Yk0u`dkk)K.Zb@8SO9GXO_2"$>P(i5m9D^8&@0+7UlF*LS=h$#jH&iU<dGnTK`79NcWYR:I]p7id3VR5n;P'+T[KQ>6qLqZM*+T#Z9DTm%4.AtCe_JbmYV@b70@18%fQ_&`B>''ZI*g)&bKW]F5bNm`E(\bD%Hps;f0`&)M^b]`8cRN1Pp?Yl&g*gEl0r!$%`mB2mk+pRW?U.K\97]aL6QCglu"4nk#)M$"Ip`p!WI`XL0Kf.T`!fj:5(%Xo:Y.E-lu2kH<alb[Jlj%Wgi;gh@7<dd,:h_2?57_0Z+C0_rlRn@RamZ*O[lXdPO<&cN=F1GANhrP@&rR42iWQ*j1J#V/[X.9H6b!1qO_7$cDlJDsYY.&?OSl<q&X1I!928lM^J\nO:EKp%WbK2e10dH69#UiSbWo]SV^IA[hY#ckW^i$d%7$@9$Z)8*n,J\A&ub:uEKg528ldu-G_M7L9)!d8?143XSP<faq,bm?3LiQSn>_l-`/L;ZP#JV*[L'jnaXXiOP3MZD:mnSdp0&NFL7MDlQ.#%DHF,gqRQjCn5!6N9JXIF]"j5B@m-Z'B,cc0B8'`H(DWJA=LB$d.D7K(SW^S[#[FdL'3pHe`p.joZB+eqdecV]Up_Em?2%mA3Q,$U/LNK/@sC:)*N;nf<t7E#K1]nbG8oo\!J:4k>:<";CT\RF&k':f$7@ms!q--_cEUc4ps`iY9p"Rc0.Cl[egO\&t$H0;ic&W@b/>l."-1b]/+[ZXi+Lh=\@,<phTMSr'kF)C:pmSt3G;<tCYr1m<;I`=6LJH!c1cG9s)$:kkFo2;t=S)fUQP^;#p7<ZFtT=H=EZI#HT=,1q&qNWK;sL<UULm7X%3Z+Sm=,J1Tdh6cou,-/X$EX]Cs>uuSN"\D.>0@n^bW]s[&K<q+T@U0TBm`b>s0>,$-PJ@tqq;MU&lUS!T:,cd4n`Z3P&CWUR4.QD!3"@s]UH\t;p#PghWlt2Bs*+8u#oM#N(i#!Q-4.n2Dl@a&5<#SR'^\>CNrB&UVh0B~>endstream
|
||||||
|
endobj
|
||||||
|
17 0 obj
|
||||||
|
<<
|
||||||
|
/Filter [ /ASCII85Decode /FlateDecode ] /Length 2404
|
||||||
|
>>
|
||||||
|
stream
|
||||||
|
Gb!SmbAuXO'&EF8HE!]N%c8J:9:XVIQ.1%@R[lCA"Nd#T#n8",aaALHRL`9$*k&7i-:u=F6iocO%iXUf=onGD"oLP7M8#J-Ri__>6CKMTLqT1Q^X*M?X%ue_gG95Nd#TOt9iBjVZREY2B_I$38CF$@,gQ@M]QfUY;*\`=<T12:V(hM83/pkJ`eBP^p_lN2(V4]hIC:%X[7R@Lg)3'foTP>L>%3;l)^9;iG\I_C@><q/S47u,'hpp@oP;&3G?SD=*F:o?L4QgGUU6S4gA0q;GoDb\"OSJgn/:@CVL*\K5m*mOc0,DEet6\KP4%C)5,_@[CMg[T[BoYHn*^bFS]6d.pNZEl3-^&uoeMCLhoepAn%-&I5jnS3#;R4U+.hN_T/u&'q1[YIg^%5NAna6@9)EM*nJP.#,_mZah<D?BL-9Sn\bh3>fQsqTPdG@.[;ipM5Fsi!QqNlpiIRs2M3mgM68.Wej%Xbf8!f!#Dok;[&>'3VC7Z?k%45RQ6$#Uu'Q[!,q^>%;RcuOAH7eNO\Aqk4>;Q$N-k;3a=NaT\GU3N2:%"c5Ok-:3D/HCG4:"U;$iB^lKpdu`DnAOWdsG_F]FtfcqTkae?Op6u"^DlHRA[i/s+RWVmREP3C+IL/i'(8RFcTS?#Vp_..P[eQf_OZ7fp/_iK.>AUbWES/2Y"&\J=(d@@eJCBL68FtMD<aSATna'8r<t:CF[;,)MOHG4j'VR7ui@sB`PW,j^B.>:N$npD-cFji5UOqLR5^VfNW2P-<7V)AnS_tnD6[fEe5'G6'>PG,A'PL9q^o?MS7m%midh]ap>UK9A^,C32-qopg<rK]^d%X8(c?7Ou.<R\T<>iI_(,t^CAhmh""0\US.A:ljoT`S_O-=f0Q<eKC(I'S9oN<nH2CY/JB2ni5Yr"HnK7`4`)ubKtNkLW@Y93&T9nc%h@(_ou_B=Gjf)G!FN;j'hA-"X/"Y&1O1bGGGb*n=3eV]_TI..Le<+1%(]Yp?UaK5ShHUR$I(OSS-Y?i.%G\`EoG75%d!_gDD&k[;7#R'i2BV_I"cm\NiGj2k?G8,Q23$O;5lmlj]TcpRU^Oq)aR*4`kl&o>pCn]ZngiR7EGUfhU2tHY7*r^qH]B]4p*+&OZA(sH6PR=W;:rY^tmm#/U8lua9O3,d&%M3i)U"7XC[]/Z=!nC\l4rI2Tdd63,mffHbS[$L9qQ=,XCt7!;^TiUU_&BEWpNqG,&5T2Te1_!OA+bAUs^Vjbi`R,jN$Q]pF_W_lHr'[+,]>:s`^Ik;>0%Qsn\,%%MYO`^&$6;PYbl=rF7&]h\/aT@J((d.%q9(iT,Ao^Ik'l.DZp&KU1a*7_;Rfb2bBjpL\\F8c&k,5B#A1$pp]+NK[f.h-Onk6hJ<`s_k]-`q8B?#StBN?%L<(16.\e`o3;[9*`*qV^0uQann$Na$CkR9Hu1X/^;Z"J!?G16lf^0Qp7d%,l6^2`#EX'!B'0O[L-Yd;puYZO4cD>*i.5`W)Yf(K?\^\Pt1cB>l8u"g;4CcIf@c66=oV0Vl&mB'.T`Y*Q.3cJ\sL=ZO5?8krFqGP'R[4-*e4dC8FF'^E#7BtG/Hg9!g*U4r&P77V/^jh@;*ln;A/>?2$Ol8_dn!1U6^X#;B[Q,[gCf3eh563;%ilt)15L/u2+Eu7Mr'nLl0aXQeib]^3X3(X>(pM;b][V)MbI0]/9bfJZdIZ)6q!O52Qcje8o7MgG=D<h>fcV2Ear<uTO:+T2VM4h^J"W+'2`:W'%BB^u:JcSO)./-&ccJUsH0cFgt$2B<)0b62o55P!3ku":(,`b&P!q7!^p;-$&V;O>7d-nU\T]`M!g=Yc$?alLpI21r`Y$TWISTH.sQV1MiY4pC[(-bkbi\DRc1<HtbFI^7!%f#>.Hf4*5cNX+[Ru<`KEV6DBm3JH8Z)23qW,*]:<`@fLXR(:5Eesp-URNK$e$$mW>s1pbbu:S&6'jT:@.mhM4:g+6D71?6=G-M!@7U<K.c&Z)=ZuOf@qS8Kg]=iPE2-ZT&q<h[rG`1G1kd,[DW3$m;0[[q&pl9*[laCL#oZlRYnMJsp<kRr4OT"*\-nGPW<Ve"2h^fkRVH&g(!@M[_StAcW^PqY]I+3$)<@nMI:!!'SSI_@:bQQ&M.1gn4qNER'pn`t`Ep$-FE6hbJSqC.!niKm>K<XfTWES`XNR%#8T+D=N>2pQHj)cP9u##:!@4!BHD,Rh6C2d#*@DcC*/neu0;0>,4d+1Z$l1,ASh7\(2b28%DTOj6Aj>0+G3"9b8!\Zp#H`C029$O'+#NP2f>p08%3#&RBuD.=E]^K$Mlg&Ne'uKMS*[gMRhPHBhqUlZ6uEQ!ERYI:+Rb-(KRX-b7&ck!:"Qa#lJM:6:FpZcka-Wn1C0G#W<=OpBpe=W\oe^m"Fj:]'5M^_-i-cHV>~>endstream
|
||||||
|
endobj
|
||||||
|
18 0 obj
|
||||||
|
<<
|
||||||
|
/Filter [ /ASCII85Decode /FlateDecode ] /Length 2101
|
||||||
|
>>
|
||||||
|
stream
|
||||||
|
Gb!kua`?/p&A@B[qP^pD0M-5cS.nD3.S3d$jdRk6M&ATJ&emS\Nk[d6i&^F"YnC,P8SOS9!HpDPpNiSF*rbs&IpN0RF:Rbgn\GaoHjtPG69+uINm@B:pAA?2)R.!bP5l4K?qJKcI*N2-/G>5`"I:BCjFc.PebDDP=TfoB=;o4D]QKQ1_YVNKk60eO;5`T!#%))_>;>TS0]2IXPf%m@-UEEX5o/I)0?'P!'/C8#U"B?#`gp@4N0<q]jMhHR,a@Rl)KEU2"TQ!/>LKtVVNX6[q)+\W+Y-=XB?QJ!EJEt5e5gI<)1A>eF`nbb2WXF,<O-L"X=nf4=d<d$HY[rnTG"@`8sCI0<o\nV"[*tG*0h-p1o75U*$9dL];?>dX3pkDM]gp_Rnf>;^fo9_cD<97mtO&YeP6U1k(:Y/iht25Mqu`f79Q9lW,XKqY8T\6X_.('^39B"+kLjPF=Ck1*07in7t]1XhMC=:Y/Z`h]em<'DSB<rIe@E/.'c/Ac>ddTNjJKis/u)ubd5*GJakQ+&;A=93M$DE^#*<+r&KD5Rc3G49DI]T^#b'ZFk^-G>g1kIlLYQU_Mi%gnU-/!R3/?=#S[N6"oh,d385X=]6l_l_X..5W!>jF2[IT2r>eJiVtZ>9;ftS\Ju&ARF>n`oJPjsg5WfG6=$;.9FG4He;JCiTZ>_=U5$t*'7ORNrRo7c,g5ue@IUK:X<^[.G:e0FR(q(G_O.^D2KsW%EaN@r':q"cZ\l>LU"E'F'j_&n.=Sn`;m#HMq6$N9%BO8p<NTOrl]kEG3DunO.4iq@?Oe:&N#2Gd/TrsLI(0Xl#J*U?qbZds#MkSg_./arog^g>nppI^:D)S+h?c\SN]'MUr\3P<s\mkpdn5rnS*`TS(.F<>%Gk1f-D#o--@C>K2L#]&G0l\WV8hmX8^;*p:/WLRPLE+#($>6^M-^Rm?+(upFC4qNX[P^sA&D@;W]DE5dqm)_WV\])2!b!"PGd27>+2o,%.*T=(=GVE]cRpSQ0a'[(R@u]g)oRBHnVPNJic%d$8]9X5jTH<JEBaAmV,'9n<Yf4uTo[eC:--(BcCZBe94dTH)l(bkck5%f@j*.P^dGd`BGfW8fd"c4,F4=.9p,EmP?H5&UC'aCWsD[De0cgu!ai:!dAR,;NcG^OUO.P8(1E*q`TJKhh*cZ[nC;U"J2EMSnsnT(!1"+RZI)]I;BPC:C%&CLV8P*:B!bn-_kLgd@WY0)H+]LjM4aFl>>AuSq9"Q=1Db+UM8H@%=2s9;Aa]C\2hsegBX4*tIa#Hka.qQ!G]_Ah;M3rW"j1JqTar'_2OEtVP%?.aiu`dN7)>gd\^Il.hgVVbk[Q[%#>*9'kBt_bA/.rEab1ohd'q?(h'ecYGI#LK!'Cta?NXBBCnedAY4+,WiQL6gIfcbml?]R+,q/%0SM:&'ahkBXcN3/r>8C[3f3f5^M_abDWFf-EAZR_t/p:pO0Q?\N#k(e.G.-DQOBPFJ\h#;1.3tR`0NXB!7o;8niDfbVDQ*!PVK&M?2siTN-<BN0?')uUI<i1-T7hB@>XJZ?k2E^RCZ=8.@[a><QIpp](Ob>aN1D%IT"amj#nG62RE.g2+4a[Ko!T5O=fj$>@C=m6-qjr1,B>>)qV([DF""hU2gpcT?F!RD^(:cC:V`Y[C1Bj<@n5'7WEX)/%>dh7?Rr(K3Dh3>OrLY-9_H3o.ZN43/@^HVcIq!=`L(de:OdKOVi$='6O.c<NNcGk+IAHMR\:fkrS:[\p,o,[l5NO%0)M9j<e9EsgnVXg^;M']quifW*]d>$>W$"CZONdE6bq`mg,dom&B)S1(pgGs3^07UY+X'u`$KPlB)+oUD\)>ZM+*`u&rdf4cDdP2>Z0'QIH9\*+KgNIjdDdf]n`'lECpC<(r%ak,u6e)UX(^aaBeOJHoC']5k^R3f(NnY:s2YkFK1HW;#i<4[%6OSe[DLulWcPY/K,En-`j6./9'f_XMD4s*#%_A_YEl*q%NW]4_OoWE>f(lQB9t@EVp4Y0?P'gGAf2k3dq>5`@6Oh,UI&g@1^!$BD\=&T&,.7*F:<Kglm)Cehq'\2W+TP^/0O5Ak@pah,8A^Y&T(s$#Cu?YkL(FItF3~>endstream
|
||||||
|
endobj
|
||||||
|
19 0 obj
|
||||||
|
<<
|
||||||
|
/Filter [ /ASCII85Decode /FlateDecode ] /Length 2061
|
||||||
|
>>
|
||||||
|
stream
|
||||||
|
Gatm<9lo&I&A@sBm*X';/-12Ah)%^PG/fB@jHL$?64,LoToc#6k1B>ZpaJ^2UlHZ]'F>>_f\Q;q_1Gp>K`++eM1/[UYAXOM8W+;ihskFN2n`O&ICg-jbf"^9+HhO)krE2="aCQqNfYFj'64.=E@!Vo'`4JpH@8fP[88n-8V&gafVb&kUfu74"W$21-4P-(CKLLC2("r^kY8T3#mE=UG``DRQ&1QG\n/gi,EETd<g=pu[i8ZH3rXgK?YT%^fDgA^CZja6ad>bJ7k_@9O#Gg^c.bspX%_-0RZbCmWlQu]Bu6e$RW#<WimMY^R%t*3Y\67M^rm*k'S2Hi?=J1-l2pBuf$WCJ9'9F-^pR0Hj4\nSI*[nSi,bYK*\$?kYQK[K)J$-fMle#S)13lIUM<5T2YC]9oH*U%;5VgT?Rd"=Hen/iL;U=5.iMd[X`g:hK=Cb+I_T2L>Z4rs:)r78?PD9Ph7pXDJq3igC98c=%@nm[;0QBoK,o's,OXDuG<U(+kKm,B7;'gPpPmjQT(-62]o0Lp3/JH^Jg(eeYW4ZdN2&O@SX7l7/A.?+._C)blZK<J1)jN"1cL]B)q:LWWlrB$A4U?c_Sj?'XYSEHLj`WedZL[S8b0R9E@:dL+H/[KS0[_O2oHG;qT/_bBd)Pa/uMACq*C)>&U$B/^6JU&6P%0^'(<HV0=\l4O;s%i%0kjB#fb9j$!<><%A(i"mA@s'Kg8.6YU`(ZCUNtY,/bai(eD;ujZ7]IK]Z?dQ"fg2"k>NBg-2@&"#1AW1QomN0LBW^G&'ohV$6b1=5XIW1AFM,,`)#t(W\)NeI@clhS*$W"`m*r-Lf`r>@sgh^HOOc/QB:!CSDfGY>Ogh@G.OBa;.":g?L_#"dufsS3p73js!@#Olsm+K.8Y+J6o9n^,'D4^lL`9c^'2bVV:04X(]O9*!A?/oJGB9+T.[\]eOXG_A&26Q]COG#%cuB``0ike`cu)VGNQWW_*tj3>kGrIdtXUr!A[d5e=365@5OO]BH=na=E9A",p/bJO<:HW%c0"i-=Gln&/'ll#XKnmW)<3V,JBnAr.:lep=5##dKl:cQ]Rl4V^obnV&RS6qHdnh7#V0AIp"=(YP%X,EIVcafDd,<W7D9PL^KFjAE`M0JH",`PIKm_E^&e;Tg!K8)&(JFeK$+gGd1JhS$tQO'%XQJr+mT"h-$Xph_RGZb6+ZlOYDn/7EX/.#5L)PtauV*^5Pkk%L2&A!]9B+AZ5n=H=in*_a#d/^RUh_;QloH$omZ>Y&;$&p?2gBZ&5M+"7,:G#0,!J`NJMk4/KeFrm0+V!b+EO+A=\`JB,>8i_!/^S5^D'iJg,^e/p(e\US,1+^2t^41>/6K?;647At(/Ttp;AJIh*Q)lK7UJ'\Ge`JtGnmdM>6\G*4YZ!^HUjKZXHQmPKH7D8E;eM;S/ga$Z`0Pg*%#ZO%&+]*IjL]([o0N7]*-MLAF$=C^%)[Y^m&b*m]0QISCMT!e*cEC7O(r/HIDS.?NCWC#6$ielmHGt&la-:qI>4dEi5qWT@h/m6Uo1o>:jh8"GbH2M#Q.+b(cPQ`%9jA_oO,YR0RuU3blC.M`Aop?"I(PCZ,aPO#B@Ufa-.ns+\OCkl@606L[K:pMObZINUm0d[&sf$?6oUjFUjB2b4Gia1#)9(@GR[@5rVm\JEA]jboYp?=BDWq]&^<>F"HT-T9%@3!Aj$ULa<nC7#gE36-dR#ehANBPNc\GgZrs)*q=OSY%\==h/0&qNZC3JWmS9PQG]DcO5Fc$O'(u8"S**'M1^@$ot\$o2?SZH57@1kR>4X(:,n+P:F%/E%Si+BR%rj2Xu)\$ARA-)/Z(IPaT[VY3.@rtN!Ic10J;?pQ2DO0*S/]JlS.!;Tr_!ShsL]u(,\IS@!(TQqEoH6L+6ROpHsS\O1+l>F;"A-DIkoX/m#J+ft+Y%],[7Q$P+js`Q&QX[T(_UCPS=t6F5-;G+o545*+E`bn3R<Y\17E/:F]t"KEa@ARJ[+qBOe[HEEm-Zmsd"1](rR_+lp@cu3F9nD)(QbeojJ!%+F:LARk)1OH3k!5lL0dFE`][2Y)~>endstream
|
||||||
|
endobj
|
||||||
|
xref
|
||||||
|
0 20
|
||||||
|
0000000000 65535 f
|
||||||
|
0000000061 00000 n
|
||||||
|
0000000112 00000 n
|
||||||
|
0000000219 00000 n
|
||||||
|
0000000331 00000 n
|
||||||
|
0000000408 00000 n
|
||||||
|
0000000613 00000 n
|
||||||
|
0000000818 00000 n
|
||||||
|
0000001023 00000 n
|
||||||
|
0000001228 00000 n
|
||||||
|
0000001433 00000 n
|
||||||
|
0000001639 00000 n
|
||||||
|
0000001709 00000 n
|
||||||
|
0000001990 00000 n
|
||||||
|
0000002081 00000 n
|
||||||
|
0000003500 00000 n
|
||||||
|
0000005802 00000 n
|
||||||
|
0000007885 00000 n
|
||||||
|
0000010381 00000 n
|
||||||
|
0000012574 00000 n
|
||||||
|
trailer
|
||||||
|
<<
|
||||||
|
/ID
|
||||||
|
[<3d879f6902a83f70d587d7d3563c8f7d><3d879f6902a83f70d587d7d3563c8f7d>]
|
||||||
|
% ReportLab generated PDF document -- digest (opensource)
|
||||||
|
|
||||||
|
/Info 12 0 R
|
||||||
|
/Root 11 0 R
|
||||||
|
/Size 20
|
||||||
|
>>
|
||||||
|
startxref
|
||||||
|
14727
|
||||||
|
%%EOF
|
||||||
13
src/App.tsx
13
src/App.tsx
@@ -2,7 +2,7 @@ import React, { Suspense } from 'react';
|
|||||||
import { Routes, Route, Navigate, useLocation } from 'react-router-dom';
|
import { Routes, Route, Navigate, useLocation } from 'react-router-dom';
|
||||||
import { Loader2 } from 'lucide-react';
|
import { Loader2 } from 'lucide-react';
|
||||||
import Layout from './components/Layout';
|
import Layout from './components/Layout';
|
||||||
import { isAuthenticated, isSuperAdmin } from './dataService';
|
import { isAuthenticated } from './dataService';
|
||||||
|
|
||||||
const Dashboard = React.lazy(() => import('./pages/Dashboard'));
|
const Dashboard = React.lazy(() => import('./pages/Dashboard'));
|
||||||
const Products = React.lazy(() => import('./pages/Products'));
|
const Products = React.lazy(() => import('./pages/Products'));
|
||||||
@@ -34,13 +34,6 @@ function PrivateRoute({ children }: { children: React.ReactNode }) {
|
|||||||
return children;
|
return children;
|
||||||
}
|
}
|
||||||
|
|
||||||
function SuperAdminRoute({ children }: { children: React.ReactNode }) {
|
|
||||||
if (!isSuperAdmin()) {
|
|
||||||
return <Navigate to="/graph" replace />;
|
|
||||||
}
|
|
||||||
return children;
|
|
||||||
}
|
|
||||||
|
|
||||||
const RouteFallback = () => (
|
const RouteFallback = () => (
|
||||||
<div className="flex min-h-screen items-center justify-center bg-dark-bg text-brand-primary">
|
<div className="flex min-h-screen items-center justify-center bg-dark-bg text-brand-primary">
|
||||||
<Loader2 className="h-8 w-8 animate-spin" />
|
<Loader2 className="h-8 w-8 animate-spin" />
|
||||||
@@ -75,8 +68,8 @@ function App() {
|
|||||||
<Route path="rfm" element={<Rfm />} />
|
<Route path="rfm" element={<Rfm />} />
|
||||||
<Route path="campaigns" element={<Campaigns />} />
|
<Route path="campaigns" element={<Campaigns />} />
|
||||||
<Route path="registrations" element={<Registrations />} />
|
<Route path="registrations" element={<Registrations />} />
|
||||||
<Route path="admin/users" element={<SuperAdminRoute><AdminUsers /></SuperAdminRoute>} />
|
<Route path="admin/users" element={<AdminUsers />} />
|
||||||
<Route path="admin/olist" element={<SuperAdminRoute><OlistSync /></SuperAdminRoute>} />
|
<Route path="admin/olist" element={<OlistSync />} />
|
||||||
</Route>
|
</Route>
|
||||||
</Routes>
|
</Routes>
|
||||||
</Suspense>
|
</Suspense>
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
import { useState, useEffect } from 'react';
|
import { useState, useEffect } from 'react';
|
||||||
import { Outlet, Link, useLocation } from 'react-router-dom';
|
import { Outlet, Link, useLocation } from 'react-router-dom';
|
||||||
import { LayoutDashboard, Users, BarChart3, ChevronLeft, ChevronRight, Package, LogOut, Megaphone, Grid3X3, Shield, ShieldCheck, Moon, Sun, Tags, Boxes, ClipboardList, Menu, X, BriefcaseBusiness, Radio } from 'lucide-react';
|
import { LayoutDashboard, Users, BarChart3, ChevronLeft, ChevronRight, Package, LogOut, Megaphone, Grid3X3, Shield, Moon, Sun, Tags, Boxes, ClipboardList, Menu, X, BriefcaseBusiness, Radio } from 'lucide-react';
|
||||||
import type { DateRange, OrderData } from '../types';
|
import type { DateRange, OrderData } from '../types';
|
||||||
import { isSuperAdmin, logout } from '../dataService';
|
import { logout } from '../dataService';
|
||||||
import { rangeForLastDays } from '../dateRanges';
|
import { rangeForLastDays } from '../dateRanges';
|
||||||
|
|
||||||
const emptyOrdersData: OrderData[] = [];
|
const emptyOrdersData: OrderData[] = [];
|
||||||
@@ -61,12 +61,10 @@ const Layout = () => {
|
|||||||
};
|
};
|
||||||
const showSidebarLabels = !isSidebarCollapsed || isMobileNavOpen;
|
const showSidebarLabels = !isSidebarCollapsed || isMobileNavOpen;
|
||||||
|
|
||||||
const adminNavigation = isSuperAdmin()
|
const adminNavigation = [
|
||||||
? [
|
{ name: 'Olist', href: '/admin/olist', icon: Radio },
|
||||||
{ name: 'Olist', href: '/admin/olist', icon: Radio },
|
{ name: 'Usuários', href: '/admin/users', icon: Shield }
|
||||||
{ name: 'Usuários', href: '/admin/users', icon: Shield }
|
];
|
||||||
]
|
|
||||||
: [];
|
|
||||||
const navigationSections = [
|
const navigationSections = [
|
||||||
{
|
{
|
||||||
label: 'Painel',
|
label: 'Painel',
|
||||||
@@ -81,9 +79,6 @@ const Layout = () => {
|
|||||||
{ name: 'Dados Pendentes', href: '/planning-issues', icon: ClipboardList },
|
{ name: 'Dados Pendentes', href: '/planning-issues', icon: ClipboardList },
|
||||||
{ name: 'Cadastros', href: '/registrations', icon: Tags },
|
{ name: 'Cadastros', href: '/registrations', icon: Tags },
|
||||||
{ name: 'Suprimentos', href: '/supplies', icon: Boxes },
|
{ name: 'Suprimentos', href: '/supplies', icon: Boxes },
|
||||||
{ name: 'Planos de Produção', href: '/production-plans', icon: ClipboardList },
|
|
||||||
{ name: 'Ordens de Produção', href: '/production-orders', icon: Package },
|
|
||||||
{ name: 'Qualidade', href: '/production-quality', icon: ShieldCheck },
|
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -95,7 +90,7 @@ const Layout = () => {
|
|||||||
{ name: 'Campanhas', href: '/campaigns', icon: Megaphone },
|
{ name: 'Campanhas', href: '/campaigns', icon: Megaphone },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
...(adminNavigation.length ? [{ label: 'Super admin', items: adminNavigation }] : []),
|
{ label: 'Administração', items: adminNavigation },
|
||||||
];
|
];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -128,7 +128,7 @@ export const fetchStock = async (): Promise<StockData[]> => {
|
|||||||
'Authorization': `Bearer ${token}`
|
'Authorization': `Bearer ${token}`
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
if (response.status === 401 || response.status === 403) {
|
if (response.status === 401) {
|
||||||
logout();
|
logout();
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
@@ -171,7 +171,7 @@ export const fetchData = async (): Promise<OrderData[]> => {
|
|||||||
'Authorization': `Bearer ${token}`
|
'Authorization': `Bearer ${token}`
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
if (response.status === 401 || response.status === 403) {
|
if (response.status === 401) {
|
||||||
logout();
|
logout();
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
@@ -193,7 +193,10 @@ const authFetch = async (path: string, options: RequestInit = {}): Promise<Respo
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
if (response.status === 401 || response.status === 403) {
|
// A 403 means the signed-in user lacks permission for this specific
|
||||||
|
// resource. Keep their session intact so protected optional data cannot
|
||||||
|
// force a logout from an otherwise accessible page.
|
||||||
|
if (response.status === 401) {
|
||||||
logout();
|
logout();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -219,12 +222,13 @@ export const downloadDatabaseDiagnostic = async (): Promise<void> => {
|
|||||||
|
|
||||||
export const fetchProductionOrders = async (
|
export const fetchProductionOrders = async (
|
||||||
dateRange: DateRange,
|
dateRange: DateRange,
|
||||||
filters?: { search?: string },
|
filters?: { search?: string; activeOnly?: boolean },
|
||||||
options?: CacheOptions
|
options?: CacheOptions
|
||||||
): Promise<ProductionOrderSummary> => {
|
): Promise<ProductionOrderSummary> => {
|
||||||
const params = buildDateRangeParams(dateRange);
|
const params = buildDateRangeParams(dateRange);
|
||||||
const search = filters?.search?.trim();
|
const search = filters?.search?.trim();
|
||||||
if (search) params.set('search', search);
|
if (search) params.set('search', search);
|
||||||
|
if (filters?.activeOnly) params.set('activeOnly', 'true');
|
||||||
|
|
||||||
const path = `/production-orders?${params.toString()}`;
|
const path = `/production-orders?${params.toString()}`;
|
||||||
return getCachedAnalytics(path, async () => {
|
return getCachedAnalytics(path, async () => {
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useEffect, useMemo, useState } from 'react';
|
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||||
import { Link, useOutletContext, useSearchParams } from 'react-router-dom';
|
import { Link, useOutletContext, useSearchParams } from 'react-router-dom';
|
||||||
import { AlertTriangle, ArrowLeft, ArrowRight, Check, ChevronDown, ClipboardList, Download, Eye, Layers3, Pencil, RotateCcw, Ruler, Save as SaveIcon, Scissors, Search, Settings2, UserRound, X } from 'lucide-react';
|
import { AlertTriangle, ArrowLeft, ArrowRight, Check, ChevronDown, ClipboardList, Download, Eye, Layers3, Pencil, RotateCcw, Ruler, Save as SaveIcon, Scissors, Search, Settings2, UserRound, X } from 'lucide-react';
|
||||||
import DateRangePicker from '../components/DateRangePicker';
|
import DateRangePicker from '../components/DateRangePicker';
|
||||||
@@ -19,6 +19,7 @@ type CorrectionIssueFilter = CutProductIssue | 'all';
|
|||||||
type SettingsSection = 'rules' | 'corrections';
|
type SettingsSection = 'rules' | 'corrections';
|
||||||
type CuttingView = 'plan' | 'families' | 'issues';
|
type CuttingView = 'plan' | 'families' | 'issues';
|
||||||
type PreviewStep = 'selection' | 'confirmation';
|
type PreviewStep = 'selection' | 'confirmation';
|
||||||
|
type PreviewReviewFilter = 'all' | 'needs_review' | 'complete';
|
||||||
type MaterialReadiness = { status: 'ready' | 'material_ready' | 'blocked' | 'missing'; blockers: string[] };
|
type MaterialReadiness = { status: 'ready' | 'material_ready' | 'blocked' | 'missing'; blockers: string[] };
|
||||||
type CutMaterialRequirement = { key: string; label: string; unit: string; quantity: number };
|
type CutMaterialRequirement = { key: string; label: string; unit: string; quantity: number };
|
||||||
|
|
||||||
@@ -156,6 +157,7 @@ const Cutting = () => {
|
|||||||
const [settingsSection, setSettingsSection] = useState<SettingsSection>('rules');
|
const [settingsSection, setSettingsSection] = useState<SettingsSection>('rules');
|
||||||
const [cuttingSettings, setCuttingSettings] = useState<CuttingSettings>(loadCuttingSettings);
|
const [cuttingSettings, setCuttingSettings] = useState<CuttingSettings>(loadCuttingSettings);
|
||||||
const [yieldRecommendations, setYieldRecommendations] = useState<ProductionYieldRecommendation[]>([]);
|
const [yieldRecommendations, setYieldRecommendations] = useState<ProductionYieldRecommendation[]>([]);
|
||||||
|
const [hasLoadedYieldRecommendations, setHasLoadedYieldRecommendations] = useState(false);
|
||||||
const [saveStatus, setSaveStatus] = useState<SaveStatus>('idle');
|
const [saveStatus, setSaveStatus] = useState<SaveStatus>('idle');
|
||||||
const [hasUnsavedSettings, setHasUnsavedSettings] = useState(false);
|
const [hasUnsavedSettings, setHasUnsavedSettings] = useState(false);
|
||||||
const [correctionIssueFilter, setCorrectionIssueFilter] = useState<CorrectionIssueFilter>('all');
|
const [correctionIssueFilter, setCorrectionIssueFilter] = useState<CorrectionIssueFilter>('all');
|
||||||
@@ -167,10 +169,11 @@ const Cutting = () => {
|
|||||||
const [previewStep, setPreviewStep] = useState<PreviewStep>('selection');
|
const [previewStep, setPreviewStep] = useState<PreviewStep>('selection');
|
||||||
const [previewPage, setPreviewPage] = useState(1);
|
const [previewPage, setPreviewPage] = useState(1);
|
||||||
const [previewItemsPerPage, setPreviewItemsPerPage] = useState(10);
|
const [previewItemsPerPage, setPreviewItemsPerPage] = useState(10);
|
||||||
|
const [confirmationPage, setConfirmationPage] = useState(1);
|
||||||
|
const [confirmationItemsPerPage, setConfirmationItemsPerPage] = useState(10);
|
||||||
|
const [previewReviewFilter, setPreviewReviewFilter] = useState<PreviewReviewFilter>('all');
|
||||||
const [selectedPreviewRowIds, setSelectedPreviewRowIds] = useState<string[]>([]);
|
const [selectedPreviewRowIds, setSelectedPreviewRowIds] = useState<string[]>([]);
|
||||||
const [previewQuantityOverrides, setPreviewQuantityOverrides] = useState<Record<string, string>>({});
|
const [previewQuantityOverrides, setPreviewQuantityOverrides] = useState<Record<string, string>>({});
|
||||||
const [previewLayers, setPreviewLayers] = useState<Record<string, string>>({});
|
|
||||||
const [previewMarkerReferences, setPreviewMarkerReferences] = useState<Record<string, 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: '', responsibleUserId: null as number | null, responsibleName: '', expectedDate: '', notes: '' });
|
const [productionPlanForm, setProductionPlanForm] = useState({ name: '', responsibleUserId: null as number | null, responsibleName: '', expectedDate: '', notes: '' });
|
||||||
const [operationalUsers, setOperationalUsers] = useState<OperationalUser[]>([]);
|
const [operationalUsers, setOperationalUsers] = useState<OperationalUser[]>([]);
|
||||||
@@ -212,14 +215,22 @@ const Cutting = () => {
|
|||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
if (!isSettingsOpen || settingsSection !== 'rules' || hasLoadedYieldRecommendations) return;
|
||||||
|
|
||||||
let isMounted = true;
|
let isMounted = true;
|
||||||
void fetchProductionOrderImportData().then(data => {
|
void fetchProductionOrderImportData().then(data => {
|
||||||
if (isMounted) setYieldRecommendations(data.recommendations);
|
if (isMounted) {
|
||||||
|
setYieldRecommendations(data.recommendations);
|
||||||
|
setHasLoadedYieldRecommendations(true);
|
||||||
|
}
|
||||||
}).catch(() => {
|
}).catch(() => {
|
||||||
if (isMounted) setYieldRecommendations([]);
|
if (isMounted) {
|
||||||
|
setYieldRecommendations([]);
|
||||||
|
setHasLoadedYieldRecommendations(true);
|
||||||
|
}
|
||||||
});
|
});
|
||||||
return () => { isMounted = false; };
|
return () => { isMounted = false; };
|
||||||
}, []);
|
}, [hasLoadedYieldRecommendations, isSettingsOpen, settingsSection]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let isMounted = true;
|
let isMounted = true;
|
||||||
@@ -228,7 +239,7 @@ const Cutting = () => {
|
|||||||
setIsLoading(true);
|
setIsLoading(true);
|
||||||
const [productData, productionOrderData, compositionData, stockData, supplySummary] = await Promise.all([
|
const [productData, productionOrderData, compositionData, stockData, supplySummary] = await Promise.all([
|
||||||
fetchProductAnalytics(dateRange),
|
fetchProductAnalytics(dateRange),
|
||||||
fetchProductionOrders(allProductionOrdersRange),
|
fetchProductionOrders(allProductionOrdersRange, { activeOnly: true }),
|
||||||
fetchProductCompositions(),
|
fetchProductCompositions(),
|
||||||
fetchStock(),
|
fetchStock(),
|
||||||
fetchSupplySummary()
|
fetchSupplySummary()
|
||||||
@@ -394,18 +405,22 @@ const Cutting = () => {
|
|||||||
};
|
};
|
||||||
}, [cutPlan.needRows, materialReadinessByProductId]);
|
}, [cutPlan.needRows, materialReadinessByProductId]);
|
||||||
|
|
||||||
const getPreviewQuantity = (row: CutPlanSkuRow) => {
|
const getPreviewQuantity = useCallback((row: CutPlanSkuRow) => {
|
||||||
const override = Number((previewQuantityOverrides[row.id] || '').replace(',', '.'));
|
const override = Number((previewQuantityOverrides[row.id] || '').replace(',', '.'));
|
||||||
return Number.isFinite(override) && override > 0 ? Math.round(override) : row.suggestedCutQuantity;
|
return Number.isFinite(override) && override > 0 ? Math.round(override) : row.suggestedCutQuantity;
|
||||||
};
|
}, [previewQuantityOverrides]);
|
||||||
|
|
||||||
const selectedPreviewRows = useMemo(() => {
|
const selectedPreviewRows = useMemo(() => {
|
||||||
const selectedIds = new Set(selectedPreviewRowIds);
|
const selectedIds = new Set(selectedPreviewRowIds);
|
||||||
return rowsAvailableForOrder.filter(row => selectedIds.has(row.id));
|
return rowsAvailableForOrder.filter(row => selectedIds.has(row.id));
|
||||||
}, [rowsAvailableForOrder, selectedPreviewRowIds]);
|
}, [rowsAvailableForOrder, selectedPreviewRowIds]);
|
||||||
|
const confirmationTotalPages = Math.ceil(selectedPreviewRows.length / confirmationItemsPerPage);
|
||||||
|
const safeConfirmationPage = Math.min(confirmationPage, confirmationTotalPages || 1);
|
||||||
|
const confirmationStartIndex = (safeConfirmationPage - 1) * confirmationItemsPerPage;
|
||||||
|
const paginatedConfirmationRows = selectedPreviewRows.slice(confirmationStartIndex, confirmationStartIndex + confirmationItemsPerPage);
|
||||||
const orderPreviewTotalUnits = useMemo(() => (
|
const orderPreviewTotalUnits = useMemo(() => (
|
||||||
selectedPreviewRows.reduce((total, row) => total + getPreviewQuantity(row), 0)
|
selectedPreviewRows.reduce((total, row) => total + getPreviewQuantity(row), 0)
|
||||||
), [previewQuantityOverrides, selectedPreviewRows]);
|
), [getPreviewQuantity, selectedPreviewRows]);
|
||||||
const selectedResponsibleUser = useMemo(() => (
|
const selectedResponsibleUser = useMemo(() => (
|
||||||
operationalUsers.find((user) => user.id === productionPlanForm.responsibleUserId && user.name === productionPlanForm.responsibleName) || null
|
operationalUsers.find((user) => user.id === productionPlanForm.responsibleUserId && user.name === productionPlanForm.responsibleName) || null
|
||||||
), [operationalUsers, productionPlanForm.responsibleName, productionPlanForm.responsibleUserId]);
|
), [operationalUsers, productionPlanForm.responsibleName, productionPlanForm.responsibleUserId]);
|
||||||
@@ -434,7 +449,7 @@ const Cutting = () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
return Array.from(requirements.values()).sort((a, b) => a.label.localeCompare(b.label, 'pt-BR'));
|
return Array.from(requirements.values()).sort((a, b) => a.label.localeCompare(b.label, 'pt-BR'));
|
||||||
}, [compositionsByProductId, previewQuantityOverrides, selectedPreviewRows]);
|
}, [compositionsByProductId, getPreviewQuantity, selectedPreviewRows]);
|
||||||
|
|
||||||
const selectedLotReservations = useMemo(() => (
|
const selectedLotReservations = useMemo(() => (
|
||||||
selectedMaterialRequirements.flatMap(requirement => {
|
selectedMaterialRequirements.flatMap(requirement => {
|
||||||
@@ -450,13 +465,17 @@ const Cutting = () => {
|
|||||||
const previewRowsWithIssues = useMemo(() => (
|
const previewRowsWithIssues = useMemo(() => (
|
||||||
selectedPreviewRows.filter(row => row.issues.length).length
|
selectedPreviewRows.filter(row => row.issues.length).length
|
||||||
), [selectedPreviewRows]);
|
), [selectedPreviewRows]);
|
||||||
const previewTotalPages = Math.ceil(rowsAvailableForOrder.length / previewItemsPerPage);
|
const previewRows = useMemo(() => rowsAvailableForOrder.filter(row => {
|
||||||
|
if (previewReviewFilter === 'needs_review') return row.issues.length > 0;
|
||||||
|
if (previewReviewFilter === 'complete') return row.issues.length === 0;
|
||||||
|
return true;
|
||||||
|
}), [previewReviewFilter, rowsAvailableForOrder]);
|
||||||
|
const previewTotalPages = Math.ceil(previewRows.length / previewItemsPerPage);
|
||||||
const safePreviewPage = Math.min(previewPage, previewTotalPages || 1);
|
const safePreviewPage = Math.min(previewPage, previewTotalPages || 1);
|
||||||
const previewStartIndex = (safePreviewPage - 1) * previewItemsPerPage;
|
const previewStartIndex = (safePreviewPage - 1) * previewItemsPerPage;
|
||||||
const paginatedPreviewRows = rowsAvailableForOrder.slice(previewStartIndex, previewStartIndex + previewItemsPerPage);
|
const paginatedPreviewRows = previewRows.slice(previewStartIndex, previewStartIndex + previewItemsPerPage);
|
||||||
const selectedPreviewRowIdSet = useMemo(() => new Set(selectedPreviewRowIds), [selectedPreviewRowIds]);
|
const selectedPreviewRowIdSet = useMemo(() => new Set(selectedPreviewRowIds), [selectedPreviewRowIds]);
|
||||||
const areAllPreviewRowsSelected = rowsAvailableForOrder.length > 0 && selectedPreviewRows.length === rowsAvailableForOrder.length;
|
const areAllPreviewRowsSelected = rowsAvailableForOrder.length > 0 && selectedPreviewRows.length === rowsAvailableForOrder.length;
|
||||||
const areAllPagePreviewRowsSelected = paginatedPreviewRows.length > 0 && paginatedPreviewRows.every(row => selectedPreviewRowIdSet.has(row.id));
|
|
||||||
|
|
||||||
const totalPages = Math.ceil(filteredRows.length / itemsPerPage);
|
const totalPages = Math.ceil(filteredRows.length / itemsPerPage);
|
||||||
const safeCurrentPage = Math.min(currentPage, totalPages || 1);
|
const safeCurrentPage = Math.min(currentPage, totalPages || 1);
|
||||||
@@ -682,10 +701,10 @@ const Cutting = () => {
|
|||||||
|
|
||||||
setGenerationMessage('');
|
setGenerationMessage('');
|
||||||
setPreviewPage(1);
|
setPreviewPage(1);
|
||||||
|
setConfirmationPage(1);
|
||||||
|
setPreviewReviewFilter('all');
|
||||||
setSelectedPreviewRowIds(rowsAvailableForOrder.map(row => row.id));
|
setSelectedPreviewRowIds(rowsAvailableForOrder.map(row => row.id));
|
||||||
setPreviewQuantityOverrides({});
|
setPreviewQuantityOverrides({});
|
||||||
setPreviewLayers({});
|
|
||||||
setPreviewMarkerReferences({});
|
|
||||||
setLotSelections({});
|
setLotSelections({});
|
||||||
setProductionPlanForm({ name: cutPlanReference, responsibleUserId: null, responsibleName: '', expectedDate: '', notes: '' });
|
setProductionPlanForm({ name: cutPlanReference, responsibleUserId: null, responsibleName: '', expectedDate: '', notes: '' });
|
||||||
setIsPlanDetailsOpen(false);
|
setIsPlanDetailsOpen(false);
|
||||||
@@ -696,10 +715,6 @@ const Cutting = () => {
|
|||||||
setIsOrderPreviewOpen(true);
|
setIsOrderPreviewOpen(true);
|
||||||
};
|
};
|
||||||
|
|
||||||
const setPreviewSelection = (rows: CutPlanSkuRow[]) => {
|
|
||||||
setSelectedPreviewRowIds(rows.map(row => row.id));
|
|
||||||
};
|
|
||||||
|
|
||||||
const togglePreviewRow = (productId: string) => {
|
const togglePreviewRow = (productId: string) => {
|
||||||
setSelectedPreviewRowIds(current => (
|
setSelectedPreviewRowIds(current => (
|
||||||
current.includes(productId)
|
current.includes(productId)
|
||||||
@@ -708,16 +723,11 @@ const Cutting = () => {
|
|||||||
));
|
));
|
||||||
};
|
};
|
||||||
|
|
||||||
const togglePreviewPage = () => {
|
const toggleAllPreviewRows = () => {
|
||||||
const pageIds = paginatedPreviewRows.map(row => row.id);
|
|
||||||
setSelectedPreviewRowIds(current => {
|
setSelectedPreviewRowIds(current => {
|
||||||
const currentIds = new Set(current);
|
const currentIds = new Set(current);
|
||||||
if (pageIds.every(id => currentIds.has(id))) {
|
const allRowsSelected = rowsAvailableForOrder.length > 0 && rowsAvailableForOrder.every(row => currentIds.has(row.id));
|
||||||
pageIds.forEach(id => currentIds.delete(id));
|
return allRowsSelected ? [] : rowsAvailableForOrder.map(row => row.id);
|
||||||
} else {
|
|
||||||
pageIds.forEach(id => currentIds.add(id));
|
|
||||||
}
|
|
||||||
return Array.from(currentIds);
|
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -756,8 +766,6 @@ const Cutting = () => {
|
|||||||
markers: [
|
markers: [
|
||||||
{ label: row.family.materialLabel, color: '#38bdf8' },
|
{ label: row.family.materialLabel, color: '#38bdf8' },
|
||||||
...(row.color ? [{ label: row.color, color: '#52DFA0' }] : []),
|
...(row.color ? [{ label: row.color, color: '#52DFA0' }] : []),
|
||||||
...(Number(previewLayers[row.id]) > 0 ? [{ label: `${previewLayers[row.id]} camadas`, color: '#c084fc' }] : []),
|
|
||||||
...(previewMarkerReferences[row.id]?.trim() ? [{ label: `Risco ${previewMarkerReferences[row.id].trim()}`, color: '#38bdf8' }] : []),
|
|
||||||
{ label: 'Material confirmado', color: '#52DFA0' },
|
{ label: 'Material confirmado', color: '#52DFA0' },
|
||||||
...(row.issues.length ? [{ label: 'Dados pendentes', color: '#f59e0b' }] : [])
|
...(row.issues.length ? [{ label: 'Dados pendentes', color: '#f59e0b' }] : [])
|
||||||
],
|
],
|
||||||
@@ -773,8 +781,6 @@ const Cutting = () => {
|
|||||||
stock: row.stock,
|
stock: row.stock,
|
||||||
openProductionQuantity: row.openProductionQuantity,
|
openProductionQuantity: row.openProductionQuantity,
|
||||||
availableQuantity: row.availableQuantity,
|
availableQuantity: row.availableQuantity,
|
||||||
plannedLayers: Number(previewLayers[row.id]) || null,
|
|
||||||
markerReference: previewMarkerReferences[row.id]?.trim() || '',
|
|
||||||
manuallyAdjustedQuantity: getPreviewQuantity(row) !== row.suggestedCutQuantity,
|
manuallyAdjustedQuantity: getPreviewQuantity(row) !== row.suggestedCutQuantity,
|
||||||
issues: row.issues
|
issues: row.issues
|
||||||
}
|
}
|
||||||
@@ -788,7 +794,7 @@ const Cutting = () => {
|
|||||||
source: 'cut_plan'
|
source: 'cut_plan'
|
||||||
});
|
});
|
||||||
|
|
||||||
const refreshedOrders = await fetchProductionOrders(allProductionOrdersRange, undefined, { force: true });
|
const refreshedOrders = await fetchProductionOrders(allProductionOrdersRange, { activeOnly: true }, { force: true });
|
||||||
setProductionOrders(refreshedOrders.orders);
|
setProductionOrders(refreshedOrders.orders);
|
||||||
setGenerationMessage(`${result.plan?.number || 'Plano'} criado com ${result.created.length} OPs${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);
|
||||||
@@ -1007,8 +1013,8 @@ const Cutting = () => {
|
|||||||
{previewRowsWithIssues > 0 && <span className="text-xs font-bold text-amber-300">{formatNumber(previewRowsWithIssues)} para revisar</span>}
|
{previewRowsWithIssues > 0 && <span className="text-xs font-bold text-amber-300">{formatNumber(previewRowsWithIssues)} para revisar</span>}
|
||||||
</div>
|
</div>
|
||||||
<div className="mt-3 divide-y divide-dark-border overflow-hidden rounded-lg border border-dark-border bg-dark-card">
|
<div className="mt-3 divide-y divide-dark-border overflow-hidden rounded-lg border border-dark-border bg-dark-card">
|
||||||
{selectedPreviewRows.map(row => (
|
{paginatedConfirmationRows.map(row => (
|
||||||
<div key={row.id} className="grid gap-3 px-3 py-3 lg:grid-cols-[minmax(0,1fr)_100px_100px_190px] lg:items-center">
|
<div key={row.id} className="grid gap-3 px-3 py-3 lg:grid-cols-[minmax(0,1fr)_100px] lg:items-center">
|
||||||
<div className="min-w-0">
|
<div className="min-w-0">
|
||||||
<p className="truncate text-sm font-bold text-dark-text" title={row.name}>{row.name}</p>
|
<p className="truncate text-sm font-bold text-dark-text" title={row.name}>{row.name}</p>
|
||||||
<div className="mt-1 flex flex-wrap items-center gap-1.5">
|
<div className="mt-1 flex flex-wrap items-center gap-1.5">
|
||||||
@@ -1021,26 +1027,46 @@ const Cutting = () => {
|
|||||||
<label className="text-[10px] font-bold uppercase tracking-wider text-dark-muted">Cortar
|
<label className="text-[10px] font-bold uppercase tracking-wider text-dark-muted">Cortar
|
||||||
<input inputMode="numeric" value={previewQuantityOverrides[row.id] ?? ''} onChange={(event) => setPreviewQuantityOverrides(current => ({ ...current, [row.id]: event.target.value }))} className="mt-1 h-9 w-full rounded-lg border border-dark-border bg-dark-input px-2 text-right text-sm font-bold text-brand-primary outline-none focus:border-brand-primary" placeholder={formatNumber(row.suggestedCutQuantity)} aria-label={`Quantidade planejada para ${row.name}`} />
|
<input inputMode="numeric" value={previewQuantityOverrides[row.id] ?? ''} onChange={(event) => setPreviewQuantityOverrides(current => ({ ...current, [row.id]: event.target.value }))} className="mt-1 h-9 w-full rounded-lg border border-dark-border bg-dark-input px-2 text-right text-sm font-bold text-brand-primary outline-none focus:border-brand-primary" placeholder={formatNumber(row.suggestedCutQuantity)} aria-label={`Quantidade planejada para ${row.name}`} />
|
||||||
</label>
|
</label>
|
||||||
<label className="text-[10px] font-bold uppercase tracking-wider text-dark-muted">Camadas
|
|
||||||
<input inputMode="numeric" value={previewLayers[row.id] ?? ''} onChange={(event) => setPreviewLayers(current => ({ ...current, [row.id]: event.target.value }))} className="mt-1 h-9 w-full rounded-lg border border-dark-border bg-dark-input px-2 text-right text-sm font-bold text-dark-text outline-none focus:border-brand-primary" placeholder="Opcional" aria-label={`Camadas planejadas para ${row.name}`} />
|
|
||||||
</label>
|
|
||||||
<label className="text-[10px] font-bold uppercase tracking-wider text-dark-muted">Risco / encaixe
|
|
||||||
<input value={previewMarkerReferences[row.id] ?? ''} onChange={(event) => setPreviewMarkerReferences(current => ({ ...current, [row.id]: event.target.value }))} className="mt-1 h-9 w-full rounded-lg border border-dark-border bg-dark-input px-2 text-sm font-bold text-dark-text outline-none placeholder:text-dark-muted focus:border-brand-primary" placeholder="Código ou arquivo" aria-label={`Risco ou encaixe para ${row.name}`} />
|
|
||||||
</label>
|
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
<p className="mt-3 text-xs font-semibold text-dark-muted">A quantidade sugerida vem da cobertura. Ajustes manuais ficam marcados na OP; camadas e risco são referências de preparação, e o resultado real é confirmado durante a execução.</p>
|
<PaginationControls
|
||||||
|
totalItems={selectedPreviewRows.length}
|
||||||
|
currentPage={safeConfirmationPage}
|
||||||
|
totalPages={confirmationTotalPages}
|
||||||
|
pageSize={confirmationItemsPerPage}
|
||||||
|
pageSizeOptions={[10, 20, 50]}
|
||||||
|
itemLabel="OPs"
|
||||||
|
pageSizeLabel="por página"
|
||||||
|
startIndex={confirmationStartIndex}
|
||||||
|
endIndex={Math.min(confirmationStartIndex + confirmationItemsPerPage, selectedPreviewRows.length)}
|
||||||
|
onPageChange={setConfirmationPage}
|
||||||
|
onPageSizeChange={(pageSize) => { setConfirmationItemsPerPage(pageSize); setConfirmationPage(1); }}
|
||||||
|
className="mt-3 border-t border-dark-border pt-3"
|
||||||
|
/>
|
||||||
|
<p className="mt-3 text-xs font-semibold text-dark-muted">A quantidade sugerida vem da cobertura. Ajustes manuais ficam marcados na OP, e o resultado real é confirmado durante a execução.</p>
|
||||||
</div>
|
</div>
|
||||||
</div>}
|
</div>}
|
||||||
|
|
||||||
{previewStep === 'selection' && <>
|
{previewStep === 'selection' && <>
|
||||||
<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">A seleção é mantida em todas as páginas. Use a caixa no cabeçalho para marcar ou desmarcar todos os SKUs aptos.</p>
|
||||||
<div className="flex flex-wrap gap-2">
|
<label className="flex shrink-0 items-center gap-2 text-xs font-bold text-dark-muted">
|
||||||
<button type="button" onClick={() => setPreviewSelection(rowsAvailableForOrder)} className="rounded-lg border border-dark-border bg-dark-card px-3 py-1.5 text-xs font-bold text-dark-text transition-colors hover:border-brand-primary cursor-pointer">{areAllPreviewRowsSelected ? 'Todos selecionados' : 'Selecionar todos'}</button>
|
Revisão de dados
|
||||||
<button type="button" onClick={() => setPreviewSelection([])} disabled={!selectedPreviewRows.length} className="rounded-lg border border-dark-border bg-dark-card px-3 py-1.5 text-xs font-bold text-dark-muted transition-colors hover:border-red-400/40 hover:text-red-300 disabled:cursor-not-allowed disabled:opacity-50 cursor-pointer">Limpar</button>
|
<select
|
||||||
</div>
|
value={previewReviewFilter}
|
||||||
|
onChange={(event) => {
|
||||||
|
setPreviewReviewFilter(event.target.value as PreviewReviewFilter);
|
||||||
|
setPreviewPage(1);
|
||||||
|
}}
|
||||||
|
className="h-9 rounded-lg border border-dark-border bg-dark-card px-2.5 text-xs font-bold text-dark-text outline-none transition-colors focus:border-brand-primary cursor-pointer"
|
||||||
|
aria-label="Filtrar produtos por revisão de dados"
|
||||||
|
>
|
||||||
|
<option value="all">Todos</option>
|
||||||
|
<option value="needs_review">Precisam de revisão</option>
|
||||||
|
<option value="complete">Sem revisão pendente</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="overflow-auto">
|
<div className="overflow-auto">
|
||||||
@@ -1055,7 +1081,7 @@ const Cutting = () => {
|
|||||||
</colgroup>
|
</colgroup>
|
||||||
<thead className="border-b border-dark-border bg-dark-header text-dark-muted">
|
<thead className="border-b border-dark-border bg-dark-header text-dark-muted">
|
||||||
<tr>
|
<tr>
|
||||||
<th className="px-4 py-3 text-center"><input type="checkbox" checked={areAllPagePreviewRowsSelected} onChange={togglePreviewPage} aria-label="Selecionar OPs desta página" className="h-4 w-4 accent-brand-primary cursor-pointer" /></th>
|
<th className="px-4 py-3 text-center"><input type="checkbox" checked={areAllPreviewRowsSelected} onChange={toggleAllPreviewRows} aria-label="Selecionar ou desmarcar todas as OPs aptas" className="h-4 w-4 accent-brand-primary cursor-pointer" /></th>
|
||||||
<th className="px-4 py-3 text-[10px] font-bold uppercase tracking-wider">Produto</th>
|
<th className="px-4 py-3 text-[10px] font-bold uppercase tracking-wider">Produto</th>
|
||||||
<th className="px-4 py-3 text-[10px] font-bold uppercase tracking-wider">Cortar agora</th>
|
<th className="px-4 py-3 text-[10px] font-bold uppercase tracking-wider">Cortar agora</th>
|
||||||
<th className="px-4 py-3 text-[10px] font-bold uppercase tracking-wider">Demanda</th>
|
<th className="px-4 py-3 text-[10px] font-bold uppercase tracking-wider">Demanda</th>
|
||||||
@@ -1082,7 +1108,20 @@ const Cutting = () => {
|
|||||||
<td className="px-4 py-3 font-bold whitespace-nowrap text-brand-primary">{formatNumber(row.suggestedCutQuantity)} un.</td>
|
<td className="px-4 py-3 font-bold whitespace-nowrap text-brand-primary">{formatNumber(row.suggestedCutQuantity)} un.</td>
|
||||||
<td className="px-4 py-3 font-bold whitespace-nowrap text-dark-text">{formatNumber(row.projectedDemand, 1)} un.</td>
|
<td className="px-4 py-3 font-bold whitespace-nowrap text-dark-text">{formatNumber(row.projectedDemand, 1)} un.</td>
|
||||||
<td className="px-4 py-3 whitespace-nowrap"><div className="font-bold text-dark-text">{formatNumber(row.availableQuantity)} un.</div>{row.openProductionQuantity ? <div className="mt-1 text-[10px] font-semibold text-dark-muted">{formatNumber(row.openProductionQuantity)} em OP</div> : null}</td>
|
<td className="px-4 py-3 whitespace-nowrap"><div className="font-bold text-dark-text">{formatNumber(row.availableQuantity)} un.</div>{row.openProductionQuantity ? <div className="mt-1 text-[10px] font-semibold text-dark-muted">{formatNumber(row.openProductionQuantity)} em OP</div> : null}</td>
|
||||||
<td className="px-4 py-3">{renderIssueBadge(row)}</td>
|
<td className="px-4 py-3">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
{renderIssueBadge(row)}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => openQuickCutEdit(row)}
|
||||||
|
className="inline-flex h-7 w-7 shrink-0 items-center justify-center rounded-lg border border-dark-border bg-dark-input text-dark-muted transition-colors hover:border-brand-primary hover:text-brand-primary cursor-pointer"
|
||||||
|
title={`Revisar dados do SKU ${row.id}`}
|
||||||
|
aria-label={`Revisar dados do SKU ${row.id}`}
|
||||||
|
>
|
||||||
|
<Pencil className="h-3.5 w-3.5" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
))}
|
))}
|
||||||
</tbody>
|
</tbody>
|
||||||
@@ -1102,7 +1141,7 @@ const Cutting = () => {
|
|||||||
</section>}
|
</section>}
|
||||||
|
|
||||||
{previewStep === 'selection' && <PaginationControls
|
{previewStep === 'selection' && <PaginationControls
|
||||||
totalItems={rowsAvailableForOrder.length}
|
totalItems={previewRows.length}
|
||||||
currentPage={safePreviewPage}
|
currentPage={safePreviewPage}
|
||||||
totalPages={previewTotalPages}
|
totalPages={previewTotalPages}
|
||||||
pageSize={previewItemsPerPage}
|
pageSize={previewItemsPerPage}
|
||||||
@@ -1110,7 +1149,7 @@ const Cutting = () => {
|
|||||||
itemLabel="OPs"
|
itemLabel="OPs"
|
||||||
pageSizeLabel="por página"
|
pageSizeLabel="por página"
|
||||||
startIndex={previewStartIndex}
|
startIndex={previewStartIndex}
|
||||||
endIndex={Math.min(previewStartIndex + previewItemsPerPage, rowsAvailableForOrder.length)}
|
endIndex={Math.min(previewStartIndex + previewItemsPerPage, previewRows.length)}
|
||||||
onPageChange={setPreviewPage}
|
onPageChange={setPreviewPage}
|
||||||
onPageSizeChange={(pageSize) => { setPreviewItemsPerPage(pageSize); setPreviewPage(1); }}
|
onPageSizeChange={(pageSize) => { setPreviewItemsPerPage(pageSize); setPreviewPage(1); }}
|
||||||
className="border-t border-dark-border px-4 py-3"
|
className="border-t border-dark-border px-4 py-3"
|
||||||
@@ -1132,7 +1171,7 @@ const Cutting = () => {
|
|||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => previewStep === 'selection' ? setPreviewStep('confirmation') : void generateProductionOrders()}
|
onClick={() => previewStep === 'selection' ? (setConfirmationPage(1), setPreviewStep('confirmation')) : void generateProductionOrders()}
|
||||||
disabled={isGeneratingOrders || !selectedPreviewRows.length}
|
disabled={isGeneratingOrders || !selectedPreviewRows.length}
|
||||||
className="inline-flex items-center justify-center gap-2 rounded-xl border border-brand-primary/30 bg-brand-primary/15 px-4 py-2 text-sm font-bold text-brand-primary transition-colors hover:border-brand-primary disabled:cursor-not-allowed disabled:opacity-50 cursor-pointer"
|
className="inline-flex items-center justify-center gap-2 rounded-xl border border-brand-primary/30 bg-brand-primary/15 px-4 py-2 text-sm font-bold text-brand-primary transition-colors hover:border-brand-primary disabled:cursor-not-allowed disabled:opacity-50 cursor-pointer"
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { Fragment, type FormEvent, useCallback, useEffect, useMemo, useState } from 'react';
|
import { type FormEvent, useCallback, useEffect, useMemo, useState } from 'react';
|
||||||
import { Link, useNavigate, useOutletContext } from 'react-router-dom';
|
import { Link, useNavigate, useOutletContext } from 'react-router-dom';
|
||||||
import { AlertTriangle, ArrowLeft, CheckCircle2, ChevronDown, ChevronUp, ClipboardList, Clock3, Download, PackageCheck, PlayCircle, Printer, Scissors, Search, Undo2, XCircle } from 'lucide-react';
|
import { AlertTriangle, ArrowLeft, CheckCircle2, ChevronDown, ChevronUp, ClipboardList, Clock3, Download, PackageCheck, PlayCircle, Printer, Scissors, Search, Undo2, X, XCircle } from 'lucide-react';
|
||||||
import DateRangePicker from '../components/DateRangePicker';
|
import DateRangePicker from '../components/DateRangePicker';
|
||||||
import PaginationControls from '../components/PaginationControls';
|
import PaginationControls from '../components/PaginationControls';
|
||||||
import RefreshStatus from '../components/RefreshStatus';
|
import RefreshStatus from '../components/RefreshStatus';
|
||||||
@@ -88,6 +88,31 @@ const getStatusStyle = (status: ProductionOrderStatus, fallbackLabel: string) =>
|
|||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const getNextStage = (status: ProductionOrderStatus): { status: Exclude<ProductionOrderStatusTab, 'all'>; label: string; description: string } | null => {
|
||||||
|
switch (status) {
|
||||||
|
case 'open': return { status: 'cutting', label: 'Iniciar corte', description: 'Libera o apontamento de corte e a baixa física de materiais.' };
|
||||||
|
case 'cutting': return { status: 'sewing', label: 'Avançar para costura', description: 'Use quando o corte desta OP estiver concluído.' };
|
||||||
|
case 'sewing': return { status: 'review', label: 'Enviar para revisão', description: 'Use quando as peças estiverem prontas para a conferência final.' };
|
||||||
|
case 'review': return { status: 'finished', label: 'Finalizar OP', description: 'Confirme a quantidade aprovada antes de encerrar a ordem.' };
|
||||||
|
default: return null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const getPreviousStage = (status: ProductionOrderStatus): { status: Exclude<ProductionOrderStatusTab, 'all'>; label: string; description: string } | null => {
|
||||||
|
switch (status) {
|
||||||
|
case 'cutting': return { status: 'open', label: 'Voltar para em aberto', description: 'Use quando a produção ainda não foi iniciada.' };
|
||||||
|
case 'sewing': return { status: 'cutting', label: 'Voltar para corte', description: 'Use quando esta OP precisa retornar à etapa de corte.' };
|
||||||
|
case 'review': return { status: 'sewing', label: 'Voltar para costura', description: 'Use quando a revisão identificar um ajuste na montagem.' };
|
||||||
|
case 'finished': return { status: 'review', label: 'Reabrir em revisão', description: 'Use para corrigir uma OP finalizada antes de encerrá-la novamente.' };
|
||||||
|
default: return null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const canReturnToCutPlan = (order: ProductionOrderItem) => (
|
||||||
|
!['finished', 'canceled'].includes(order.status)
|
||||||
|
&& (order.integrationStatus || '').toLowerCase() === 'local'
|
||||||
|
);
|
||||||
|
|
||||||
const ProductionOrdersSkeleton = () => (
|
const ProductionOrdersSkeleton = () => (
|
||||||
<div className="space-y-6" aria-label="Carregando ordens de produção">
|
<div className="space-y-6" aria-label="Carregando ordens de produção">
|
||||||
<div className="grid grid-cols-1 gap-4 md:grid-cols-4">
|
<div className="grid grid-cols-1 gap-4 md:grid-cols-4">
|
||||||
@@ -216,8 +241,11 @@ const ProductionOrders = () => {
|
|||||||
const openCount = summary.counts.open || 0;
|
const openCount = summary.counts.open || 0;
|
||||||
const progressCount = (summary.counts.cutting || 0) + (summary.counts.sewing || 0) + (summary.counts.review || 0);
|
const progressCount = (summary.counts.cutting || 0) + (summary.counts.sewing || 0) + (summary.counts.review || 0);
|
||||||
const finishedCount = summary.counts.finished || 0;
|
const finishedCount = summary.counts.finished || 0;
|
||||||
const totalQuantity = summary.orders.reduce((total, order) => total + order.quantity, 0);
|
const finishedQuantity = summary.orders.filter(order => order.status === 'finished').reduce((total, order) => total + (order.finishedQuantity ?? order.quantity), 0);
|
||||||
const selectedOrder = summary.orders.find(order => order.id === selectedOrderId) || null;
|
const selectedOrder = summary.orders.find(order => order.id === selectedOrderId) || null;
|
||||||
|
const selectedOrderNextStage = selectedOrder ? getNextStage(selectedOrder.status) : null;
|
||||||
|
const selectedOrderPreviousStage = selectedOrder ? getPreviousStage(selectedOrder.status) : null;
|
||||||
|
const selectedOrderCanReturnToCutPlan = Boolean(selectedOrder && canReturnToCutPlan(selectedOrder));
|
||||||
|
|
||||||
const handleManualRefresh = () => {
|
const handleManualRefresh = () => {
|
||||||
void loadProductionOrders({ force: true });
|
void loadProductionOrders({ force: true });
|
||||||
@@ -242,7 +270,7 @@ const ProductionOrders = () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const returnOrderToCutPlan = async (order: ProductionOrderItem) => {
|
const returnOrderToCutPlan = async (order: ProductionOrderItem) => {
|
||||||
if (order.status !== 'open' || (order.integrationStatus || '').toLowerCase() !== 'local') return;
|
if (!canReturnToCutPlan(order)) return;
|
||||||
const orderNumber = order.number || `#${order.id}`;
|
const orderNumber = order.number || `#${order.id}`;
|
||||||
if (!window.confirm(`Devolver a OP ${orderNumber} ao Plano de Corte?\n\nA OP será cancelada, preservando o histórico. A necessidade voltará para o Plano de Corte e poderá ser gerada novamente quando você decidir.`)) return;
|
if (!window.confirm(`Devolver a OP ${orderNumber} ao Plano de Corte?\n\nA OP será cancelada, preservando o histórico. A necessidade voltará para o Plano de Corte e poderá ser gerada novamente quando você decidir.`)) return;
|
||||||
|
|
||||||
@@ -307,7 +335,6 @@ const ProductionOrders = () => {
|
|||||||
rejectedQuantity: parseOptionalQuantity('rejectedQuantity'),
|
rejectedQuantity: parseOptionalQuantity('rejectedQuantity'),
|
||||||
finishedQuantity: parseOptionalQuantity('finishedQuantity'),
|
finishedQuantity: parseOptionalQuantity('finishedQuantity'),
|
||||||
qualityNotes: String(form.get('qualityNotes') || '').trim(),
|
qualityNotes: String(form.get('qualityNotes') || '').trim(),
|
||||||
cutLayers: parseOptionalQuantity('cutLayers'),
|
|
||||||
markerReference: String(form.get('markerReference') || '').trim(),
|
markerReference: String(form.get('markerReference') || '').trim(),
|
||||||
});
|
});
|
||||||
const nextSummary = await fetchProductionOrders(dateRange, { search: searchTerm }, { force: true });
|
const nextSummary = await fetchProductionOrders(dateRange, { search: searchTerm }, { force: true });
|
||||||
@@ -380,18 +407,10 @@ const ProductionOrders = () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const renderOrderDetails = (order: ProductionOrderItem) => {
|
const renderOrderDetails = (order: ProductionOrderItem) => {
|
||||||
const canReturnToCutPlan = order.status === 'open' && (order.integrationStatus || '').toLowerCase() === 'local';
|
const canRecordOperationalData = ['cutting', 'sewing', 'review'].includes(order.status);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6 rounded-xl border border-dark-border bg-dark-input/25 p-5">
|
<div className="space-y-6">
|
||||||
<div className="flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between">
|
|
||||||
<div>
|
|
||||||
<p className="text-xs font-bold uppercase tracking-widest text-dark-muted">Detalhes da OP</p>
|
|
||||||
<p className="mt-1 text-sm font-bold text-dark-text">{order.number || `OP #${order.id}`} · {order.productDescription}</p>
|
|
||||||
</div>
|
|
||||||
{canReturnToCutPlan && <button type="button" onClick={() => void returnOrderToCutPlan(order)} disabled={busyStatusOrderId === order.id} className="inline-flex h-9 shrink-0 items-center justify-center gap-2 rounded-lg border border-amber-400/30 bg-amber-400/10 px-3 text-xs font-bold text-amber-200 transition-colors hover:border-amber-400/60 hover:bg-amber-400/15 disabled:cursor-not-allowed disabled:opacity-50 cursor-pointer"><Undo2 className="h-4 w-4" />Devolver ao Plano de Corte</button>}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="grid grid-cols-2 gap-3 md:grid-cols-4">
|
<div className="grid grid-cols-2 gap-3 md:grid-cols-4">
|
||||||
<div className="rounded-xl border border-dark-border bg-dark-card p-3"><p className="text-[10px] font-bold uppercase tracking-wider text-dark-muted">Quantidade</p><p className="mt-1 text-lg font-bold text-dark-text">{formatQuantity(order.quantity)} <span className="text-xs text-dark-muted">{order.unit}</span></p></div>
|
<div className="rounded-xl border border-dark-border bg-dark-card p-3"><p className="text-[10px] font-bold uppercase tracking-wider text-dark-muted">Quantidade</p><p className="mt-1 text-lg font-bold text-dark-text">{formatQuantity(order.quantity)} <span className="text-xs text-dark-muted">{order.unit}</span></p></div>
|
||||||
<div className="relative rounded-xl border border-dark-border bg-dark-card p-3"><p className="text-[10px] font-bold uppercase tracking-wider text-dark-muted">Status</p><button type="button" onClick={() => setStatusEditorOrderId(current => current === order.id ? null : order.id)} aria-expanded={statusEditorOrderId === order.id} className={`mt-2 inline-flex cursor-pointer items-center gap-2 rounded-lg border px-2.5 py-1 text-xs font-bold ${getStatusStyle(order.status, order.statusLabel).className}`}><span className={`h-2 w-2 rounded-full ${getStatusStyle(order.status, order.statusLabel).dotClass}`} />{getStatusStyle(order.status, order.statusLabel).label}{statusEditorOrderId === order.id ? <ChevronUp className="h-3.5 w-3.5" /> : <ChevronDown className="h-3.5 w-3.5" />}</button>{statusEditorOrderId === order.id && <div className="absolute left-0 top-full z-20 mt-2 w-64 overflow-hidden rounded-xl border border-dark-border bg-dark-card shadow-xl"><p className="border-b border-dark-border px-3 py-2 text-[10px] font-bold uppercase tracking-wider text-dark-muted">Alterar status</p>{editableStatusOptions.map(option => { const style = getStatusStyle(option.value, option.label); const isCurrent = order.status === option.value; const Icon = option.value === 'finished' ? CheckCircle2 : option.value === 'canceled' ? XCircle : option.value === 'cutting' ? Scissors : option.value === 'sewing' ? PlayCircle : option.value === 'review' ? ClipboardList : Clock3; return <button key={option.value} type="button" disabled={isCurrent || busyStatusOrderId === order.id} onClick={() => void handleStatusChange(order, option.value)} className={`flex w-full cursor-pointer items-center gap-3 px-3 py-2.5 text-left transition-colors disabled:cursor-not-allowed disabled:opacity-60 ${isCurrent ? style.className : 'hover:bg-dark-input/70'}`}><Icon className="h-4 w-4 shrink-0" /><span className="text-xs font-bold text-dark-text">{option.label}{isCurrent && ' · Atual'}</span></button>; })}</div>}</div>
|
<div className="relative rounded-xl border border-dark-border bg-dark-card p-3"><p className="text-[10px] font-bold uppercase tracking-wider text-dark-muted">Status</p><button type="button" onClick={() => setStatusEditorOrderId(current => current === order.id ? null : order.id)} aria-expanded={statusEditorOrderId === order.id} className={`mt-2 inline-flex cursor-pointer items-center gap-2 rounded-lg border px-2.5 py-1 text-xs font-bold ${getStatusStyle(order.status, order.statusLabel).className}`}><span className={`h-2 w-2 rounded-full ${getStatusStyle(order.status, order.statusLabel).dotClass}`} />{getStatusStyle(order.status, order.statusLabel).label}{statusEditorOrderId === order.id ? <ChevronUp className="h-3.5 w-3.5" /> : <ChevronDown className="h-3.5 w-3.5" />}</button>{statusEditorOrderId === order.id && <div className="absolute left-0 top-full z-20 mt-2 w-64 overflow-hidden rounded-xl border border-dark-border bg-dark-card shadow-xl"><p className="border-b border-dark-border px-3 py-2 text-[10px] font-bold uppercase tracking-wider text-dark-muted">Alterar status</p>{editableStatusOptions.map(option => { const style = getStatusStyle(option.value, option.label); const isCurrent = order.status === option.value; const Icon = option.value === 'finished' ? CheckCircle2 : option.value === 'canceled' ? XCircle : option.value === 'cutting' ? Scissors : option.value === 'sewing' ? PlayCircle : option.value === 'review' ? ClipboardList : Clock3; return <button key={option.value} type="button" disabled={isCurrent || busyStatusOrderId === order.id} onClick={() => void handleStatusChange(order, option.value)} className={`flex w-full cursor-pointer items-center gap-3 px-3 py-2.5 text-left transition-colors disabled:cursor-not-allowed disabled:opacity-60 ${isCurrent ? style.className : 'hover:bg-dark-input/70'}`}><Icon className="h-4 w-4 shrink-0" /><span className="text-xs font-bold text-dark-text">{option.label}{isCurrent && ' · Atual'}</span></button>; })}</div>}</div>
|
||||||
@@ -399,6 +418,8 @@ const ProductionOrders = () => {
|
|||||||
<div className="rounded-xl border border-dark-border bg-dark-card p-3"><p className="text-[10px] font-bold uppercase tracking-wider text-dark-muted">Previsão</p><p className="mt-1 text-sm font-bold text-dark-text">{formatDate(order.expectedDate)}</p></div>
|
<div className="rounded-xl border border-dark-border bg-dark-card p-3"><p className="text-[10px] font-bold uppercase tracking-wider text-dark-muted">Previsão</p><p className="mt-1 text-sm font-bold text-dark-text">{formatDate(order.expectedDate)}</p></div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{order.status === 'finished' && <section><h3 className="text-xs font-bold uppercase tracking-widest text-dark-muted">Resultado registrado</h3><div className="mt-3 grid gap-3 sm:grid-cols-3"><div className="rounded-xl border border-dark-border bg-dark-card p-3"><p className="text-[10px] font-bold uppercase tracking-wider text-dark-muted">Cortado</p><p className="mt-1 text-lg font-bold text-dark-text">{formatQuantity(order.actualCutQuantity ?? 0)} <span className="text-xs text-dark-muted">{order.unit}</span></p></div><div className="rounded-xl border border-dark-border bg-dark-card p-3"><p className="text-[10px] font-bold uppercase tracking-wider text-dark-muted">Perdas</p><p className="mt-1 text-lg font-bold text-amber-200">{formatQuantity(order.rejectedQuantity || 0)} <span className="text-xs text-dark-muted">{order.unit}</span></p></div><div className="rounded-xl border border-dark-border bg-dark-card p-3"><p className="text-[10px] font-bold uppercase tracking-wider text-dark-muted">Aprovado</p><p className="mt-1 text-lg font-bold text-emerald-300">{formatQuantity(order.finishedQuantity ?? 0)} <span className="text-xs text-dark-muted">{order.unit}</span></p></div></div>{(order.markerReference || order.qualityNotes) && <div className="mt-3 rounded-xl border border-dark-border bg-dark-card p-3 text-xs font-semibold text-dark-muted">{order.markerReference && <p>Referência do risco: <span className="font-bold text-dark-text">{order.markerReference}</span></p>}{order.qualityNotes && <p className={order.markerReference ? 'mt-2' : ''}>{order.qualityNotes}</p>}</div>}</section>}
|
||||||
|
|
||||||
<section>
|
<section>
|
||||||
<h3 className="text-xs font-bold uppercase tracking-widest text-dark-muted">Produto e marcadores</h3>
|
<h3 className="text-xs font-bold uppercase tracking-widest text-dark-muted">Produto e marcadores</h3>
|
||||||
<div className="mt-3 rounded-xl border border-dark-border bg-dark-card p-4">
|
<div className="mt-3 rounded-xl border border-dark-border bg-dark-card p-4">
|
||||||
@@ -408,34 +429,27 @@ const ProductionOrders = () => {
|
|||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section>
|
{canRecordOperationalData && <section>
|
||||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
<div><h3 className="text-xs font-bold uppercase tracking-widest text-dark-muted">Execução e qualidade</h3><p className="mt-1 text-xs font-semibold text-dark-muted">Registre o corte real, perdas e a quantidade aprovada antes de avançar a OP.</p></div>
|
||||||
<div>
|
|
||||||
<h3 className="text-xs font-bold uppercase tracking-widest text-dark-muted">Execução e qualidade</h3>
|
|
||||||
<p className="mt-1 text-xs font-semibold text-dark-muted">Registre o corte real, perdas e a quantidade aprovada antes de finalizar.</p>
|
|
||||||
</div>
|
|
||||||
<button type="button" onClick={() => printProductionSheet(order)} className="inline-flex h-9 items-center gap-2 rounded-lg border border-dark-border bg-dark-card px-3 text-xs font-bold text-dark-text transition-colors hover:border-brand-primary hover:text-brand-primary cursor-pointer"><Printer className="h-4 w-4" />Imprimir ficha</button>
|
|
||||||
</div>
|
|
||||||
<form onSubmit={(event) => void handleExecutionSave(event, order)} className="mt-3 rounded-xl border border-dark-border bg-dark-card p-4">
|
<form onSubmit={(event) => void handleExecutionSave(event, order)} className="mt-3 rounded-xl border border-dark-border bg-dark-card p-4">
|
||||||
<div className="grid grid-cols-1 gap-3 sm:grid-cols-3">
|
<div className="grid grid-cols-1 gap-3 sm:grid-cols-3">
|
||||||
<label className="text-xs font-bold text-dark-muted">Cortado real<input name="actualCutQuantity" inputMode="decimal" defaultValue={order.actualCutQuantity ?? ''} className="mt-1.5 h-11 w-full rounded-lg border border-dark-border bg-dark-input px-3 text-sm font-semibold text-dark-text outline-none placeholder:text-dark-muted focus:border-brand-primary" placeholder={formatQuantity(order.quantity)} /></label>
|
<label className="text-xs font-bold text-dark-muted">Cortado real<input name="actualCutQuantity" inputMode="decimal" defaultValue={order.actualCutQuantity ?? ''} className="mt-1.5 h-11 w-full rounded-lg border border-dark-border bg-dark-input px-3 text-sm font-semibold text-dark-text outline-none placeholder:text-dark-muted focus:border-brand-primary" placeholder={formatQuantity(order.quantity)} /></label>
|
||||||
<label className="text-xs font-bold text-dark-muted">Perdas / defeitos<input name="rejectedQuantity" inputMode="decimal" defaultValue={order.rejectedQuantity || ''} className="mt-1.5 h-11 w-full rounded-lg border border-dark-border bg-dark-input px-3 text-sm font-semibold text-dark-text outline-none placeholder:text-dark-muted focus:border-brand-primary" placeholder="0" /></label>
|
<label className="text-xs font-bold text-dark-muted">Perdas / defeitos<input name="rejectedQuantity" inputMode="decimal" defaultValue={order.rejectedQuantity || ''} className="mt-1.5 h-11 w-full rounded-lg border border-dark-border bg-dark-input px-3 text-sm font-semibold text-dark-text outline-none placeholder:text-dark-muted focus:border-brand-primary" placeholder="0" /></label>
|
||||||
<label className="text-xs font-bold text-dark-muted">Aprovado final<input name="finishedQuantity" inputMode="decimal" defaultValue={order.finishedQuantity ?? ''} className="mt-1.5 h-11 w-full rounded-lg border border-dark-border bg-dark-input px-3 text-sm font-semibold text-dark-text outline-none placeholder:text-dark-muted focus:border-brand-primary" placeholder="Após revisão" /></label>
|
<label className="text-xs font-bold text-dark-muted">Aprovado final<input name="finishedQuantity" inputMode="decimal" defaultValue={order.finishedQuantity ?? ''} className="mt-1.5 h-11 w-full rounded-lg border border-dark-border bg-dark-input px-3 text-sm font-semibold text-dark-text outline-none placeholder:text-dark-muted focus:border-brand-primary" placeholder="Após revisão" /></label>
|
||||||
<label className="text-xs font-bold text-dark-muted">Camadas cortadas<input name="cutLayers" inputMode="numeric" defaultValue={order.cutLayers ?? ''} className="mt-1.5 h-11 w-full rounded-lg border border-dark-border bg-dark-input px-3 text-sm font-semibold text-dark-text outline-none placeholder:text-dark-muted focus:border-brand-primary" placeholder="Ex.: 12" /></label>
|
<label className="text-xs font-bold text-dark-muted sm:col-span-2">Referência do risco<input name="markerReference" defaultValue={order.markerReference} className="mt-1.5 h-11 w-full rounded-lg border border-dark-border bg-dark-input px-3 text-sm font-semibold text-dark-text outline-none placeholder:text-dark-muted focus:border-brand-primary" placeholder="Código ou arquivo do risco" /></label>
|
||||||
<label className="text-xs font-bold text-dark-muted sm:col-span-2">Risco / encaixe<input name="markerReference" defaultValue={order.markerReference} className="mt-1.5 h-11 w-full rounded-lg border border-dark-border bg-dark-input px-3 text-sm font-semibold text-dark-text outline-none placeholder:text-dark-muted focus:border-brand-primary" placeholder="Código do risco, encaixe ou arquivo" /></label>
|
|
||||||
<label className="text-xs font-bold text-dark-muted sm:col-span-3">Motivo de perda / observação<textarea name="qualityNotes" defaultValue={order.qualityNotes} className="mt-1.5 min-h-20 w-full rounded-lg border border-dark-border bg-dark-input px-3 py-2.5 text-sm font-semibold text-dark-text outline-none placeholder:text-dark-muted focus:border-brand-primary" placeholder="Ex.: 4 peças com mancha de tinta; 2 recuperadas na revisão." /></label>
|
<label className="text-xs font-bold text-dark-muted sm:col-span-3">Motivo de perda / observação<textarea name="qualityNotes" defaultValue={order.qualityNotes} className="mt-1.5 min-h-20 w-full rounded-lg border border-dark-border bg-dark-input px-3 py-2.5 text-sm font-semibold text-dark-text outline-none placeholder:text-dark-muted focus:border-brand-primary" placeholder="Ex.: 4 peças com mancha de tinta; 2 recuperadas na revisão." /></label>
|
||||||
</div>
|
</div>
|
||||||
<div className="mt-4 flex flex-wrap items-center justify-between gap-3"><p className="text-xs font-semibold text-dark-muted">Planejado: {formatQuantity(order.quantity)} {order.unit}</p><button type="submit" disabled={busyExecutionOrderId === order.id} className="inline-flex h-10 items-center gap-2 rounded-lg border border-brand-primary/35 bg-brand-primary/10 px-4 text-sm font-bold text-brand-primary transition-colors hover:bg-brand-primary/20 disabled:cursor-not-allowed disabled:opacity-50 cursor-pointer"><CheckCircle2 className="h-4 w-4" />{busyExecutionOrderId === order.id ? 'Salvando...' : 'Salvar execução'}</button></div>
|
<div className="mt-4 flex flex-wrap items-center justify-between gap-3"><p className="text-xs font-semibold text-dark-muted">Planejado: {formatQuantity(order.quantity)} {order.unit}</p><button type="submit" disabled={busyExecutionOrderId === order.id} className="inline-flex h-10 items-center gap-2 rounded-lg border border-brand-primary/35 bg-brand-primary/10 px-4 text-sm font-bold text-brand-primary transition-colors hover:bg-brand-primary/20 disabled:cursor-not-allowed disabled:opacity-50 cursor-pointer"><CheckCircle2 className="h-4 w-4" />{busyExecutionOrderId === order.id ? 'Salvando...' : 'Salvar execução'}</button></div>
|
||||||
{executionMessage && <p className="mt-3 rounded-lg border border-dark-border bg-dark-input px-3 py-2 text-sm font-bold text-dark-muted">{executionMessage}</p>}
|
{executionMessage && <p className="mt-3 rounded-lg border border-dark-border bg-dark-input px-3 py-2 text-sm font-bold text-dark-muted">{executionMessage}</p>}
|
||||||
</form>
|
</form>
|
||||||
</section>
|
</section>}
|
||||||
|
|
||||||
<section>
|
{(canRecordOperationalData || order.defects.length > 0) && <section>
|
||||||
<div>
|
<div>
|
||||||
<h3 className="text-xs font-bold uppercase tracking-widest text-dark-muted">Defeitos e origem</h3>
|
<h3 className="text-xs font-bold uppercase tracking-widest text-dark-muted">Defeitos e origem</h3>
|
||||||
<p className="mt-1 text-xs font-semibold text-dark-muted">Registre cada ocorrência para acompanhar perdas por posto, lote e tipo de defeito.</p>
|
<p className="mt-1 text-xs font-semibold text-dark-muted">Registre cada ocorrência para acompanhar perdas por posto, lote e tipo de defeito.</p>
|
||||||
</div>
|
</div>
|
||||||
<form onSubmit={(event) => void handleDefectSave(event, order)} className="mt-3 rounded-xl border border-dark-border bg-dark-card p-4">
|
{canRecordOperationalData && <form onSubmit={(event) => void handleDefectSave(event, order)} className="mt-3 rounded-xl border border-dark-border bg-dark-card p-4">
|
||||||
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2 lg:grid-cols-4">
|
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2 lg:grid-cols-4">
|
||||||
<label className="text-xs font-bold text-dark-muted">Posto de trabalho<select required name="workstationId" defaultValue="" className="mt-1.5 h-11 w-full rounded-lg border border-dark-border bg-dark-input px-3 text-sm font-semibold text-dark-text outline-none focus:border-brand-primary"><option value="">Selecione...</option>{workstations.map(workstation => <option key={workstation.id} value={workstation.id}>{workstation.name}</option>)}</select></label>
|
<label className="text-xs font-bold text-dark-muted">Posto de trabalho<select required name="workstationId" defaultValue="" className="mt-1.5 h-11 w-full rounded-lg border border-dark-border bg-dark-input px-3 text-sm font-semibold text-dark-text outline-none focus:border-brand-primary"><option value="">Selecione...</option>{workstations.map(workstation => <option key={workstation.id} value={workstation.id}>{workstation.name}</option>)}</select></label>
|
||||||
<label className="text-xs font-bold text-dark-muted">Tipo de defeito<input required name="defectType" className="mt-1.5 h-11 w-full rounded-lg border border-dark-border bg-dark-input px-3 text-sm font-semibold text-dark-text outline-none placeholder:text-dark-muted focus:border-brand-primary" placeholder="Ex.: furo, mancha" /></label>
|
<label className="text-xs font-bold text-dark-muted">Tipo de defeito<input required name="defectType" className="mt-1.5 h-11 w-full rounded-lg border border-dark-border bg-dark-input px-3 text-sm font-semibold text-dark-text outline-none placeholder:text-dark-muted focus:border-brand-primary" placeholder="Ex.: furo, mancha" /></label>
|
||||||
@@ -446,10 +460,10 @@ const ProductionOrders = () => {
|
|||||||
</div>
|
</div>
|
||||||
<div className="mt-4 flex flex-wrap items-center justify-between gap-3"><p className="text-xs font-semibold text-dark-muted">{workstations.length ? `${workstations.length} postos ativos disponíveis.` : 'Nenhum posto de trabalho ativo cadastrado.'}</p><button type="submit" disabled={busyDefectOrderId === order.id || !workstations.length} className="inline-flex h-10 items-center gap-2 rounded-lg border border-amber-400/35 bg-amber-400/10 px-4 text-sm font-bold text-amber-200 transition-colors hover:bg-amber-400/20 disabled:cursor-not-allowed disabled:opacity-50 cursor-pointer"><AlertTriangle className="h-4 w-4" />{busyDefectOrderId === order.id ? 'Registrando...' : 'Registrar defeito'}</button></div>
|
<div className="mt-4 flex flex-wrap items-center justify-between gap-3"><p className="text-xs font-semibold text-dark-muted">{workstations.length ? `${workstations.length} postos ativos disponíveis.` : 'Nenhum posto de trabalho ativo cadastrado.'}</p><button type="submit" disabled={busyDefectOrderId === order.id || !workstations.length} className="inline-flex h-10 items-center gap-2 rounded-lg border border-amber-400/35 bg-amber-400/10 px-4 text-sm font-bold text-amber-200 transition-colors hover:bg-amber-400/20 disabled:cursor-not-allowed disabled:opacity-50 cursor-pointer"><AlertTriangle className="h-4 w-4" />{busyDefectOrderId === order.id ? 'Registrando...' : 'Registrar defeito'}</button></div>
|
||||||
{defectMessage && <p className="mt-3 rounded-lg border border-dark-border bg-dark-input px-3 py-2 text-sm font-bold text-dark-muted">{defectMessage}</p>}
|
{defectMessage && <p className="mt-3 rounded-lg border border-dark-border bg-dark-input px-3 py-2 text-sm font-bold text-dark-muted">{defectMessage}</p>}
|
||||||
</form>
|
</form>}
|
||||||
{order.defects.length > 0 && <div className="mt-3 overflow-hidden rounded-xl border border-dark-border bg-dark-card"><div className="grid grid-cols-[minmax(0,1fr)_auto] gap-3 border-b border-dark-border bg-dark-input/40 px-4 py-2.5 text-[10px] font-bold uppercase tracking-widest text-dark-muted"><span>Ocorrência</span><span>Quantidade</span></div><div className="divide-y divide-dark-border">{order.defects.map(defect => <div key={defect.id} className="grid grid-cols-[minmax(0,1fr)_auto] gap-3 px-4 py-3"><div><p className="text-sm font-bold text-dark-text">{defect.defectType}</p><p className="mt-1 text-xs font-semibold text-dark-muted">{[defect.workstationName || 'Sem posto', defect.origin, defect.lotCode ? `Lote ${defect.lotCode}` : ''].filter(Boolean).join(' · ')}</p>{defect.notes && <p className="mt-1 text-xs font-medium text-dark-muted">{defect.notes}</p>}</div><p className="text-sm font-bold text-amber-200">{formatQuantity(defect.quantity)} {order.unit}</p></div>)}</div></div>}
|
{order.defects.length > 0 && <div className="mt-3 overflow-hidden rounded-xl border border-dark-border bg-dark-card"><div className="grid grid-cols-[minmax(0,1fr)_auto] gap-3 border-b border-dark-border bg-dark-input/40 px-4 py-2.5 text-[10px] font-bold uppercase tracking-widest text-dark-muted"><span>Ocorrência</span><span>Quantidade</span></div><div className="divide-y divide-dark-border">{order.defects.map(defect => <div key={defect.id} className="grid grid-cols-[minmax(0,1fr)_auto] gap-3 px-4 py-3"><div><p className="text-sm font-bold text-dark-text">{defect.defectType}</p><p className="mt-1 text-xs font-semibold text-dark-muted">{[defect.workstationName || 'Sem posto', defect.origin, defect.lotCode ? `Lote ${defect.lotCode}` : ''].filter(Boolean).join(' · ')}</p>{defect.notes && <p className="mt-1 text-xs font-medium text-dark-muted">{defect.notes}</p>}</div><p className="text-sm font-bold text-amber-200">{formatQuantity(defect.quantity)} {order.unit}</p></div>)}</div></div>}
|
||||||
{!order.defects.length && <p className="mt-3 rounded-xl border border-dark-border bg-dark-card px-4 py-3 text-sm font-semibold text-dark-muted">Nenhum defeito registrado nesta OP.</p>}
|
{!order.defects.length && canRecordOperationalData && <p className="mt-3 rounded-xl border border-dark-border bg-dark-card px-4 py-3 text-sm font-semibold text-dark-muted">Nenhum defeito registrado nesta OP.</p>}
|
||||||
</section>
|
</section>}
|
||||||
|
|
||||||
<section>
|
<section>
|
||||||
<h3 className="text-xs font-bold uppercase tracking-widest text-dark-muted">Composição</h3>
|
<h3 className="text-xs font-bold uppercase tracking-widest text-dark-muted">Composição</h3>
|
||||||
@@ -461,10 +475,10 @@ const ProductionOrders = () => {
|
|||||||
<div className="mt-3 overflow-hidden rounded-xl border border-dark-border"><table className="w-full text-xs"><thead className="bg-dark-header text-dark-muted"><tr><th className="px-3 py-2.5 text-left font-bold">Lote</th><th className="px-3 py-2.5 text-left font-bold">Material</th><th className="px-3 py-2.5 text-right font-bold">Reservado</th><th className="px-3 py-2.5 text-right font-bold">Consumido</th></tr></thead><tbody className="divide-y divide-dark-border">{order.lotReservations.map(reservation => <tr key={reservation.id}><td className="px-3 py-3"><p className="font-bold text-dark-text">#{reservation.lotId}</p><p className="mt-0.5 text-[10px] font-semibold text-dark-muted">{reservation.lotSupplier || 'Sem fornecedor'}</p></td><td className="px-3 py-3"><p className="font-bold text-dark-text">{reservation.lotProduct}</p><p className="mt-0.5 font-mono text-[10px] font-semibold text-dark-muted">{reservation.materialKey}</p></td><td className="px-3 py-3 text-right font-bold text-brand-primary">{formatQuantity(reservation.quantity)} {reservation.lotUnit}</td><td className="px-3 py-3 text-right font-semibold text-dark-muted">{formatQuantity(reservation.consumedQuantity)} {reservation.lotUnit}</td></tr>)}</tbody></table></div>
|
<div className="mt-3 overflow-hidden rounded-xl border border-dark-border"><table className="w-full text-xs"><thead className="bg-dark-header text-dark-muted"><tr><th className="px-3 py-2.5 text-left font-bold">Lote</th><th className="px-3 py-2.5 text-left font-bold">Material</th><th className="px-3 py-2.5 text-right font-bold">Reservado</th><th className="px-3 py-2.5 text-right font-bold">Consumido</th></tr></thead><tbody className="divide-y divide-dark-border">{order.lotReservations.map(reservation => <tr key={reservation.id}><td className="px-3 py-3"><p className="font-bold text-dark-text">#{reservation.lotId}</p><p className="mt-0.5 text-[10px] font-semibold text-dark-muted">{reservation.lotSupplier || 'Sem fornecedor'}</p></td><td className="px-3 py-3"><p className="font-bold text-dark-text">{reservation.lotProduct}</p><p className="mt-0.5 font-mono text-[10px] font-semibold text-dark-muted">{reservation.materialKey}</p></td><td className="px-3 py-3 text-right font-bold text-brand-primary">{formatQuantity(reservation.quantity)} {reservation.lotUnit}</td><td className="px-3 py-3 text-right font-semibold text-dark-muted">{formatQuantity(reservation.consumedQuantity)} {reservation.lotUnit}</td></tr>)}</tbody></table></div>
|
||||||
</section>}
|
</section>}
|
||||||
|
|
||||||
<section>
|
{order.status === 'cutting' && <section>
|
||||||
<h3 className="text-xs font-bold uppercase tracking-widest text-dark-muted">Baixa de material</h3>
|
<h3 className="text-xs font-bold uppercase tracking-widest text-dark-muted">Baixa de material</h3>
|
||||||
{order.status === 'cutting' ? <form onSubmit={handleProductionExit} className="mt-3 rounded-xl border border-dark-border bg-dark-card p-4"><p className="text-sm font-semibold text-dark-muted">Registre uma saída somente quando o material for realmente consumido pela OP.</p><div className="mt-4 grid grid-cols-1 gap-3 sm:grid-cols-2"><label className="text-xs font-bold text-dark-muted">Lote de material<select value={exitForm.lotId} onChange={(event) => setExitForm(current => ({ ...current, lotId: event.target.value }))} className="mt-1.5 h-11 w-full rounded-lg border border-dark-border bg-dark-input px-3 text-sm font-semibold text-dark-text outline-none focus:border-brand-primary"><option value="">Selecione...</option>{supplyLots.map(lot => <option key={lot.id} value={lot.id}>#{lot.id} · {lot.product} · {formatQuantity(lot.quantity)} {lot.unit}</option>)}</select></label><label className="text-xs font-bold text-dark-muted">Quantidade<input inputMode="decimal" value={exitForm.quantity} onChange={(event) => setExitForm(current => ({ ...current, quantity: event.target.value }))} className="mt-1.5 h-11 w-full rounded-lg border border-dark-border bg-dark-input px-3 text-sm font-semibold text-dark-text outline-none placeholder:text-dark-muted focus:border-brand-primary" placeholder="kg" /></label><label className="sm:col-span-2 text-xs font-bold text-dark-muted">Motivo (opcional)<input value={exitForm.reason} onChange={(event) => setExitForm(current => ({ ...current, reason: event.target.value }))} className="mt-1.5 h-11 w-full rounded-lg border border-dark-border bg-dark-input px-3 text-sm font-semibold text-dark-text outline-none placeholder:text-dark-muted focus:border-brand-primary" placeholder="Ex.: corte do pedido" /></label></div>{selectedLot && <p className="mt-3 text-xs font-bold text-dark-muted">Saldo do lote #{selectedLot.id}: {formatQuantity(selectedLot.quantity)} {selectedLot.unit}</p>}<button type="submit" disabled={isSupplyBusy || !supplyLots.length || !exitForm.lotId || !exitForm.quantity} className="mt-4 inline-flex h-11 items-center gap-2 rounded-lg border border-brand-primary/35 bg-brand-primary/10 px-4 text-sm font-bold text-brand-primary transition-colors hover:bg-brand-primary/20 disabled:cursor-not-allowed disabled:opacity-50"><PackageCheck className="h-4 w-4" />{isSupplyBusy ? 'Registrando...' : 'Registrar baixa'}</button>{supplyMessage && <p className="mt-3 rounded-lg border border-dark-border bg-dark-input px-3 py-2 text-sm font-bold text-dark-muted">{supplyMessage}</p>}</form> : <div className="mt-3 rounded-xl border border-dark-border bg-dark-card px-4 py-3 text-sm font-semibold text-dark-muted">Altere a OP para <span className="font-bold text-amber-200">Em corte</span> antes de registrar a baixa de materiais.</div>}
|
{order.status === 'cutting' ? <form onSubmit={handleProductionExit} className="mt-3 rounded-xl border border-dark-border bg-dark-card p-4"><p className="text-sm font-semibold text-dark-muted">Registre uma saída somente quando o material for realmente consumido pela OP.</p><div className="mt-4 grid grid-cols-1 gap-3 sm:grid-cols-2"><label className="text-xs font-bold text-dark-muted">Lote de material<select value={exitForm.lotId} onChange={(event) => setExitForm(current => ({ ...current, lotId: event.target.value }))} className="mt-1.5 h-11 w-full rounded-lg border border-dark-border bg-dark-input px-3 text-sm font-semibold text-dark-text outline-none focus:border-brand-primary"><option value="">Selecione...</option>{supplyLots.map(lot => <option key={lot.id} value={lot.id}>#{lot.id} · {lot.product} · {formatQuantity(lot.quantity)} {lot.unit}</option>)}</select></label><label className="text-xs font-bold text-dark-muted">Quantidade<input inputMode="decimal" value={exitForm.quantity} onChange={(event) => setExitForm(current => ({ ...current, quantity: event.target.value }))} className="mt-1.5 h-11 w-full rounded-lg border border-dark-border bg-dark-input px-3 text-sm font-semibold text-dark-text outline-none placeholder:text-dark-muted focus:border-brand-primary" placeholder="kg" /></label><label className="sm:col-span-2 text-xs font-bold text-dark-muted">Motivo (opcional)<input value={exitForm.reason} onChange={(event) => setExitForm(current => ({ ...current, reason: event.target.value }))} className="mt-1.5 h-11 w-full rounded-lg border border-dark-border bg-dark-input px-3 text-sm font-semibold text-dark-text outline-none placeholder:text-dark-muted focus:border-brand-primary" placeholder="Ex.: corte do pedido" /></label></div>{selectedLot && <p className="mt-3 text-xs font-bold text-dark-muted">Saldo do lote #{selectedLot.id}: {formatQuantity(selectedLot.quantity)} {selectedLot.unit}</p>}<button type="submit" disabled={isSupplyBusy || !supplyLots.length || !exitForm.lotId || !exitForm.quantity} className="mt-4 inline-flex h-11 items-center gap-2 rounded-lg border border-brand-primary/35 bg-brand-primary/10 px-4 text-sm font-bold text-brand-primary transition-colors hover:bg-brand-primary/20 disabled:cursor-not-allowed disabled:opacity-50"><PackageCheck className="h-4 w-4" />{isSupplyBusy ? 'Registrando...' : 'Registrar baixa'}</button>{supplyMessage && <p className="mt-3 rounded-lg border border-dark-border bg-dark-input px-3 py-2 text-sm font-bold text-dark-muted">{supplyMessage}</p>}</form> : <div className="mt-3 rounded-xl border border-dark-border bg-dark-card px-4 py-3 text-sm font-semibold text-dark-muted">Altere a OP para <span className="font-bold text-amber-200">Em corte</span> antes de registrar a baixa de materiais.</div>}
|
||||||
</section>
|
</section>}
|
||||||
|
|
||||||
{(order.steps.length > 0 || order.notes || order.supplier || order.lotCode || order.rollQuantity || order.fabricKg || order.ribKg || order.yieldPiecesPerKg) && <section><h3 className="text-xs font-bold uppercase tracking-widest text-dark-muted">Informações adicionais</h3><div className="mt-3 grid grid-cols-1 gap-3 sm:grid-cols-2">{(order.supplier || order.lotCode || order.rollQuantity || order.fabricKg || order.ribKg || order.yieldPiecesPerKg) && <dl className="grid grid-cols-2 gap-3 rounded-xl border border-dark-border bg-dark-card p-4 text-xs sm:col-span-2">{order.supplier && <div><dt className="font-bold text-dark-muted">Fornecedor</dt><dd className="mt-1 font-bold text-dark-text">{order.supplier}</dd></div>}{order.lotCode && <div><dt className="font-bold text-dark-muted">Lote</dt><dd className="mt-1 font-bold text-dark-text">{order.lotCode}</dd></div>}{order.rollQuantity !== null && <div><dt className="font-bold text-dark-muted">Rolos</dt><dd className="mt-1 font-bold text-dark-text">{formatQuantity(order.rollQuantity)}</dd></div>}{order.fabricKg !== null && <div><dt className="font-bold text-dark-muted">Malha</dt><dd className="mt-1 font-bold text-dark-text">{formatQuantity(order.fabricKg)} kg</dd></div>}{order.ribKg !== null && <div><dt className="font-bold text-dark-muted">Ribana</dt><dd className="mt-1 font-bold text-dark-text">{formatQuantity(order.ribKg)} kg</dd></div>}{order.yieldPiecesPerKg !== null && <div><dt className="font-bold text-dark-muted">Rendimento</dt><dd className="mt-1 font-bold text-dark-text">{formatQuantity(order.yieldPiecesPerKg)} un./kg</dd></div>}</dl>}{order.steps.length > 0 && <div className="rounded-xl border border-dark-border bg-dark-card p-4 sm:col-span-2"><p className="text-xs font-bold text-dark-text">Etapas</p><div className="mt-3 divide-y divide-dark-border">{order.steps.map(step => <div key={step.id} className="flex items-center gap-3 py-2 text-xs"><span className="h-2.5 w-2.5 rounded-full" style={{ backgroundColor: step.color || 'var(--color-brand-primary)' }} /><span className="font-bold text-dark-text">{step.name}</span><span className="ml-auto font-semibold text-dark-muted">{formatDate(step.startDate)} - {formatDate(step.endDate)}</span></div>)}</div></div>}{order.notes && <p className="whitespace-pre-wrap rounded-xl border border-dark-border bg-dark-card p-4 text-sm font-semibold text-dark-muted sm:col-span-2">{order.notes}</p>}</div></section>}
|
{(order.steps.length > 0 || order.notes || order.supplier || order.lotCode || order.rollQuantity || order.fabricKg || order.ribKg || order.yieldPiecesPerKg) && <section><h3 className="text-xs font-bold uppercase tracking-widest text-dark-muted">Informações adicionais</h3><div className="mt-3 grid grid-cols-1 gap-3 sm:grid-cols-2">{(order.supplier || order.lotCode || order.rollQuantity || order.fabricKg || order.ribKg || order.yieldPiecesPerKg) && <dl className="grid grid-cols-2 gap-3 rounded-xl border border-dark-border bg-dark-card p-4 text-xs sm:col-span-2">{order.supplier && <div><dt className="font-bold text-dark-muted">Fornecedor</dt><dd className="mt-1 font-bold text-dark-text">{order.supplier}</dd></div>}{order.lotCode && <div><dt className="font-bold text-dark-muted">Lote</dt><dd className="mt-1 font-bold text-dark-text">{order.lotCode}</dd></div>}{order.rollQuantity !== null && <div><dt className="font-bold text-dark-muted">Rolos</dt><dd className="mt-1 font-bold text-dark-text">{formatQuantity(order.rollQuantity)}</dd></div>}{order.fabricKg !== null && <div><dt className="font-bold text-dark-muted">Malha</dt><dd className="mt-1 font-bold text-dark-text">{formatQuantity(order.fabricKg)} kg</dd></div>}{order.ribKg !== null && <div><dt className="font-bold text-dark-muted">Ribana</dt><dd className="mt-1 font-bold text-dark-text">{formatQuantity(order.ribKg)} kg</dd></div>}{order.yieldPiecesPerKg !== null && <div><dt className="font-bold text-dark-muted">Rendimento</dt><dd className="mt-1 font-bold text-dark-text">{formatQuantity(order.yieldPiecesPerKg)} un./kg</dd></div>}</dl>}{order.steps.length > 0 && <div className="rounded-xl border border-dark-border bg-dark-card p-4 sm:col-span-2"><p className="text-xs font-bold text-dark-text">Etapas</p><div className="mt-3 divide-y divide-dark-border">{order.steps.map(step => <div key={step.id} className="flex items-center gap-3 py-2 text-xs"><span className="h-2.5 w-2.5 rounded-full" style={{ backgroundColor: step.color || 'var(--color-brand-primary)' }} /><span className="font-bold text-dark-text">{step.name}</span><span className="ml-auto font-semibold text-dark-muted">{formatDate(step.startDate)} - {formatDate(step.endDate)}</span></div>)}</div></div>}{order.notes && <p className="whitespace-pre-wrap rounded-xl border border-dark-border bg-dark-card p-4 text-sm font-semibold text-dark-muted sm:col-span-2">{order.notes}</p>}</div></section>}
|
||||||
</div>
|
</div>
|
||||||
@@ -480,7 +494,7 @@ const ProductionOrders = () => {
|
|||||||
Suprimentos
|
Suprimentos
|
||||||
</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 a fila de produção e o histórico das ordens.</p>
|
||||||
<div className="mt-3 flex flex-wrap gap-x-4 gap-y-2"><Link to="/production-plans" className="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><Link to="/production-quality" className="inline-flex items-center gap-2 text-sm font-bold text-brand-primary transition-colors hover:text-brand-primary/80"><AlertTriangle className="h-4 w-4" />Ver qualidade</Link></div>
|
<div className="mt-3 flex flex-wrap gap-x-4 gap-y-2"><Link to="/production-plans" className="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><Link to="/production-quality" className="inline-flex items-center gap-2 text-sm font-bold text-brand-primary transition-colors hover:text-brand-primary/80"><AlertTriangle className="h-4 w-4" />Ver qualidade</Link></div>
|
||||||
</div>
|
</div>
|
||||||
<DateRangePicker
|
<DateRangePicker
|
||||||
@@ -543,7 +557,7 @@ const ProductionOrders = () => {
|
|||||||
<div>
|
<div>
|
||||||
<p className="text-xs font-bold uppercase tracking-widest text-dark-muted">Finalizadas</p>
|
<p className="text-xs font-bold uppercase tracking-widest text-dark-muted">Finalizadas</p>
|
||||||
<p className="mt-2 text-3xl font-bold text-emerald-300">{finishedCount}</p>
|
<p className="mt-2 text-3xl font-bold text-emerald-300">{finishedCount}</p>
|
||||||
<p className="mt-1 text-xs font-semibold text-dark-muted">{formatQuantity(totalQuantity)} un. no período</p>
|
<p className="mt-1 text-xs font-semibold text-dark-muted">{formatQuantity(finishedQuantity)} un. aprovadas</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="rounded-xl border border-emerald-400/25 bg-emerald-400/10 p-3 text-emerald-300">
|
<div className="rounded-xl border border-emerald-400/25 bg-emerald-400/10 p-3 text-emerald-300">
|
||||||
<CheckCircle2 className="h-5 w-5" />
|
<CheckCircle2 className="h-5 w-5" />
|
||||||
@@ -660,8 +674,7 @@ const ProductionOrders = () => {
|
|||||||
{paginatedOrders.map((order: ProductionOrderItem) => {
|
{paginatedOrders.map((order: ProductionOrderItem) => {
|
||||||
const statusStyle = getStatusStyle(order.status, order.statusLabel);
|
const statusStyle = getStatusStyle(order.status, order.statusLabel);
|
||||||
return (
|
return (
|
||||||
<Fragment key={order.id}>
|
<tr key={order.id} className="transition-colors hover:bg-dark-input/50">
|
||||||
<tr className="transition-colors hover:bg-dark-input/50">
|
|
||||||
<td className="px-6 py-3 font-mono text-xs font-bold text-dark-text">{order.number || '-'}</td>
|
<td className="px-6 py-3 font-mono text-xs font-bold text-dark-text">{order.number || '-'}</td>
|
||||||
<td className="px-6 py-3">
|
<td className="px-6 py-3">
|
||||||
<div className="font-bold text-dark-text">{order.productDescription}</div>
|
<div className="font-bold text-dark-text">{order.productDescription}</div>
|
||||||
@@ -685,22 +698,20 @@ const ProductionOrders = () => {
|
|||||||
<td className="px-6 py-3 text-right">
|
<td className="px-6 py-3 text-right">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setSelectedOrderId(current => current === order.id ? null : order.id);
|
setSelectedOrderId(order.id);
|
||||||
setSupplyMessage('');
|
setSupplyMessage('');
|
||||||
setExitForm({ lotId: '', quantity: '', reason: '' });
|
setExitForm({ lotId: '', quantity: '', reason: '' });
|
||||||
}}
|
}}
|
||||||
title={selectedOrderId === order.id ? 'Fechar detalhes da OP' : 'Abrir detalhes da OP'}
|
title="Abrir ficha da OP"
|
||||||
aria-label={`${selectedOrderId === order.id ? 'Fechar' : 'Abrir'} detalhes da OP ${order.number || order.id}`}
|
aria-label={`Abrir ficha da OP ${order.number || order.id}`}
|
||||||
aria-expanded={selectedOrderId === order.id}
|
className="inline-flex h-9 items-center justify-center gap-2 rounded-lg border border-dark-border bg-dark-input px-3 text-xs font-bold text-dark-muted transition-colors hover:border-brand-primary hover:text-brand-primary cursor-pointer"
|
||||||
className={`inline-flex h-9 w-9 cursor-pointer items-center justify-center rounded-lg border transition-colors hover:border-brand-primary hover:text-brand-primary ${selectedOrderId === order.id ? 'border-brand-primary bg-brand-primary/10 text-brand-primary' : 'border-dark-border bg-dark-input text-dark-muted'}`}
|
|
||||||
>
|
>
|
||||||
{selectedOrderId === order.id ? <ChevronUp className="h-4 w-4" /> : <ChevronDown className="h-4 w-4" />}
|
Abrir OP
|
||||||
|
<ChevronDown className="h-4 w-4" />
|
||||||
</button>
|
</button>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
{selectedOrderId === order.id && <tr className="bg-dark-input/20"><td colSpan={5} className="px-6 pb-5 pt-3">{renderOrderDetails(order)}</td></tr>}
|
|
||||||
</Fragment>
|
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</tbody>
|
</tbody>
|
||||||
@@ -719,7 +730,7 @@ const ProductionOrders = () => {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<PaginationControls
|
{totalPages > 1 && <PaginationControls
|
||||||
totalItems={filteredOrders.length}
|
totalItems={filteredOrders.length}
|
||||||
currentPage={safeCurrentPage}
|
currentPage={safeCurrentPage}
|
||||||
totalPages={totalPages}
|
totalPages={totalPages}
|
||||||
@@ -734,13 +745,28 @@ const ProductionOrders = () => {
|
|||||||
setItemsPerPage(pageSize);
|
setItemsPerPage(pageSize);
|
||||||
setCurrentPage(1);
|
setCurrentPage(1);
|
||||||
}}
|
}}
|
||||||
/>
|
/>}
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{selectedOrder && <div className="fixed inset-0 z-50 flex items-center justify-center bg-black/70 p-4 backdrop-blur-sm" role="dialog" aria-modal="true" aria-label={`Ficha da OP ${selectedOrder.number || selectedOrder.id}`} onClick={() => busyStatusOrderId !== selectedOrder.id && setSelectedOrderId(null)}>
|
||||||
|
<section className="flex max-h-[calc(100vh-2rem)] w-full max-w-5xl flex-col overflow-hidden rounded-2xl border border-dark-border bg-dark-card shadow-2xl" onClick={(event) => event.stopPropagation()}>
|
||||||
|
<header className="flex items-start justify-between gap-4 border-b border-dark-border px-5 py-4">
|
||||||
|
<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">{selectedOrder.number || `OP #${selectedOrder.id}`}</span><span className={`inline-flex items-center gap-2 rounded-full border px-2.5 py-1 text-[10px] font-bold ${getStatusStyle(selectedOrder.status, selectedOrder.statusLabel).className}`}><span className={`h-1.5 w-1.5 rounded-full ${getStatusStyle(selectedOrder.status, selectedOrder.statusLabel).dotClass}`} />{getStatusStyle(selectedOrder.status, selectedOrder.statusLabel).label}</span></div><h2 className="mt-2 truncate text-lg font-bold text-dark-text" title={selectedOrder.productDescription}>{selectedOrder.productDescription}</h2><div className="mt-1 flex flex-wrap items-center gap-x-3 gap-y-1 text-xs font-semibold text-dark-muted"><span className="font-mono">{selectedOrder.productSku || 'Sem SKU'}</span>{selectedOrder.productionPlanNumber && <Link to="/production-plans" className="text-brand-primary hover:underline">{selectedOrder.productionPlanNumber}</Link>}<span>Previsão: {formatDate(selectedOrder.expectedDate)}</span></div></div>
|
||||||
|
<div className="flex shrink-0 items-center gap-2"><button type="button" onClick={() => printProductionSheet(selectedOrder)} title="Imprimir ficha" aria-label="Imprimir ficha" className="inline-flex h-9 w-9 items-center justify-center rounded-xl border border-dark-border bg-dark-input text-dark-muted transition-colors hover:border-brand-primary hover:text-brand-primary cursor-pointer"><Printer className="h-4 w-4" /></button><button type="button" onClick={() => setSelectedOrderId(null)} disabled={busyStatusOrderId === selectedOrder.id} title="Fechar" aria-label="Fechar ficha da OP" className="inline-flex h-9 w-9 items-center justify-center rounded-xl border border-dark-border bg-dark-input text-dark-muted transition-colors hover:border-brand-primary hover:text-dark-text disabled:cursor-not-allowed disabled:opacity-60 cursor-pointer"><X className="h-4 w-4" /></button></div>
|
||||||
|
</header>
|
||||||
|
<div className="overflow-y-auto p-5">
|
||||||
|
{statusMessage && <div className="mb-4 rounded-xl border border-dark-border bg-dark-input px-3 py-2 text-sm font-bold text-dark-muted">{statusMessage}</div>}
|
||||||
|
{selectedOrderNextStage || selectedOrderPreviousStage ? <section className="flex flex-col gap-3 rounded-xl border border-brand-primary/25 bg-brand-primary/[0.055] p-4 sm:flex-row sm:items-center sm:justify-between"><div><p className="text-[10px] font-bold uppercase tracking-wider text-brand-primary">Fluxo de produção</p><p className="mt-1 text-sm font-bold text-dark-text">{selectedOrderNextStage?.label || selectedOrderPreviousStage?.label}</p><p className="mt-1 text-xs font-semibold text-dark-muted">{selectedOrderNextStage?.description || selectedOrderPreviousStage?.description}</p></div><div className="flex shrink-0 flex-wrap gap-2"><button type="button" onClick={() => selectedOrderPreviousStage && void handleStatusChange(selectedOrder, selectedOrderPreviousStage.status)} disabled={!selectedOrderPreviousStage || busyStatusOrderId === selectedOrder.id} className="inline-flex h-10 items-center justify-center gap-2 rounded-xl border border-dark-border bg-dark-input px-4 text-sm font-bold text-dark-text transition-colors hover:border-brand-primary disabled:cursor-not-allowed disabled:opacity-60 cursor-pointer"><Undo2 className="h-4 w-4" />{busyStatusOrderId === selectedOrder.id ? 'Atualizando...' : selectedOrderPreviousStage?.label || 'Sem etapa anterior'}</button>{selectedOrderNextStage && <button type="button" onClick={() => void handleStatusChange(selectedOrder, selectedOrderNextStage.status)} disabled={busyStatusOrderId === selectedOrder.id} className="inline-flex h-10 items-center justify-center gap-2 rounded-xl bg-brand-primary px-4 text-sm font-bold text-brand-contrast transition-colors hover:bg-brand-primary/90 disabled:cursor-not-allowed disabled:opacity-60 cursor-pointer">{busyStatusOrderId === selectedOrder.id ? 'Atualizando...' : selectedOrderNextStage.label}<ChevronDown className="h-4 w-4 -rotate-90" /></button>}</div></section> : <section className="rounded-xl border border-dark-border bg-dark-input/45 px-4 py-3 text-sm font-semibold text-dark-muted">Esta OP foi cancelada e não possui próxima etapa operacional.</section>}
|
||||||
|
<div className="mt-5">{renderOrderDetails(selectedOrder)}</div>
|
||||||
|
</div>
|
||||||
|
<footer className="flex flex-col-reverse gap-2 border-t border-dark-border px-5 py-4 sm:flex-row sm:items-center sm:justify-between"><div>{selectedOrderCanReturnToCutPlan && <button type="button" onClick={() => void returnOrderToCutPlan(selectedOrder)} disabled={busyStatusOrderId === selectedOrder.id} className="inline-flex h-10 items-center gap-2 rounded-xl border border-dark-border bg-dark-input px-3 text-sm font-bold text-dark-muted transition-colors hover:border-amber-400/45 hover:text-amber-200 disabled:cursor-not-allowed disabled:opacity-60 cursor-pointer"><Undo2 className="h-4 w-4" />Devolver ao Plano de Corte</button>}</div><button type="button" onClick={() => setSelectedOrderId(null)} disabled={busyStatusOrderId === selectedOrder.id} className="h-10 rounded-xl border border-dark-border bg-dark-input px-4 text-sm font-bold text-dark-text transition-colors hover:border-brand-primary disabled:cursor-not-allowed disabled:opacity-60 cursor-pointer">Fechar</button></footer>
|
||||||
|
</section>
|
||||||
|
</div>}
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||||
import { Link } from 'react-router-dom';
|
import { Link } from 'react-router-dom';
|
||||||
import { ArrowLeft, Check, CheckCircle2, ChevronDown, ClipboardCheck, ClipboardList, Factory, Loader2, PlayCircle, UserRound, X, type LucideIcon } from 'lucide-react';
|
import { ArrowLeft, CalendarDays, Check, CheckCircle2, ChevronDown, ClipboardCheck, ClipboardList, Factory, Loader2, PlayCircle, RefreshCw, UserRound, X, type LucideIcon } from 'lucide-react';
|
||||||
import { fetchOperationalUsers, fetchProductionPlans, saveProductionPlanReview, updateProductionPlan, updateProductionPlanStatus } from '../dataService';
|
import { fetchOperationalUsers, fetchProductionPlans, saveProductionPlanReview, updateProductionPlan, updateProductionPlanStatus } from '../dataService';
|
||||||
import type { OperationalUser, ProductionOrderStatus, ProductionPlan, ProductionPlanReviewDecision } from '../types';
|
import type { OperationalUser, ProductionOrderStatus, ProductionPlan, ProductionPlanReviewDecision } from '../types';
|
||||||
|
|
||||||
@@ -13,6 +13,18 @@ const statusOptions: Array<{ value: ProductionOrderStatus; label: string; classN
|
|||||||
{ value: 'canceled', label: 'Cancelada', className: 'border-zinc-500/35 bg-zinc-500/10 text-zinc-400' }
|
{ value: 'canceled', label: 'Cancelada', className: 'border-zinc-500/35 bg-zinc-500/10 text-zinc-400' }
|
||||||
];
|
];
|
||||||
|
|
||||||
|
type ProductionPlanStatusTab = 'all' | 'open' | 'cutting' | 'sewing' | 'review' | 'finished' | 'canceled';
|
||||||
|
|
||||||
|
const statusTabs: Array<{ key: ProductionPlanStatusTab; label: string; dotClass: string }> = [
|
||||||
|
{ key: 'all', label: 'Todos', dotClass: 'bg-dark-muted' },
|
||||||
|
{ key: 'open', label: 'Em aberto', dotClass: 'bg-amber-400' },
|
||||||
|
{ key: 'cutting', label: 'Em corte', dotClass: 'bg-amber-300' },
|
||||||
|
{ key: 'sewing', label: 'Em costura', dotClass: 'bg-sky-400' },
|
||||||
|
{ key: 'review', label: 'Em revisão', dotClass: 'bg-violet-400' },
|
||||||
|
{ key: 'finished', label: 'Finalizada', dotClass: 'bg-emerald-400' },
|
||||||
|
{ key: 'canceled', label: 'Cancelada', dotClass: 'bg-zinc-500' }
|
||||||
|
];
|
||||||
|
|
||||||
const statusStyle = (status: string) => statusOptions.find(option => option.value === status)?.className || 'border-dark-border bg-dark-input text-dark-muted';
|
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 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 formatDate = (value: string | null) => value ? new Intl.DateTimeFormat('pt-BR').format(new Date(`${value.slice(0, 10)}T00:00:00`)) : '-';
|
||||||
@@ -25,10 +37,14 @@ const planMetrics: Array<{ label: string; value: string | number; icon: LucideIc
|
|||||||
const ProductionPlans = () => {
|
const ProductionPlans = () => {
|
||||||
const [plans, setPlans] = useState<ProductionPlan[]>([]);
|
const [plans, setPlans] = useState<ProductionPlan[]>([]);
|
||||||
const [isLoading, setIsLoading] = useState(true);
|
const [isLoading, setIsLoading] = useState(true);
|
||||||
|
const [isRefreshing, setIsRefreshing] = useState(false);
|
||||||
|
const [isLoadingOperationalUsers, setIsLoadingOperationalUsers] = useState(false);
|
||||||
const [busyPlanId, setBusyPlanId] = useState<number | null>(null);
|
const [busyPlanId, setBusyPlanId] = useState<number | null>(null);
|
||||||
const [operationalUsers, setOperationalUsers] = useState<OperationalUser[]>([]);
|
const [operationalUsers, setOperationalUsers] = useState<OperationalUser[]>([]);
|
||||||
const [responsiblePickerPlanId, setResponsiblePickerPlanId] = useState<number | null>(null);
|
const [responsiblePickerPlanId, setResponsiblePickerPlanId] = useState<number | null>(null);
|
||||||
const [reviewingPlanId, setReviewingPlanId] = useState<number | null>(null);
|
const [reviewingPlanId, setReviewingPlanId] = useState<number | null>(null);
|
||||||
|
const [isReviewUserPickerOpen, setIsReviewUserPickerOpen] = useState(false);
|
||||||
|
const [statusFilter, setStatusFilter] = useState<ProductionPlanStatusTab>('all');
|
||||||
const [reviewForm, setReviewForm] = useState({
|
const [reviewForm, setReviewForm] = useState({
|
||||||
decision: 'pending' as ProductionPlanReviewDecision,
|
decision: 'pending' as ProductionPlanReviewDecision,
|
||||||
materialsReserved: false,
|
materialsReserved: false,
|
||||||
@@ -52,7 +68,12 @@ const ProductionPlans = () => {
|
|||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const loadOperationalUsers = useCallback(async () => {
|
const loadOperationalUsers = useCallback(async () => {
|
||||||
setOperationalUsers(await fetchOperationalUsers());
|
setIsLoadingOperationalUsers(true);
|
||||||
|
try {
|
||||||
|
setOperationalUsers(await fetchOperationalUsers());
|
||||||
|
} finally {
|
||||||
|
setIsLoadingOperationalUsers(false);
|
||||||
|
}
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -60,8 +81,37 @@ const ProductionPlans = () => {
|
|||||||
return () => window.clearTimeout(timer);
|
return () => window.clearTimeout(timer);
|
||||||
}, [loadOperationalUsers, loadPlans]);
|
}, [loadOperationalUsers, loadPlans]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!message) return undefined;
|
||||||
|
const timer = window.setTimeout(() => setMessage(''), 5_000);
|
||||||
|
return () => window.clearTimeout(timer);
|
||||||
|
}, [message]);
|
||||||
|
|
||||||
const activePlans = useMemo(() => plans.filter(plan => !['finished', 'canceled'].includes(plan.status)), [plans]);
|
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 totalOpenQuantity = useMemo(() => activePlans.reduce((total, plan) => total + plan.totalQuantity, 0), [activePlans]);
|
||||||
|
const reviewingPlan = useMemo(() => plans.find(plan => plan.id === reviewingPlanId) || null, [plans, reviewingPlanId]);
|
||||||
|
const filteredPlans = useMemo(() => (
|
||||||
|
statusFilter === 'all' ? plans : plans.filter(plan => plan.status === statusFilter)
|
||||||
|
), [plans, statusFilter]);
|
||||||
|
|
||||||
|
const refreshPlans = async () => {
|
||||||
|
setIsRefreshing(true);
|
||||||
|
try {
|
||||||
|
await Promise.all([loadPlans(), loadOperationalUsers()]);
|
||||||
|
} finally {
|
||||||
|
setIsRefreshing(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const toggleResponsiblePicker = (planId: number) => {
|
||||||
|
setResponsiblePickerPlanId(current => current === planId ? null : planId);
|
||||||
|
if (!operationalUsers.length) void loadOperationalUsers();
|
||||||
|
};
|
||||||
|
|
||||||
|
const toggleReviewUserPicker = () => {
|
||||||
|
setIsReviewUserPickerOpen(current => !current);
|
||||||
|
if (!operationalUsers.length) void loadOperationalUsers();
|
||||||
|
};
|
||||||
|
|
||||||
const movePlan = async (plan: ProductionPlan, status: ProductionOrderStatus) => {
|
const movePlan = async (plan: ProductionPlan, status: ProductionOrderStatus) => {
|
||||||
if (status === plan.status) return;
|
if (status === plan.status) return;
|
||||||
@@ -102,6 +152,29 @@ const ProductionPlans = () => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const changeExpectedDate = async (plan: ProductionPlan, expectedDate: string) => {
|
||||||
|
setBusyPlanId(plan.id);
|
||||||
|
setMessage('');
|
||||||
|
try {
|
||||||
|
const updated = await updateProductionPlan(plan.id, {
|
||||||
|
name: plan.name,
|
||||||
|
contractor: plan.contractor,
|
||||||
|
responsibleUserId: plan.responsibleUserId,
|
||||||
|
responsibleName: plan.responsibleName,
|
||||||
|
plannedStartDate: plan.plannedStartDate,
|
||||||
|
expectedDate: expectedDate || null,
|
||||||
|
notes: plan.notes,
|
||||||
|
source: plan.source
|
||||||
|
});
|
||||||
|
setPlans(current => current.map(item => item.id === updated.id ? updated : item));
|
||||||
|
setMessage(expectedDate ? `Prazo de ${updated.number} atualizado.` : `Prazo de ${updated.number} removido.`);
|
||||||
|
} catch (error) {
|
||||||
|
setMessage(error instanceof Error ? error.message : 'Não foi possível atualizar o prazo.');
|
||||||
|
} finally {
|
||||||
|
setBusyPlanId(null);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const openReview = (plan: ProductionPlan) => {
|
const openReview = (plan: ProductionPlan) => {
|
||||||
const checklist = plan.review?.checklist || {};
|
const checklist = plan.review?.checklist || {};
|
||||||
setReviewForm({
|
setReviewForm({
|
||||||
@@ -114,6 +187,7 @@ const ProductionPlans = () => {
|
|||||||
reviewedBy: plan.review?.reviewedBy || plan.responsibleName || '',
|
reviewedBy: plan.review?.reviewedBy || plan.responsibleName || '',
|
||||||
});
|
});
|
||||||
setResponsiblePickerPlanId(null);
|
setResponsiblePickerPlanId(null);
|
||||||
|
setIsReviewUserPickerOpen(false);
|
||||||
setReviewingPlanId(plan.id);
|
setReviewingPlanId(plan.id);
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -150,14 +224,14 @@ const ProductionPlans = () => {
|
|||||||
: 'border-dark-border bg-dark-input text-dark-muted';
|
: 'border-dark-border bg-dark-input text-dark-muted';
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-5">
|
||||||
<div className="flex flex-col gap-4 lg:flex-row lg:items-end lg:justify-between">
|
<div className="flex flex-col gap-4 lg:flex-row lg:items-end lg:justify-between">
|
||||||
<div>
|
<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>
|
<Link to="/supplies" 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" />Suprimentos</Link>
|
||||||
<h1 className="text-2xl font-bold text-zinc-900 dark:text-dark-text">Planos de Produção</h1>
|
<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>
|
<p className="mt-2 max-w-3xl font-medium text-zinc-500 dark:text-dark-muted">Acompanhe cada frente de produção, o responsável e o estágio das OPs sem perder o histórico do plano.</p>
|
||||||
</div>
|
</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>
|
<button type="button" onClick={() => void refreshPlans()} disabled={isRefreshing} title="Atualizar planos" aria-label="Atualizar planos" className="inline-flex h-10 w-10 items-center justify-center rounded-xl border border-dark-border bg-dark-card text-dark-muted transition-colors hover:border-brand-primary hover:text-brand-primary disabled:cursor-not-allowed disabled:opacity-60 cursor-pointer"><RefreshCw className={`h-4 w-4 ${isRefreshing ? 'animate-spin' : ''}`} /></button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="grid grid-cols-1 gap-4 md:grid-cols-3">
|
<div className="grid grid-cols-1 gap-4 md:grid-cols-3">
|
||||||
@@ -168,31 +242,47 @@ const ProductionPlans = () => {
|
|||||||
? activePlans.reduce((total, plan) => total + plan.orderCount, 0)
|
? activePlans.reduce((total, plan) => total + plan.orderCount, 0)
|
||||||
: formatNumber(totalOpenQuantity);
|
: formatNumber(totalOpenQuantity);
|
||||||
const MetricIcon = metric.icon;
|
const MetricIcon = metric.icon;
|
||||||
return <div key={metric.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">{metric.label}</p><MetricIcon className={`h-5 w-5 ${metric.tone}`} /></div><p className="mt-3 text-3xl font-bold text-dark-text">{value}</p></div>;
|
const description = metric.label === 'Planos ativos' ? 'Frentes em acompanhamento' : metric.label === 'OPs planejadas' ? 'Ordens vinculadas aos planos ativos' : 'Quantidade ainda em operação';
|
||||||
|
return <div key={metric.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">{metric.label}</p><span className={`rounded-lg border border-dark-border bg-dark-input p-2 ${metric.tone}`}><MetricIcon className="h-4 w-4" /></span></div><p className="mt-3 text-3xl font-bold text-dark-text">{value}</p><p className="mt-1 text-xs font-semibold text-dark-muted">{description}</p></div>;
|
||||||
})}
|
})}
|
||||||
</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>}
|
{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="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>
|
<div className="border-b border-dark-border px-5 py-4"><div className="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between"><div><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 as OPs locais ativas. OPs finalizadas ou canceladas preservam o histórico.</p></div><span className="w-fit rounded-full border border-dark-border bg-dark-input px-2.5 py-1 text-xs font-bold text-dark-muted">{filteredPlans.length} {filteredPlans.length === 1 ? 'plano' : 'planos'}</span></div><div className="mt-4 flex gap-1 overflow-x-auto" role="tablist" aria-label="Status dos planos">{statusTabs.map(tab => { const count = tab.key === 'all' ? plans.length : plans.filter(plan => plan.status === tab.key).length; const isSelected = statusFilter === tab.key; return <button key={tab.key} type="button" role="tab" aria-selected={isSelected} onClick={() => setStatusFilter(tab.key)} className={`shrink-0 border-b-2 px-3 py-2 text-left text-xs font-bold transition-colors cursor-pointer ${isSelected ? 'border-brand-primary text-dark-text' : 'border-transparent text-dark-muted hover:text-dark-text'}`}><span className="flex items-center gap-2"><span className={`h-2 w-2 rounded-full ${tab.dotClass}`} />{tab.label}</span><span className="mt-1 block text-sm text-dark-text">{count}</span></button>; })}</div></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 ? (
|
{isLoading ? <div className="flex min-h-56 items-center justify-center text-brand-primary"><Loader2 className="h-7 w-7 animate-spin" /></div> : filteredPlans.length ? (
|
||||||
<div className="divide-y divide-dark-border">
|
<div className="divide-y divide-dark-border">
|
||||||
{plans.map(plan => (
|
{filteredPlans.map(plan => (
|
||||||
<div key={plan.id} className="p-5">
|
<div key={plan.id} className="p-5">
|
||||||
<div className="grid gap-4 xl:grid-cols-[minmax(240px,1.3fr)_minmax(150px,.7fr)_minmax(160px,.7fr)_minmax(190px,.75fr)_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>{plan.review && <span className={`rounded-full border px-2 py-1 text-[10px] font-bold ${reviewDecisionStyle(plan.review.decision)}`}>{reviewDecisionLabel[plan.review.decision]}</span>}</div><p className="mt-2 text-base font-bold text-dark-text">{plan.name}</p>{plan.notes && <p className="mt-1 max-w-3xl text-xs font-semibold leading-5 text-dark-muted">{plan.notes}</p>}</div>
|
||||||
<div className="min-w-0"><div className="flex flex-wrap items-center gap-2"><span className="font-mono text-xs font-bold text-brand-primary">{plan.number}</span><span className={`rounded-full border px-2.5 py-1 text-xs font-bold ${statusStyle(plan.status)}`}>{plan.statusLabel}</span>{plan.review && <span className={`rounded-full border px-2 py-1 text-[10px] font-bold ${reviewDecisionStyle(plan.review.decision)}`}>{reviewDecisionLabel[plan.review.decision]}</span>}</div><p className="mt-2 truncate font-bold text-dark-text" title={plan.name}>{plan.name}</p><p className="mt-1 text-xs font-semibold text-dark-muted">{plan.responsibleName ? `Responsável: ${plan.responsibleName}` : plan.contractor ? `Responsável: ${plan.contractor}` : 'Sem responsável definido'} · previsão {formatDate(plan.expectedDate)}</p>{plan.notes && <p className="mt-1 truncate text-xs font-semibold text-dark-muted" title={plan.notes}>{plan.notes}</p>}</div>
|
<div className="mt-4 grid gap-3 border-t border-dark-border pt-4 sm:grid-cols-2 xl:grid-cols-4">
|
||||||
<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="rounded-xl border border-dark-border bg-dark-input/45 p-3"><p className="text-[10px] font-bold uppercase tracking-wider text-dark-muted">Escopo</p><p className="mt-1 text-sm 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">Corte previsto pelo plano</p></div>
|
||||||
<div className="flex flex-wrap gap-1.5">{statusOptions.filter(option => Number(plan.statusCounts[option.value] || 0) > 0).map(option => <span key={option.value} className={`rounded-full border px-2 py-1 text-xs font-bold ${statusStyle(option.value)}`}>{plan.statusCounts[option.value]} {option.label.toLowerCase()}</span>)}</div>
|
<div className="rounded-xl border border-dark-border bg-dark-input/45 p-3"><p className="text-[10px] font-bold uppercase tracking-wider text-dark-muted">Andamento</p><p className="mt-1 text-sm font-bold text-dark-text">Cortado {formatNumber(plan.actualCutQuantity)} · final {formatNumber(plan.finishedQuantity)}</p><div className="mt-2 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-0.5 text-[10px] font-bold ${statusStyle(option.value)}`}>{plan.statusCounts[option.value]} {option.label.toLowerCase()}</span>)}</div></div>
|
||||||
<div className="relative text-xs font-bold text-dark-muted"><p>Responsável</p><button type="button" disabled={busyPlanId === plan.id} onClick={() => setResponsiblePickerPlanId(current => current === plan.id ? null : plan.id)} className="mt-1 flex h-10 w-full items-center justify-between gap-2 rounded-lg border border-dark-border bg-dark-input px-3 text-left text-sm font-semibold text-dark-text transition-colors hover:border-brand-primary disabled:cursor-not-allowed disabled:opacity-60 cursor-pointer"><span className="flex min-w-0 items-center gap-2"><UserRound className="h-4 w-4 shrink-0 text-brand-primary" /><span className="truncate">{plan.responsibleName || 'Selecionar responsável'}</span></span><ChevronDown className={`h-4 w-4 shrink-0 text-dark-muted transition-transform ${responsiblePickerPlanId === plan.id ? 'rotate-180' : ''}`} /></button>{responsiblePickerPlanId === plan.id && <div className="absolute right-0 z-20 mt-2 w-full min-w-[270px] overflow-hidden rounded-xl border border-dark-border bg-dark-card shadow-xl"><div className="border-b border-dark-border px-3 py-2 text-[10px] font-bold uppercase tracking-wider text-dark-muted">Usuários ativos do Graphs</div><div className="max-h-56 overflow-y-auto p-1.5">{operationalUsers.map(user => <button key={user.id ?? 'super-admin'} type="button" onClick={() => void changeResponsible(plan, user)} className="flex w-full items-center justify-between gap-3 rounded-lg px-2.5 py-2 text-left text-sm font-semibold text-dark-text transition-colors hover:bg-dark-input cursor-pointer"><span className="truncate">{user.name}</span>{plan.responsibleUserId === user.id && plan.responsibleName === user.name && <Check className="h-4 w-4 shrink-0 text-brand-primary" />}</button>)}{!operationalUsers.length && <p className="px-2 py-3 text-xs font-semibold text-dark-muted">Nenhum usuário ativo encontrado.</p>}</div>{plan.responsibleName && <button type="button" onClick={() => void changeResponsible(plan, null)} className="w-full border-t border-dark-border px-3 py-2 text-left text-xs font-bold text-dark-muted transition-colors hover:bg-dark-input hover:text-dark-text cursor-pointer">Remover responsável</button>}</div>}</div>
|
<div className="rounded-xl border border-dark-border bg-dark-input/45 p-3"><div className="flex items-center gap-1.5 text-[10px] font-bold uppercase tracking-wider text-dark-muted"><CalendarDays className="h-3.5 w-3.5 shrink-0" /><span>Prazo de conclusão</span></div><input type="date" defaultValue={plan.expectedDate || ''} disabled={busyPlanId === plan.id} onChange={(event) => void changeExpectedDate(plan, event.target.value)} className="mt-3 h-10 w-full rounded-lg border border-dark-border bg-dark-card px-3 text-sm font-bold text-dark-text outline-none transition-colors focus:border-brand-primary disabled:cursor-not-allowed disabled:opacity-60 cursor-pointer" aria-label={`Prazo de conclusão do plano ${plan.number}`} /><div className="mt-2 flex items-center justify-between gap-2 text-xs font-semibold text-dark-muted"><span>Início</span><span>{formatDate(plan.plannedStartDate)}</span></div></div>
|
||||||
<div className="flex gap-2 xl:flex-col"><button type="button" disabled={busyPlanId === plan.id} onClick={() => openReview(plan)} className="inline-flex h-10 flex-1 items-center justify-center gap-2 rounded-lg border border-dark-border bg-dark-input px-3 text-xs font-bold text-dark-text transition-colors hover:border-brand-primary disabled:opacity-60 cursor-pointer"><ClipboardCheck className="h-4 w-4 text-brand-primary" />Checklist</button><label className="flex-1 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 className="relative rounded-xl border border-dark-border bg-dark-input/45 p-3"><p className="text-[10px] font-bold uppercase tracking-wider text-dark-muted">Responsável</p><button type="button" disabled={busyPlanId === plan.id} onClick={() => toggleResponsiblePicker(plan.id)} aria-expanded={responsiblePickerPlanId === plan.id} className="mt-3 flex h-10 w-full items-center justify-between gap-2 rounded-lg border border-dark-border bg-dark-card px-3 text-left text-sm font-semibold text-dark-text transition-colors hover:border-brand-primary disabled:cursor-not-allowed disabled:opacity-60 cursor-pointer"><span className="flex min-w-0 items-center gap-2"><UserRound className="h-4 w-4 shrink-0 text-brand-primary" /><span className="truncate">{plan.responsibleName || 'Selecionar responsável'}</span></span><ChevronDown className={`h-4 w-4 shrink-0 text-dark-muted transition-transform ${responsiblePickerPlanId === plan.id ? 'rotate-180' : ''}`} /></button>{responsiblePickerPlanId === plan.id && <div className="absolute inset-x-3 top-[calc(100%-0.75rem)] z-30 mt-2 overflow-hidden rounded-xl border border-dark-border bg-dark-card shadow-2xl"><div className="border-b border-dark-border px-3 py-2 text-[10px] font-bold uppercase tracking-wider text-dark-muted">Usuários ativos do Graphs</div><div className="max-h-56 overflow-y-auto p-1.5">{isLoadingOperationalUsers ? <p className="flex items-center gap-2 px-2 py-3 text-xs font-semibold text-dark-muted"><Loader2 className="h-4 w-4 animate-spin text-brand-primary" />Carregando usuários...</p> : operationalUsers.length ? operationalUsers.map(user => <button key={user.id ?? 'super-admin'} type="button" disabled={busyPlanId === plan.id} onClick={() => void changeResponsible(plan, user)} className="flex w-full items-center justify-between gap-3 rounded-lg px-2.5 py-2 text-left text-sm font-semibold text-dark-text transition-colors hover:bg-dark-input disabled:opacity-60 cursor-pointer"><span className="truncate">{user.name}</span>{plan.responsibleUserId === user.id && plan.responsibleName === user.name && <Check className="h-4 w-4 shrink-0 text-brand-primary" />}</button>) : <p className="px-2 py-3 text-xs font-semibold text-dark-muted">Nenhum usuário ativo encontrado.</p>}</div>{plan.responsibleName && <button type="button" disabled={busyPlanId === plan.id} onClick={() => void changeResponsible(plan, null)} className="w-full border-t border-dark-border px-3 py-2 text-left text-xs font-bold text-dark-muted transition-colors hover:bg-dark-input hover:text-red-300 disabled:opacity-60 cursor-pointer">Remover responsável</button>}</div>}</div>
|
||||||
</div>
|
</div>
|
||||||
{reviewingPlanId === plan.id && <div className="mt-5 rounded-xl border border-brand-primary/25 bg-brand-primary/[0.045] p-4"><div className="flex items-start justify-between gap-4"><div><h3 className="text-sm font-bold text-dark-text">Liberação operacional</h3><p className="mt-1 text-xs font-semibold text-dark-muted">Registre a decisão antes de avançar o plano. Este registro não muda o estoque nem substitui o recebimento.</p></div><button type="button" onClick={() => setReviewingPlanId(null)} className="inline-flex h-8 w-8 items-center justify-center rounded-lg border border-dark-border bg-dark-input text-dark-muted hover:text-dark-text cursor-pointer" aria-label="Fechar checklist"><X className="h-4 w-4" /></button></div><div className="mt-4 grid gap-3 md:grid-cols-2 xl:grid-cols-4">{[{ key: 'materialsReserved', label: 'Materiais reservados' }, { key: 'cutSpecificationReady', label: 'Ficha e corte definidos' }, { key: 'sizeAndColorReviewed', label: 'Tamanhos e cores revisados' }, { key: 'responsibleAndDateDefined', label: 'Responsável e data definidos' }].map(item => <label key={item.key} className="flex items-center gap-2 rounded-lg border border-dark-border bg-dark-card px-3 py-2.5 text-xs font-bold text-dark-text"><input type="checkbox" checked={reviewForm[item.key as keyof Pick<typeof reviewForm, 'materialsReserved' | 'cutSpecificationReady' | 'sizeAndColorReviewed' | 'responsibleAndDateDefined'>]} onChange={(event) => setReviewForm(current => ({ ...current, [item.key]: event.target.checked }))} className="h-4 w-4 accent-brand-primary" />{item.label}</label>)}</div><div className="mt-4 grid gap-3 md:grid-cols-[180px_minmax(0,1fr)_minmax(0,1fr)_auto]"><label className="text-xs font-bold text-dark-muted">Decisão<select value={reviewForm.decision} onChange={(event) => setReviewForm(current => ({ ...current, decision: event.target.value as ProductionPlanReviewDecision }))} 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"><option value="pending">Em revisão</option><option value="approved">Liberar para operação</option><option value="adjustments_required">Solicitar ajustes</option></select></label><label className="text-xs font-bold text-dark-muted">Revisado por<input value={reviewForm.reviewedBy} onChange={(event) => setReviewForm(current => ({ ...current, reviewedBy: event.target.value }))} className="mt-1 h-10 w-full rounded-lg border border-dark-border bg-dark-input px-3 text-sm font-semibold text-dark-text outline-none focus:border-brand-primary" placeholder="Nome do usuário" /></label><label className="text-xs font-bold text-dark-muted">Observação<input value={reviewForm.notes} onChange={(event) => setReviewForm(current => ({ ...current, notes: event.target.value }))} className="mt-1 h-10 w-full rounded-lg border border-dark-border bg-dark-input px-3 text-sm font-semibold text-dark-text outline-none focus:border-brand-primary" placeholder="Ajuste, orientação ou motivo" /></label><button type="button" disabled={busyPlanId === plan.id} onClick={() => void submitReview(plan)} className="mt-5 inline-flex h-10 items-center justify-center gap-2 rounded-lg bg-brand-primary px-4 text-xs font-bold text-brand-contrast disabled:opacity-60 cursor-pointer"><Check className="h-4 w-4" />Salvar</button></div></div>}
|
<div className="mt-3 flex flex-col gap-3 rounded-xl border border-dark-border bg-dark-input/30 p-3 sm:flex-row sm:items-end sm:justify-between"><div><p className="text-xs font-bold text-dark-text">Ações do plano</p><p className="mt-0.5 text-xs font-semibold text-dark-muted">Revise a liberação antes de avançar a operação.</p></div><div className="flex flex-col gap-2 sm:flex-row sm:items-end"><button type="button" disabled={busyPlanId === plan.id} onClick={() => openReview(plan)} className="inline-flex h-10 items-center justify-center gap-2 rounded-lg border border-dark-border bg-dark-card px-3 text-xs font-bold text-dark-text transition-colors hover:border-brand-primary disabled:cursor-not-allowed disabled:opacity-60 cursor-pointer"><ClipboardCheck className="h-4 w-4 text-brand-primary" />Checklist</button><label className="min-w-[156px] text-[10px] font-bold uppercase tracking-wider text-dark-muted">Mover plano<select value={plan.status} disabled={busyPlanId === plan.id} onChange={(event) => void movePlan(plan, event.target.value as ProductionOrderStatus)} className="mt-1 h-10 w-full rounded-lg border border-dark-border bg-dark-card px-3 text-sm font-bold normal-case tracking-normal 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>
|
</div>
|
||||||
))}
|
))}
|
||||||
</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 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">{plans.length ? 'Nenhum plano neste status' : 'Nenhum plano confirmado'}</h3><p className="mt-1 text-sm font-semibold text-dark-muted">{plans.length ? 'Escolha outro status para visualizar os demais planos.' : 'Gere OPs a partir do Plano de Corte para criar o primeiro plano PCP.'}</p></div>}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{reviewingPlan && <div className="fixed inset-0 z-50 flex items-center justify-center bg-black/70 p-4 backdrop-blur-sm" role="dialog" aria-modal="true" aria-label={`Checklist do plano ${reviewingPlan.number}`} onClick={() => busyPlanId !== reviewingPlan.id && setReviewingPlanId(null)}>
|
||||||
|
<section className="flex max-h-[calc(100vh-2rem)] w-full max-w-3xl flex-col overflow-hidden rounded-2xl border border-dark-border bg-dark-card shadow-2xl" onClick={(event) => event.stopPropagation()}>
|
||||||
|
<header className="flex items-start justify-between gap-4 border-b border-dark-border px-5 py-4">
|
||||||
|
<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">{reviewingPlan.number}</span><span className={`rounded-full border px-2 py-1 text-[10px] font-bold ${statusStyle(reviewingPlan.status)}`}>{reviewingPlan.statusLabel}</span></div><h2 className="mt-2 text-lg font-bold text-dark-text">Checklist de liberação</h2><p className="mt-1 truncate text-sm font-semibold text-dark-muted" title={reviewingPlan.name}>{reviewingPlan.name}</p></div>
|
||||||
|
<button type="button" onClick={() => setReviewingPlanId(null)} disabled={busyPlanId === reviewingPlan.id} title="Fechar" aria-label="Fechar checklist" className="inline-flex h-9 w-9 shrink-0 items-center justify-center rounded-xl border border-dark-border bg-dark-input text-dark-muted transition-colors hover:border-brand-primary hover:text-dark-text disabled:cursor-not-allowed disabled:opacity-60 cursor-pointer"><X className="h-4 w-4" /></button>
|
||||||
|
</header>
|
||||||
|
<div className="overflow-y-auto p-5">
|
||||||
|
<p className="text-sm font-medium leading-6 text-dark-muted">Revise os pontos de liberação antes de avançar a operação. Este registro não movimenta estoque nem substitui o recebimento físico.</p>
|
||||||
|
<section className="mt-5"><h3 className="text-sm font-bold text-dark-text">Conferências</h3><div className="mt-3 grid gap-2 sm:grid-cols-2">{[{ key: 'materialsReserved', label: 'Materiais reservados' }, { key: 'cutSpecificationReady', label: 'Ficha e corte definidos' }, { key: 'sizeAndColorReviewed', label: 'Tamanhos e cores revisados' }, { key: 'responsibleAndDateDefined', label: 'Responsável e prazo definidos' }].map(item => <label key={item.key} className="flex min-h-12 items-center gap-3 rounded-xl border border-dark-border bg-dark-input/45 px-3 py-2.5 text-sm font-bold text-dark-text transition-colors hover:border-brand-primary/50 cursor-pointer"><input type="checkbox" checked={reviewForm[item.key as keyof Pick<typeof reviewForm, 'materialsReserved' | 'cutSpecificationReady' | 'sizeAndColorReviewed' | 'responsibleAndDateDefined'>]} onChange={(event) => setReviewForm(current => ({ ...current, [item.key]: event.target.checked }))} className="h-4 w-4 shrink-0 accent-brand-primary cursor-pointer" />{item.label}</label>)}</div></section>
|
||||||
|
<section className="mt-5 grid gap-4 sm:grid-cols-2"><label className="text-xs font-bold text-dark-muted">Decisão<select value={reviewForm.decision} onChange={(event) => setReviewForm(current => ({ ...current, decision: event.target.value as ProductionPlanReviewDecision }))} className="mt-1.5 h-10 w-full rounded-lg border border-dark-border bg-dark-input px-3 text-sm font-bold text-dark-text outline-none transition-colors focus:border-brand-primary cursor-pointer"><option value="pending">Em revisão</option><option value="approved">Liberar para operação</option><option value="adjustments_required">Solicitar ajustes</option></select></label><div className="relative text-xs font-bold text-dark-muted"><p>Revisado por</p><button type="button" onClick={toggleReviewUserPicker} aria-expanded={isReviewUserPickerOpen} className="mt-1.5 flex h-10 w-full items-center justify-between gap-2 rounded-lg border border-dark-border bg-dark-input px-3 text-left text-sm font-semibold text-dark-text outline-none transition-colors hover:border-brand-primary cursor-pointer"><span className="flex min-w-0 items-center gap-2"><UserRound className="h-4 w-4 shrink-0 text-brand-primary" /><span className="truncate">{reviewForm.reviewedBy || 'Selecionar usuário'}</span></span><ChevronDown className={`h-4 w-4 shrink-0 text-dark-muted transition-transform ${isReviewUserPickerOpen ? 'rotate-180' : ''}`} /></button>{isReviewUserPickerOpen && <div className="absolute inset-x-0 top-full z-20 mt-2 overflow-hidden rounded-xl border border-dark-border bg-dark-card shadow-2xl"><div className="border-b border-dark-border px-3 py-2 text-[10px] font-bold uppercase tracking-wider text-dark-muted">Usuários ativos do Graphs</div><div className="max-h-52 overflow-y-auto p-1.5">{isLoadingOperationalUsers ? <p className="flex items-center gap-2 px-2 py-3 text-xs font-semibold text-dark-muted"><Loader2 className="h-4 w-4 animate-spin text-brand-primary" />Carregando usuários...</p> : operationalUsers.length ? operationalUsers.map(user => <button key={user.id ?? 'super-admin'} type="button" onClick={() => { setReviewForm(current => ({ ...current, reviewedBy: user.name })); setIsReviewUserPickerOpen(false); }} className="flex w-full items-center justify-between gap-3 rounded-lg px-2.5 py-2 text-left text-sm font-semibold text-dark-text transition-colors hover:bg-dark-input cursor-pointer"><span className="truncate">{user.name}</span>{reviewForm.reviewedBy === user.name && <Check className="h-4 w-4 shrink-0 text-brand-primary" />}</button>) : <p className="px-2 py-3 text-xs font-semibold text-dark-muted">Nenhum usuário ativo encontrado.</p>}</div>{reviewForm.reviewedBy && <button type="button" onClick={() => { setReviewForm(current => ({ ...current, reviewedBy: '' })); setIsReviewUserPickerOpen(false); }} className="w-full border-t border-dark-border px-3 py-2 text-left text-xs font-bold text-dark-muted transition-colors hover:bg-dark-input hover:text-red-300 cursor-pointer">Remover revisor</button>}</div>}</div><label className="sm:col-span-2 text-xs font-bold text-dark-muted">Observação<input value={reviewForm.notes} onChange={(event) => setReviewForm(current => ({ ...current, notes: event.target.value }))} className="mt-1.5 h-10 w-full rounded-lg border border-dark-border bg-dark-input px-3 text-sm font-semibold text-dark-text outline-none transition-colors focus:border-brand-primary" placeholder="Ajuste, orientação ou motivo" /></label></section>
|
||||||
|
</div>
|
||||||
|
<footer className="flex flex-col-reverse gap-2 border-t border-dark-border px-5 py-4 sm:flex-row sm:items-center sm:justify-end"><button type="button" onClick={() => setReviewingPlanId(null)} disabled={busyPlanId === reviewingPlan.id} className="h-10 rounded-xl border border-dark-border bg-dark-input px-4 text-sm font-bold text-dark-text transition-colors hover:border-brand-primary disabled:cursor-not-allowed disabled:opacity-60 cursor-pointer">Cancelar</button><button type="button" disabled={busyPlanId === reviewingPlan.id} onClick={() => void submitReview(reviewingPlan)} className="inline-flex h-10 items-center justify-center gap-2 rounded-xl bg-brand-primary px-4 text-sm font-bold text-brand-contrast transition-colors hover:bg-brand-primary/90 disabled:cursor-not-allowed disabled:opacity-60 cursor-pointer"><Check className="h-4 w-4" />{busyPlanId === reviewingPlan.id ? 'Salvando...' : 'Salvar checklist'}</button></footer>
|
||||||
|
</section>
|
||||||
|
</div>}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ import {
|
|||||||
Save,
|
Save,
|
||||||
Search,
|
Search,
|
||||||
Scissors,
|
Scissors,
|
||||||
|
ShieldCheck,
|
||||||
Truck,
|
Truck,
|
||||||
Trash2,
|
Trash2,
|
||||||
Warehouse,
|
Warehouse,
|
||||||
@@ -400,10 +401,11 @@ const SuppliesHub = () => {
|
|||||||
<h2 className="text-base font-bold text-dark-text">Fluxo operacional</h2>
|
<h2 className="text-base font-bold text-dark-text">Fluxo operacional</h2>
|
||||||
<p className="mt-1 text-sm font-semibold text-dark-muted">Da necessidade de material até a produção acompanhada.</p>
|
<p className="mt-1 text-sm font-semibold text-dark-muted">Da necessidade de material até a produção acompanhada.</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="grid grid-cols-1 gap-4 xl:grid-cols-3">
|
<div className="grid grid-cols-1 gap-4 xl:grid-cols-4">
|
||||||
<FlowStepCard title="Garantir materiais" description="Confira saldo disponível, recebimentos e a compra sugerida." icon={BarChart3} to={materialPurchaseNeeds.length > 0 ? '/supplies/purchase-needs?tab=materials&filter=to_buy' : '/supplies/purchase-needs?tab=materials&filter=all'} tone={materialPurchaseNeeds.length > 0 ? 'attention' : 'ready'} meta={materialPurchaseNeeds.length > 0 ? `${formatNumber(materialPurchaseNeeds.length, 0)} materiais para comprar` : 'Materiais cobertos'} />
|
<FlowStepCard title="Garantir materiais" description="Confira saldo disponível, recebimentos e a compra sugerida." icon={BarChart3} to={materialPurchaseNeeds.length > 0 ? '/supplies/purchase-needs?tab=materials&filter=to_buy' : '/supplies/purchase-needs?tab=materials&filter=all'} tone={materialPurchaseNeeds.length > 0 ? 'attention' : 'ready'} meta={materialPurchaseNeeds.length > 0 ? `${formatNumber(materialPurchaseNeeds.length, 0)} materiais para comprar` : 'Materiais cobertos'} />
|
||||||
<FlowStepCard title="Definir corte" description="Priorize SKUs, cores e tamanhos com necessidade e material disponível." icon={Scissors} to="/cutting" tone="default" meta="Abrir plano de corte" />
|
<FlowStepCard title="Planejar corte" description="Priorize SKUs, cores e tamanhos com necessidade e material disponível." icon={Scissors} to="/cutting" tone="default" meta="Abrir plano de corte" />
|
||||||
<FlowStepCard title="Acompanhar produção" description="Gerencie as OPs criadas a partir do corte até sua finalização." icon={ClipboardList} to="/production-orders" tone={activeProductionCount > 0 ? 'ready' : 'default'} meta={activeProductionCount > 0 ? `${formatNumber(productionCounts.open, 0)} abertas · ${formatNumber(productionCounts.inProgress, 0)} em andamento` : 'Nenhuma OP em execução'} />
|
<FlowStepCard title="Planos de produção" description="Organize os cortes em planos, defina o responsável e acompanhe a liberação." icon={ClipboardCheck} to="/production-plans" tone="default" meta="Abrir planos de produção" />
|
||||||
|
<FlowStepCard title="Ordens de produção" description="Acompanhe as OPs criadas a partir do plano até sua finalização." icon={ClipboardList} to="/production-orders" tone={activeProductionCount > 0 ? 'ready' : 'default'} meta={activeProductionCount > 0 ? `${formatNumber(productionCounts.open, 0)} abertas · ${formatNumber(productionCounts.inProgress, 0)} em andamento` : 'Nenhuma OP em execução'} />
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
<section className="border-t border-dark-border pt-6">
|
<section className="border-t border-dark-border pt-6">
|
||||||
@@ -416,6 +418,7 @@ const SuppliesHub = () => {
|
|||||||
<ModuleCard title="Ordens de compra" description="Formalize pedidos de materiais, fornecedor, prazo e valor antes do recebimento." icon={ClipboardList} to="/supplies/purchase-orders" meta="Criar e acompanhar OCs" />
|
<ModuleCard title="Ordens de compra" description="Formalize pedidos de materiais, fornecedor, prazo e valor antes do recebimento." icon={ClipboardList} to="/supplies/purchase-orders" meta="Criar e acompanhar OCs" />
|
||||||
<ModuleCard title="Planejamento de malha" description="Organize a fila de matéria-prima para abastecer compras e corte." icon={Ruler} to="/supplies/fabric-planning" />
|
<ModuleCard title="Planejamento de malha" description="Organize a fila de matéria-prima para abastecer compras e corte." icon={Ruler} to="/supplies/fabric-planning" />
|
||||||
<ModuleCard title="Transformações e perdas" description="Planeje tecelagem, tinturaria, cores e perda esperada sem alterar o estoque físico." icon={Factory} to="/supplies/material-transformations" />
|
<ModuleCard title="Transformações e perdas" description="Planeje tecelagem, tinturaria, cores e perda esperada sem alterar o estoque físico." icon={Factory} to="/supplies/material-transformations" />
|
||||||
|
<ModuleCard title="Qualidade da malha" description="Acompanhe furos, perdas e origem dos defeitos por lote, posto e OP." icon={ShieldCheck} to="/production-quality" />
|
||||||
<ModuleCard title="Prioridades de estoque" description="Analise reposição, cobertura e itens que precisam de revisão de cadastro." icon={PackageSearch} to="/supplies/current-planning" />
|
<ModuleCard title="Prioridades de estoque" description="Analise reposição, cobertura e itens que precisam de revisão de cadastro." icon={PackageSearch} to="/supplies/current-planning" />
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
@@ -2783,7 +2786,7 @@ const MaterialTransformationsScreen = () => {
|
|||||||
<div className={`${panelClassName} p-5`}><p className="text-xs font-bold uppercase tracking-widest text-dark-muted">Saída esperada</p><p className="mt-2 text-3xl font-bold text-emerald-300">{formatNumber(expectedOutputKg)} kg</p><p className="mt-1 text-xs font-semibold text-dark-muted">após a perda prevista</p></div>
|
<div className={`${panelClassName} p-5`}><p className="text-xs font-bold uppercase tracking-widest text-dark-muted">Saída esperada</p><p className="mt-2 text-3xl font-bold text-emerald-300">{formatNumber(expectedOutputKg)} kg</p><p className="mt-1 text-xs font-semibold text-dark-muted">após a perda prevista</p></div>
|
||||||
<div className={`${panelClassName} p-5`}><p className="text-xs font-bold uppercase tracking-widest text-dark-muted">Perda prevista</p><p className="mt-2 text-3xl font-bold text-amber-300">{formatNumber(expectedLossKg)} kg</p><p className="mt-1 text-xs font-semibold text-dark-muted">a confirmar na execução e no recebimento</p></div>
|
<div className={`${panelClassName} p-5`}><p className="text-xs font-bold uppercase tracking-widest text-dark-muted">Perda prevista</p><p className="mt-2 text-3xl font-bold text-amber-300">{formatNumber(expectedLossKg)} kg</p><p className="mt-1 text-xs font-semibold text-dark-muted">a confirmar na execução e no recebimento</p></div>
|
||||||
</div>
|
</div>
|
||||||
<div className="grid grid-cols-1 items-start gap-6 xl:grid-cols-[minmax(0,1fr)_390px]">
|
<div className="grid grid-cols-1 items-start gap-6 xl:grid-cols-[minmax(0,1fr)_460px]">
|
||||||
<section className={`${panelClassName} overflow-hidden`}>
|
<section className={`${panelClassName} overflow-hidden`}>
|
||||||
<div className="border-b border-dark-border p-5"><h2 className="text-base font-bold text-dark-text">Fila de transformação</h2><p className="mt-1 text-sm font-semibold text-dark-muted">Cada linha registra uma etapa; use o mesmo lote para encadear tecelagem, tinturaria e acabamento.</p></div>
|
<div className="border-b border-dark-border p-5"><h2 className="text-base font-bold text-dark-text">Fila de transformação</h2><p className="mt-1 text-sm font-semibold text-dark-muted">Cada linha registra uma etapa; use o mesmo lote para encadear tecelagem, tinturaria e acabamento.</p></div>
|
||||||
{isLoading ? <div className={emptyStateClassName}><Factory className="h-8 w-8 text-brand-primary" /><h3 className="text-base font-bold text-dark-text">Carregando transformações...</h3></div>
|
{isLoading ? <div className={emptyStateClassName}><Factory className="h-8 w-8 text-brand-primary" /><h3 className="text-base font-bold text-dark-text">Carregando transformações...</h3></div>
|
||||||
|
|||||||
Reference in New Issue
Block a user