Compare commits

..

39 Commits

Author SHA1 Message Date
Cauê Faleiros
9d99f2387b perf: limit cut plan to active orders
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 53s
2026-08-28 13:14:21 -03:00
Cauê Faleiros
4122e21dc2 perf: defer cut yield recommendations
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 1m10s
2026-08-28 12:53:37 -03:00
Cauê Faleiros
c5edaec7d3 Reapply "feat: open administration to all users"
This reverts commit 9f4db67f4f.
2026-08-28 12:49:22 -03:00
Cauê Faleiros
9f4db67f4f Revert "feat: open administration to all users"
This reverts commit b4b21fb21b.
2026-08-28 12:47:13 -03:00
Cauê Faleiros
b4b21fb21b feat: open administration to all users
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 1m33s
2026-08-28 11:33:40 -03:00
Cauê Faleiros
ba136b9d78 fix: keep sessions on forbidden responses
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 1m32s
2026-08-28 11:07:39 -03:00
Cauê Faleiros
5a23817f4c Show synchronized composition in production orders 2026-08-28 09:39:01 -03:00
Cauê Faleiros
35f3938f1c Widen material transformation form 2026-08-28 09:36:05 -03:00
Cauê Faleiros
56ce81850b Add status tabs to production plans
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 48s
2026-08-28 09:26:02 -03:00
Cauê Faleiros
6b3f84a666 Allow production orders to return to prior stages
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 1m2s
2026-08-28 09:16:48 -03:00
Cauê Faleiros
bc6d53b840 Fix composition lookup by external product ID
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 2m1s
2026-08-28 08:22:58 -03:00
Cauê Faleiros
5e742feb60 Streamline production order workflow
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 44s
2026-08-27 20:30:15 -03:00
Cauê Faleiros
9592b2eb7d Remove contractor label from production plans
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 42s
2026-08-27 20:19:49 -03:00
Cauê Faleiros
314288147a Select checklist reviewer from users
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 40s
2026-08-27 20:15:54 -03:00
Cauê Faleiros
acf542f2d5 Paginate production plan confirmation
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 37s
2026-08-27 20:12:11 -03:00
Cauê Faleiros
7ae230ed2e Improve production plan interactions
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 41s
2026-08-27 20:07:58 -03:00
Cauê Faleiros
c77bc752ae Improve production order preview selection
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 42s
2026-08-27 20:02:51 -03:00
Cauê Faleiros
4b66e91442 Refine production plan actions and deadlines
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 46s
2026-08-27 19:53:50 -03:00
Cauê Faleiros
a938925904 Remove unsupported layer controls
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 45s
2026-08-27 19:49:57 -03:00
Cauê Faleiros
9960fb7986 Return production plans to supplies hub
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 46s
2026-08-27 19:43:09 -03:00
Cauê Faleiros
2b7c132300 Improve production plan operations 2026-08-27 19:38:08 -03:00
Cauê Faleiros
ef29365932 Fix cut plan confirmation cleanup
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 1m25s
2026-08-27 19:30:04 -03:00
Cauê Faleiros
64e5fefcb4 Move marker reference to production execution 2026-08-27 19:25:30 -03:00
Cauê Faleiros
d623688263 Add supplies workflow demonstration seed 2026-08-27 19:01:32 -03:00
Cauê Faleiros
e62793273b Add supplies operations guide 2026-08-27 15:39:41 -03:00
Cauê Faleiros
5d697f8f8a Consolidate production flow under supplies
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 1m43s
2026-08-27 14:16:45 -03:00
Cauê Faleiros
ed750ac4ce Organize quality under supplies 2026-08-27 13:43:17 -03:00
Cauê Faleiros
e8a684b0ab Link receipts to pending purchase approval
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 2m44s
2026-08-27 11:28:34 -03:00
Cauê Faleiros
458efa073d Add fabric hole quality indicator 2026-08-27 11:23:18 -03:00
Cauê Faleiros
ddff880215 Add pre-cut layer and marker planning 2026-08-27 11:22:12 -03:00
Cauê Faleiros
886969cce0 Record cut layers and marker references 2026-08-27 10:56:11 -03:00
Cauê Faleiros
791246c55e Add production plan release checklist 2026-08-27 10:51:41 -03:00
Cauê Faleiros
442b23dc62 Add material transformation planning 2026-08-27 10:44:49 -03:00
Cauê Faleiros
da6f3b2955 Add production quality and workstation screen 2026-08-26 16:34:25 -03:00
Cauê Faleiros
2c15719b13 Add production workstation management API 2026-08-26 16:30:36 -03:00
Cauê Faleiros
7e65feac37 Add production order defect capture 2026-08-26 16:28:59 -03:00
Cauê Faleiros
38add708ff Improve consumption reference search and pagination
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 51s
2026-08-26 16:03:52 -03:00
Cauê Faleiros
5f767a96b6 Balance consumption reference panels
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 1m2s
2026-08-26 15:32:00 -03:00
Cauê Faleiros
862e8e1d51 Widen consumption reference workspace
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 1m13s
2026-08-26 15:25:25 -03:00
24 changed files with 2217 additions and 212 deletions

View File

@@ -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`.

View File

@@ -189,6 +189,21 @@ const initDB = async () => {
); );
`); `);
// The release decision remains auditable when the plan changes status.
await pool.query(`
CREATE TABLE IF NOT EXISTS production_plan_reviews (
id SERIAL PRIMARY KEY,
production_plan_id INTEGER NOT NULL UNIQUE REFERENCES production_plans(id) ON DELETE CASCADE,
decision VARCHAR(30) NOT NULL DEFAULT 'pending',
checklist JSONB NOT NULL DEFAULT '{}'::jsonb,
notes TEXT,
reviewed_by TEXT,
reviewed_at TIMESTAMPTZ,
created_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP
);
`);
await pool.query(` await pool.query(`
ALTER TABLE production_orders ALTER TABLE production_orders
ADD COLUMN IF NOT EXISTS production_plan_id INTEGER REFERENCES production_plans(id) ON DELETE SET NULL, ADD COLUMN IF NOT EXISTS production_plan_id INTEGER REFERENCES production_plans(id) ON DELETE SET NULL,
@@ -559,6 +574,31 @@ const initDB = async () => {
); );
`); `);
// A transformation is a planning/execution record, not a stock movement.
// Physical stock remains controlled only by approved receipts and inventory
// adjustments, so a planned dyeing run can never inflate availability.
await pool.query(`
CREATE TABLE IF NOT EXISTS supply_material_transformations (
id SERIAL PRIMARY KEY,
batch_code VARCHAR(120),
stage VARCHAR(40) NOT NULL,
input_material TEXT NOT NULL,
input_quantity_kg NUMERIC(14, 4) NOT NULL CHECK (input_quantity_kg > 0),
output_material TEXT NOT NULL,
output_color VARCHAR(120),
expected_loss_percent NUMERIC(7, 4) NOT NULL DEFAULT 0 CHECK (expected_loss_percent >= 0 AND expected_loss_percent < 100),
expected_output_kg NUMERIC(14, 4) NOT NULL CHECK (expected_output_kg >= 0),
supplier TEXT,
priority VARCHAR(40) NOT NULL DEFAULT 'Normal',
status VARCHAR(30) NOT NULL DEFAULT 'planned',
notes TEXT,
created_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP
);
`);
await pool.query(`CREATE INDEX IF NOT EXISTS idx_supply_material_transformations_status ON supply_material_transformations (status, created_at DESC);`);
await pool.query(`CREATE INDEX IF NOT EXISTS idx_supply_material_transformations_batch ON supply_material_transformations (batch_code, created_at DESC);`);
await pool.query(` await pool.query(`
CREATE TABLE IF NOT EXISTS supply_purchase_orders ( CREATE TABLE IF NOT EXISTS supply_purchase_orders (
id SERIAL PRIMARY KEY, id SERIAL PRIMARY KEY,
@@ -586,6 +626,11 @@ const initDB = async () => {
await pool.query(`CREATE INDEX IF NOT EXISTS idx_supply_purchase_orders_status ON supply_purchase_orders (status, created_at DESC);`); await pool.query(`CREATE INDEX IF NOT EXISTS idx_supply_purchase_orders_status ON supply_purchase_orders (status, created_at DESC);`);
await pool.query(`
ALTER TABLE supply_receipts
ADD COLUMN IF NOT EXISTS purchase_order_id INTEGER REFERENCES supply_purchase_orders(id) ON DELETE SET NULL;
`).catch(() => {});
await pool.query(` await pool.query(`
ALTER TABLE production_orders ALTER TABLE production_orders
ADD COLUMN IF NOT EXISTS notes TEXT, ADD COLUMN IF NOT EXISTS notes TEXT,
@@ -607,7 +652,9 @@ const initDB = async () => {
ADD COLUMN IF NOT EXISTS actual_cut_quantity NUMERIC(14, 4), ADD COLUMN IF NOT EXISTS actual_cut_quantity NUMERIC(14, 4),
ADD COLUMN IF NOT EXISTS rejected_quantity NUMERIC(14, 4) NOT NULL DEFAULT 0, ADD COLUMN IF NOT EXISTS rejected_quantity NUMERIC(14, 4) NOT NULL DEFAULT 0,
ADD COLUMN IF NOT EXISTS finished_quantity NUMERIC(14, 4), ADD COLUMN IF NOT EXISTS finished_quantity NUMERIC(14, 4),
ADD COLUMN IF NOT EXISTS quality_notes TEXT; ADD COLUMN IF NOT EXISTS quality_notes TEXT,
ADD COLUMN IF NOT EXISTS cut_layers INTEGER,
ADD COLUMN IF NOT EXISTS marker_reference VARCHAR(160);
`).catch(() => {}); `).catch(() => {});
await pool.query(` await pool.query(`

View File

@@ -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": [],

View File

@@ -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,

View File

@@ -1,6 +1,6 @@
const express = require('express'); const express = require('express');
const { authenticateAPIKey, verifyToken } = require('../auth'); const { authenticateAPIKey, verifyToken } = require('../auth');
const { addProductionOrderDefect, createProductionOrders, importTinyProductCompositionExport, listProductionOrders, listProductionPlans, listProductionWorkstations, updateProductionOrderExecution, updateProductionOrderStatus, updateProductionPlan, updateProductionPlanStatus, upsertTinyProductionOrderDetail } = require('../services/productionOrderService'); const { addProductionOrderDefect, createProductionOrders, createProductionWorkstation, importTinyProductCompositionExport, listProductionOrders, listProductionPlans, listProductionWorkstations, saveProductionPlanReview, updateProductionOrderExecution, updateProductionOrderStatus, updateProductionPlan, updateProductionPlanStatus, updateProductionWorkstation, upsertTinyProductionOrderDetail } = require('../services/productionOrderService');
const router = express.Router(); const router = express.Router();
@@ -35,12 +35,28 @@ router.get('/production-plans', verifyToken, async (req, res) => {
router.get('/production-workstations', verifyToken, async (req, res) => { router.get('/production-workstations', verifyToken, async (req, res) => {
try { try {
res.json(await listProductionWorkstations()); res.json(await listProductionWorkstations({ includeInactive: req.query?.includeInactive === 'true' }));
} catch (error) { } catch (error) {
res.status(500).json({ error: 'Internal Server Error' }); res.status(500).json({ error: 'Internal Server Error' });
} }
}); });
router.post('/production-workstations', verifyToken, async (req, res) => {
try {
res.status(201).json(await createProductionWorkstation(req.body || {}));
} catch (error) {
res.status(error.statusCode || 500).json({ error: error.message || 'Internal Server Error' });
}
});
router.put('/production-workstations/:id', verifyToken, async (req, res) => {
try {
res.json(await updateProductionWorkstation(req.params.id, req.body || {}));
} catch (error) {
res.status(error.statusCode || 500).json({ error: error.message || 'Internal Server Error' });
}
});
router.put('/production-plans/:id', verifyToken, async (req, res) => { router.put('/production-plans/:id', verifyToken, async (req, res) => {
try { try {
res.json(await updateProductionPlan(req.params.id, req.body || {})); res.json(await updateProductionPlan(req.params.id, req.body || {}));
@@ -59,6 +75,14 @@ router.patch('/production-plans/:id/status', verifyToken, async (req, res) => {
} }
}); });
router.put('/production-plans/:id/review', verifyToken, async (req, res) => {
try {
res.json(await saveProductionPlanReview(req.params.id, req.body || {}));
} catch (error) {
res.status(error.statusCode || 500).json({ error: error.message || 'Internal Server Error' });
}
});
router.post('/production-orders/tiny-sync', authenticateAPIKey, async (req, res) => { router.post('/production-orders/tiny-sync', authenticateAPIKey, async (req, res) => {
try { try {
res.status(201).json(await upsertTinyProductionOrderDetail(req.body || {})); res.status(201).json(await upsertTinyProductionOrderDetail(req.body || {}));

View File

@@ -5,15 +5,18 @@ const {
approveReceipt, approveReceipt,
consumeLotForProduction, consumeLotForProduction,
createFabricPlan, createFabricPlan,
createMaterialTransformation,
createPurchaseOrder, createPurchaseOrder,
createReceipt, createReceipt,
deleteFabricPlan, deleteFabricPlan,
deleteReceipt, deleteReceipt,
getSupplySummary, getSupplySummary,
listFabricPlans, listFabricPlans,
listMaterialTransformations,
listKnownSuppliers, listKnownSuppliers,
listPurchaseOrders, listPurchaseOrders,
saveMaterialDetails, saveMaterialDetails,
updateMaterialTransformationStatus,
updatePurchaseOrderStatus, updatePurchaseOrderStatus,
listLots, listLots,
listMovements, listMovements,
@@ -153,6 +156,30 @@ router.delete('/supply/fabric-plans/:id', verifyToken, async (req, res, next) =>
} }
}); });
router.get('/supply/material-transformations', verifyToken, async (req, res, next) => {
try {
res.json(await listMaterialTransformations());
} catch (error) {
next(error);
}
});
router.post('/supply/material-transformations', verifyToken, async (req, res, next) => {
try {
res.status(201).json(await createMaterialTransformation(req.body || {}));
} catch (error) {
next(error);
}
});
router.patch('/supply/material-transformations/:id/status', verifyToken, async (req, res, next) => {
try {
res.json(await updateMaterialTransformationStatus(req.params.id, req.body?.status));
} catch (error) {
next(error);
}
});
router.get('/supply/purchase-needs', verifyToken, async (req, res, next) => { router.get('/supply/purchase-needs', verifyToken, async (req, res, next) => {
try { try {
res.json(await listPurchaseNeeds()); res.json(await listPurchaseNeeds());

View File

@@ -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();

View 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;
});

View File

@@ -51,6 +51,14 @@ const formatDate = (value) => {
const normalizeText = (value) => String(value || '').trim(); const normalizeText = (value) => String(value || '').trim();
const normalizeWorkstationCode = (value) => normalizeText(value)
.normalize('NFD')
.replace(/[\u0300-\u036f]/g, '')
.toLowerCase()
.replace(/[^a-z0-9]+/g, '-')
.replace(/^-+|-+$/g, '')
.slice(0, 60);
const normalizeQuantity = (value) => { const normalizeQuantity = (value) => {
const normalizedValue = typeof value === 'string' && value.includes(',') const normalizedValue = typeof value === 'string' && value.includes(',')
? value.replace(/\./g, '').replace(',', '.') ? value.replace(/\./g, '').replace(',', '.')
@@ -314,6 +322,8 @@ const mapProductionOrderRow = (row) => {
rejectedQuantity: Number(row.rejected_quantity || 0), rejectedQuantity: Number(row.rejected_quantity || 0),
finishedQuantity: row.finished_quantity === null ? null : Number(row.finished_quantity), finishedQuantity: row.finished_quantity === null ? null : Number(row.finished_quantity),
qualityNotes: row.quality_notes || '', qualityNotes: row.quality_notes || '',
cutLayers: row.cut_layers === null || row.cut_layers === undefined ? null : Number(row.cut_layers),
markerReference: row.marker_reference || '',
defects: Array.isArray(row.defects) ? row.defects.map(mapDefect) : [], defects: Array.isArray(row.defects) ? row.defects.map(mapDefect) : [],
lotReservations: Array.isArray(row.lot_reservations) ? row.lot_reservations.map(mapLotReservation) : [], lotReservations: Array.isArray(row.lot_reservations) ? row.lot_reservations.map(mapLotReservation) : [],
markers: Array.isArray(row.markers) ? row.markers.filter(Boolean) : [], markers: Array.isArray(row.markers) ? row.markers.filter(Boolean) : [],
@@ -343,10 +353,57 @@ const mapProductionPlanRow = (row) => ({
actualCutQuantity: Number(row.actual_cut_quantity || 0), actualCutQuantity: Number(row.actual_cut_quantity || 0),
finishedQuantity: Number(row.finished_quantity || 0), finishedQuantity: Number(row.finished_quantity || 0),
statusCounts: row.status_counts || {}, statusCounts: row.status_counts || {},
review: row.review_id ? {
id: Number(row.review_id),
decision: row.review_decision || 'pending',
checklist: row.review_checklist || {},
notes: row.review_notes || '',
reviewedBy: row.reviewed_by || '',
reviewedAt: row.reviewed_at || null,
updatedAt: row.review_updated_at || null
} : null,
createdAt: row.created_at || null, createdAt: row.created_at || null,
updatedAt: row.updated_at || null updatedAt: row.updated_at || null
}); });
const REVIEW_DECISIONS = new Set(['pending', 'approved', 'adjustments_required']);
const REVIEW_CHECKLIST_KEYS = new Set(['materialsReserved', 'cutSpecificationReady', 'sizeAndColorReviewed', 'responsibleAndDateDefined']);
const normalizeProductionPlanReview = (payload = {}) => {
const decision = normalizeText(payload.decision).toLowerCase() || 'pending';
if (!REVIEW_DECISIONS.has(decision)) {
const error = new Error('Decisão de revisão inválida.');
error.statusCode = 400;
throw error;
}
const rawChecklist = payload.checklist && typeof payload.checklist === 'object' ? payload.checklist : {};
const checklist = {};
REVIEW_CHECKLIST_KEYS.forEach(key => { checklist[key] = Boolean(rawChecklist[key]); });
return { decision, checklist, notes: normalizeText(payload.notes), reviewedBy: normalizeText(payload.reviewedBy) };
};
const saveProductionPlanReview = async (id, payload = {}) => {
const review = normalizeProductionPlanReview(payload);
const planExists = await pool.query('SELECT id FROM production_plans WHERE id = $1', [id]);
if (!planExists.rowCount) {
const error = new Error('Plano de produção não encontrado.');
error.statusCode = 404;
throw error;
}
await pool.query(`
INSERT INTO production_plan_reviews (production_plan_id, decision, checklist, notes, reviewed_by, reviewed_at, updated_at)
VALUES ($1, $2, $3::jsonb, NULLIF($4, ''), NULLIF($5, ''), CURRENT_TIMESTAMP, 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 = CURRENT_TIMESTAMP,
updated_at = CURRENT_TIMESTAMP
`, [id, review.decision, JSON.stringify(review.checklist), review.notes, review.reviewedBy]);
return getProductionPlanById(id);
};
const normalizePlanPayload = (payload = {}, fallback = {}) => ({ const normalizePlanPayload = (payload = {}, fallback = {}) => ({
id: normalizeInteger(payload.id), id: normalizeInteger(payload.id),
name: normalizeText(payload.name) || normalizeText(fallback.name), name: normalizeText(payload.name) || normalizeText(fallback.name),
@@ -380,12 +437,16 @@ const getProductionPlanById = async (id, client = pool) => {
WHERE production_plan_id = plan.id WHERE production_plan_id = plan.id
GROUP BY status GROUP BY status
) grouped_statuses ) grouped_statuses
), '{}'::json) AS status_counts ), '{}'::json) AS status_counts,
review.id AS review_id, review.decision AS review_decision,
review.checklist AS review_checklist, review.notes AS review_notes,
review.reviewed_by, review.reviewed_at, review.updated_at AS review_updated_at
FROM production_plans plan FROM production_plans plan
LEFT JOIN production_orders po ON po.production_plan_id = plan.id LEFT JOIN production_orders po ON po.production_plan_id = plan.id
LEFT JOIN app_users responsible ON responsible.id = plan.responsible_user_id LEFT JOIN app_users responsible ON responsible.id = plan.responsible_user_id
LEFT JOIN production_plan_reviews review ON review.production_plan_id = plan.id
WHERE plan.id = $1 WHERE plan.id = $1
GROUP BY plan.id, responsible.id; GROUP BY plan.id, responsible.id, review.id;
`, [id]); `, [id]);
return result.rows[0] ? mapProductionPlanRow(result.rows[0]) : null; return result.rows[0] ? mapProductionPlanRow(result.rows[0]) : null;
}; };
@@ -467,6 +528,8 @@ const getOrderById = async (id, client = pool) => {
po.rejected_quantity, po.rejected_quantity,
po.finished_quantity, po.finished_quantity,
po.quality_notes, po.quality_notes,
po.cut_layers,
po.marker_reference,
po.created_at, po.created_at,
po.updated_at, po.updated_at,
COALESCE( COALESCE(
@@ -497,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,
@@ -605,6 +710,8 @@ const baseProductionOrderSelect = `
po.rejected_quantity, po.rejected_quantity,
po.finished_quantity, po.finished_quantity,
po.quality_notes, po.quality_notes,
po.cut_layers,
po.marker_reference,
po.created_at, po.created_at,
po.updated_at, po.updated_at,
COALESCE( COALESCE(
@@ -635,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,
@@ -735,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 ')}` : ''}
@@ -781,11 +934,15 @@ const listProductionPlans = async () => {
WHERE production_plan_id = plan.id WHERE production_plan_id = plan.id
GROUP BY status GROUP BY status
) grouped_statuses ) grouped_statuses
), '{}'::json) AS status_counts ), '{}'::json) AS status_counts,
review.id AS review_id, review.decision AS review_decision,
review.checklist AS review_checklist, review.notes AS review_notes,
review.reviewed_by, review.reviewed_at, review.updated_at AS review_updated_at
FROM production_plans plan FROM production_plans plan
LEFT JOIN production_orders po ON po.production_plan_id = plan.id LEFT JOIN production_orders po ON po.production_plan_id = plan.id
LEFT JOIN app_users responsible ON responsible.id = plan.responsible_user_id LEFT JOIN app_users responsible ON responsible.id = plan.responsible_user_id
GROUP BY plan.id, responsible.id LEFT JOIN production_plan_reviews review ON review.production_plan_id = plan.id
GROUP BY plan.id, responsible.id, review.id
ORDER BY COALESCE(plan.planned_start_date, plan.created_at::date) DESC, plan.id DESC; ORDER BY COALESCE(plan.planned_start_date, plan.created_at::date) DESC, plan.id DESC;
`); `);
return result.rows.map(mapProductionPlanRow); return result.rows.map(mapProductionPlanRow);
@@ -831,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;
@@ -844,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');
@@ -1126,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) => {
@@ -1222,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;
@@ -1779,15 +1939,19 @@ const updateProductionOrderExecution = async (id, payload = {}) => {
const rejectedQuantity = normalizeNonNegativeQuantity(payload.rejectedQuantity); const rejectedQuantity = normalizeNonNegativeQuantity(payload.rejectedQuantity);
const finishedQuantity = normalizeNonNegativeQuantity(payload.finishedQuantity); const finishedQuantity = normalizeNonNegativeQuantity(payload.finishedQuantity);
const qualityNotes = normalizeText(payload.qualityNotes); const qualityNotes = normalizeText(payload.qualityNotes);
const cutLayers = payload.cutLayers === '' || payload.cutLayers === null || payload.cutLayers === undefined
? null
: normalizeInteger(payload.cutLayers);
const markerReference = normalizeText(payload.markerReference);
if (actualCutQuantity === null && rejectedQuantity === null && finishedQuantity === null && !qualityNotes) { if (actualCutQuantity === null && rejectedQuantity === null && finishedQuantity === null && !qualityNotes && cutLayers === null && !markerReference) {
const error = new Error('Informe uma quantidade de execução ou uma observação de qualidade.'); const error = new Error('Informe uma quantidade de execução ou uma observação de qualidade.');
error.statusCode = 400; error.statusCode = 400;
throw error; throw error;
} }
const currentResult = await pool.query(` const currentResult = await pool.query(`
SELECT actual_cut_quantity, rejected_quantity, finished_quantity SELECT actual_cut_quantity, rejected_quantity, finished_quantity, cut_layers, marker_reference
FROM production_orders FROM production_orders
WHERE id = $1; WHERE id = $1;
`, [id]); `, [id]);
@@ -1801,6 +1965,14 @@ const updateProductionOrderExecution = async (id, payload = {}) => {
const effectiveActual = actualCutQuantity === null ? (current.actual_cut_quantity === null ? null : Number(current.actual_cut_quantity)) : actualCutQuantity; const effectiveActual = actualCutQuantity === null ? (current.actual_cut_quantity === null ? null : Number(current.actual_cut_quantity)) : actualCutQuantity;
const effectiveRejected = rejectedQuantity === null ? Number(current.rejected_quantity || 0) : rejectedQuantity; const effectiveRejected = rejectedQuantity === null ? Number(current.rejected_quantity || 0) : rejectedQuantity;
const effectiveFinished = finishedQuantity === null ? (current.finished_quantity === null ? null : Number(current.finished_quantity)) : finishedQuantity; const effectiveFinished = finishedQuantity === null ? (current.finished_quantity === null ? null : Number(current.finished_quantity)) : finishedQuantity;
const effectiveLayers = cutLayers === null ? (current.cut_layers === null ? null : Number(current.cut_layers)) : cutLayers;
const effectiveMarkerReference = markerReference || current.marker_reference || '';
if (effectiveLayers !== null && effectiveLayers <= 0) {
const error = new Error('O número de camadas deve ser maior que zero.');
error.statusCode = 400;
throw error;
}
if (effectiveActual !== null && effectiveRejected > effectiveActual) { if (effectiveActual !== null && effectiveRejected > effectiveActual) {
const error = new Error('As perdas não podem ser maiores que a quantidade cortada.'); const error = new Error('As perdas não podem ser maiores que a quantidade cortada.');
@@ -1819,21 +1991,88 @@ const updateProductionOrderExecution = async (id, payload = {}) => {
rejected_quantity = $2, rejected_quantity = $2,
finished_quantity = $3, finished_quantity = $3,
quality_notes = NULLIF($4, ''), quality_notes = NULLIF($4, ''),
cut_layers = $5,
marker_reference = NULLIF($6, ''),
updated_at = CURRENT_TIMESTAMP updated_at = CURRENT_TIMESTAMP
WHERE id = $5; WHERE id = $7;
`, [effectiveActual, effectiveRejected, effectiveFinished, qualityNotes, id]); `, [effectiveActual, effectiveRejected, effectiveFinished, qualityNotes, effectiveLayers, effectiveMarkerReference, id]);
return getOrderById(id); return getOrderById(id);
}; };
const listProductionWorkstations = async () => { const mapWorkstation = (row) => ({
id: Number(row.id),
code: row.code || '',
name: row.name || '',
color: row.color || '#38bdf8',
sortOrder: Number(row.sort_order || 0),
isActive: Boolean(row.is_active)
});
const listProductionWorkstations = async ({ includeInactive = false } = {}) => {
const result = await pool.query(` const result = await pool.query(`
SELECT id, code, name, color, sort_order, is_active SELECT id, code, name, color, sort_order, is_active
FROM production_workstations FROM production_workstations
WHERE is_active = TRUE ${includeInactive ? '' : 'WHERE is_active = TRUE'}
ORDER BY sort_order, name; ORDER BY sort_order, name;
`); `);
return result.rows.map(row => ({ id: Number(row.id), code: row.code, name: row.name, color: row.color, sortOrder: Number(row.sort_order), isActive: Boolean(row.is_active) })); return result.rows.map(mapWorkstation);
};
const normalizeWorkstationPayload = (payload = {}) => {
const name = normalizeText(payload.name).slice(0, 160);
const code = normalizeWorkstationCode(payload.code || name);
const color = /^#[0-9a-f]{6}$/i.test(normalizeText(payload.color)) ? normalizeText(payload.color) : '#38bdf8';
const sortOrder = Number.isFinite(Number(payload.sortOrder)) ? Math.max(0, Math.trunc(Number(payload.sortOrder))) : 0;
const isActive = payload.isActive !== false;
if (!name || !code) {
const error = new Error('Informe o nome do posto de trabalho.');
error.statusCode = 400;
throw error;
}
return { name, code, color, sortOrder, isActive };
};
const createProductionWorkstation = async (payload = {}) => {
const workstation = normalizeWorkstationPayload(payload);
try {
const result = await pool.query(`
INSERT INTO production_workstations (code, name, color, sort_order, is_active, updated_at)
VALUES ($1, $2, $3, $4, $5, CURRENT_TIMESTAMP)
RETURNING id, code, name, color, sort_order, is_active;
`, [workstation.code, workstation.name, workstation.color, workstation.sortOrder, workstation.isActive]);
return mapWorkstation(result.rows[0]);
} catch (error) {
if (error?.code === '23505') {
error.message = 'Já existe um posto de trabalho com este código.';
error.statusCode = 409;
}
throw error;
}
};
const updateProductionWorkstation = async (id, payload = {}) => {
const workstation = normalizeWorkstationPayload(payload);
try {
const result = await pool.query(`
UPDATE production_workstations
SET code = $1, name = $2, color = $3, sort_order = $4, is_active = $5, updated_at = CURRENT_TIMESTAMP
WHERE id = $6
RETURNING id, code, name, color, sort_order, is_active;
`, [workstation.code, workstation.name, workstation.color, workstation.sortOrder, workstation.isActive, id]);
if (!result.rowCount) {
const error = new Error('Posto de trabalho não encontrado.');
error.statusCode = 404;
throw error;
}
return mapWorkstation(result.rows[0]);
} catch (error) {
if (error?.code === '23505') {
error.message = 'Já existe um posto de trabalho com este código.';
error.statusCode = 409;
}
throw error;
}
}; };
const addProductionOrderDefect = async (id, payload = {}) => { const addProductionOrderDefect = async (id, payload = {}) => {
@@ -1864,10 +2103,12 @@ const addProductionOrderDefect = async (id, payload = {}) => {
module.exports = { module.exports = {
createProductionOrders, createProductionOrders,
createProductionWorkstation,
deleteManualProductComposition, deleteManualProductComposition,
getProductComposition, getProductComposition,
importTinyProductCompositionExport, importTinyProductCompositionExport,
listProductionPlans, listProductionPlans,
saveProductionPlanReview,
listProductionWorkstations, listProductionWorkstations,
listProductCompositions, listProductCompositions,
listProductionOrders, listProductionOrders,
@@ -1877,6 +2118,7 @@ module.exports = {
upsertTinyProductionOrderDetail, upsertTinyProductionOrderDetail,
updateProductionPlan, updateProductionPlan,
updateProductionPlanStatus, updateProductionPlanStatus,
updateProductionWorkstation,
addProductionOrderDefect, addProductionOrderDefect,
updateProductionOrderExecution, updateProductionOrderExecution,
updateProductionOrderStatus updateProductionOrderStatus

View File

@@ -46,6 +46,9 @@ const mapReceipt = (row) => ({
invoice: row.invoice || '', invoice: row.invoice || '',
notes: row.notes || '', notes: row.notes || '',
status: row.status, status: row.status,
purchaseOrderId: row.purchase_order_id === null || row.purchase_order_id === undefined ? null : Number(row.purchase_order_id),
purchaseOrderNumber: row.purchase_order_number || '',
purchaseOrderStatus: row.purchase_order_status || '',
createdAt: row.created_at, createdAt: row.created_at,
updatedAt: row.updated_at, updatedAt: row.updated_at,
approvedAt: row.approved_at approvedAt: row.approved_at
@@ -92,6 +95,24 @@ const mapFabricPlan = (row) => ({
updatedAt: row.updated_at updatedAt: row.updated_at
}); });
const mapMaterialTransformation = (row) => ({
id: row.id,
batchCode: row.batch_code || '',
stage: row.stage,
inputMaterial: row.input_material,
inputQuantityKg: Number(row.input_quantity_kg),
outputMaterial: row.output_material,
outputColor: row.output_color || '',
expectedLossPercent: Number(row.expected_loss_percent || 0),
expectedOutputKg: Number(row.expected_output_kg || 0),
supplier: row.supplier || '',
priority: row.priority || 'Normal',
status: row.status || 'planned',
notes: row.notes || '',
createdAt: row.created_at,
updatedAt: row.updated_at
});
const mapMaterialDetails = (row) => ({ const mapMaterialDetails = (row) => ({
materialKey: row.material_key, materialKey: row.material_key,
displayName: row.display_name || '', displayName: row.display_name || '',
@@ -101,7 +122,7 @@ const mapMaterialDetails = (row) => ({
updatedAt: row.updated_at updatedAt: row.updated_at
}); });
const PURCHASE_ORDER_STATUSES = new Set(['draft', 'sent', 'approved', 'received', 'canceled']); const PURCHASE_ORDER_STATUSES = new Set(['draft', 'pending_financial', 'sent', 'approved', 'received', 'canceled']);
const mapPurchaseOrder = (row) => ({ const mapPurchaseOrder = (row) => ({
id: row.id, id: row.id,
@@ -130,9 +151,12 @@ const createValidationError = (message) => {
const listReceipts = async () => { const listReceipts = async () => {
const result = await pool.query(` const result = await pool.query(`
SELECT id, category, product, quantity, unit, supplier, invoice, notes, status, created_at, updated_at, approved_at SELECT receipt.id, receipt.category, receipt.product, receipt.quantity, receipt.unit, receipt.supplier, receipt.invoice,
FROM supply_receipts receipt.notes, receipt.status, receipt.purchase_order_id, receipt.created_at, receipt.updated_at, receipt.approved_at,
ORDER BY created_at DESC, id DESC purchase_order.number AS purchase_order_number, purchase_order.status AS purchase_order_status
FROM supply_receipts receipt
LEFT JOIN supply_purchase_orders purchase_order ON purchase_order.id = receipt.purchase_order_id
ORDER BY receipt.created_at DESC, receipt.id DESC
`); `);
return result.rows.map(mapReceipt); return result.rows.map(mapReceipt);
}; };
@@ -182,6 +206,19 @@ const listFabricPlans = async () => {
return result.rows.map(mapFabricPlan); return result.rows.map(mapFabricPlan);
}; };
const listMaterialTransformations = async () => {
const result = await pool.query(`
SELECT id, batch_code, stage, input_material, input_quantity_kg, output_material, output_color,
expected_loss_percent, expected_output_kg, supplier, priority, status, notes, created_at, updated_at
FROM supply_material_transformations
ORDER BY
CASE status WHEN 'in_progress' THEN 1 WHEN 'planned' THEN 2 WHEN 'completed' THEN 3 ELSE 4 END,
CASE priority WHEN 'Crítico' THEN 1 WHEN 'Atenção' THEN 2 ELSE 3 END,
created_at DESC, id DESC
`);
return result.rows.map(mapMaterialTransformation);
};
const listMaterialDetails = async () => { const listMaterialDetails = async () => {
const result = await pool.query(` const result = await pool.query(`
SELECT material_key, display_name, preferred_supplier, notes, created_at, updated_at SELECT material_key, display_name, preferred_supplier, notes, created_at, updated_at
@@ -899,13 +936,16 @@ const createReceipt = async (payload) => {
if (!product) throw createValidationError('Produto ou material é obrigatório.'); if (!product) throw createValidationError('Produto ou material é obrigatório.');
if (!quantity) throw createValidationError('Quantidade deve ser maior que zero.'); if (!quantity) throw createValidationError('Quantidade deve ser maior que zero.');
const result = await pool.query(` const client = await pool.connect();
try {
await client.query('BEGIN');
const result = await client.query(`
INSERT INTO supply_receipts ( INSERT INTO supply_receipts (
category, product, quantity, unit, supplier, invoice, notes, status, updated_at category, product, quantity, unit, supplier, invoice, notes, status, updated_at
) )
VALUES ($1, $2, $3, $4, $5, $6, $7, 'pending', CURRENT_TIMESTAMP) VALUES ($1, $2, $3, $4, $5, $6, $7, 'pending', CURRENT_TIMESTAMP)
RETURNING id, category, product, quantity, unit, supplier, invoice, notes, status, created_at, updated_at, approved_at RETURNING id, category, product, quantity, unit, supplier, invoice, notes, status, created_at, updated_at, approved_at
`, [ `, [
category, category,
product, product,
quantity, quantity,
@@ -913,9 +953,31 @@ const createReceipt = async (payload) => {
normalizeText(payload.supplier) || null, normalizeText(payload.supplier) || null,
normalizeText(payload.invoice) || null, normalizeText(payload.invoice) || null,
normalizeText(payload.notes) || null normalizeText(payload.notes) || null
]); ]);
const receipt = result.rows[0];
return mapReceipt(result.rows[0]); const orderResult = await client.query(`
INSERT INTO supply_purchase_orders (supplier, status, notes, updated_at)
VALUES ($1, 'pending_financial', $2, CURRENT_TIMESTAMP)
RETURNING id;
`, [
normalizeText(payload.supplier) || 'Sem fornecedor',
`Gerada automaticamente pelo recebimento #${receipt.id}${receipt.invoice ? ` · NF ${receipt.invoice}` : ''}`
]);
const purchaseOrderId = orderResult.rows[0].id;
await client.query(`UPDATE supply_purchase_orders SET number = $1 WHERE id = $2;`, [`OC-${String(purchaseOrderId).padStart(5, '0')}`, purchaseOrderId]);
await client.query(`INSERT INTO supply_purchase_order_items (purchase_order_id, material, quantity, unit) VALUES ($1, $2, $3, $4);`, [purchaseOrderId, receipt.product, receipt.quantity, receipt.unit]);
const linkedReceipt = await client.query(`
UPDATE supply_receipts SET purchase_order_id = $2, updated_at = CURRENT_TIMESTAMP WHERE id = $1
RETURNING id, category, product, quantity, unit, supplier, invoice, notes, status, purchase_order_id, created_at, updated_at, approved_at;
`, [receipt.id, purchaseOrderId]);
await client.query('COMMIT');
return mapReceipt({ ...linkedReceipt.rows[0], purchase_order_number: `OC-${String(purchaseOrderId).padStart(5, '0')}`, purchase_order_status: 'pending_financial' });
} catch (error) {
await client.query('ROLLBACK');
throw error;
} finally {
client.release();
}
}; };
const approveReceipt = async (id) => { const approveReceipt = async (id) => {
@@ -925,10 +987,13 @@ const approveReceipt = async (id) => {
await client.query('BEGIN'); await client.query('BEGIN');
const receiptResult = await client.query(` const receiptResult = await client.query(`
SELECT id, category, product, quantity, unit, supplier, invoice, notes, status, created_at, updated_at, approved_at SELECT receipt.id, receipt.category, receipt.product, receipt.quantity, receipt.unit, receipt.supplier, receipt.invoice,
FROM supply_receipts receipt.notes, receipt.status, receipt.purchase_order_id, receipt.created_at, receipt.updated_at, receipt.approved_at,
WHERE id = $1 purchase_order.number AS purchase_order_number, purchase_order.status AS purchase_order_status
FOR UPDATE FROM supply_receipts receipt
LEFT JOIN supply_purchase_orders purchase_order ON purchase_order.id = receipt.purchase_order_id
WHERE receipt.id = $1
FOR UPDATE OF receipt
`, [id]); `, [id]);
if (!receiptResult.rowCount) { if (!receiptResult.rowCount) {
@@ -941,13 +1006,17 @@ const approveReceipt = async (id) => {
return mapReceipt(receipt); return mapReceipt(receipt);
} }
if (receipt.purchase_order_id && !['approved', 'received'].includes(receipt.purchase_order_status)) {
throw createValidationError(`A ${receipt.purchase_order_number || 'OC vinculada'} ainda aguarda aprovação financeira.`);
}
const updatedReceiptResult = await client.query(` const updatedReceiptResult = await client.query(`
UPDATE supply_receipts UPDATE supply_receipts
SET status = 'approved', SET status = 'approved',
approved_at = CURRENT_TIMESTAMP, approved_at = CURRENT_TIMESTAMP,
updated_at = CURRENT_TIMESTAMP updated_at = CURRENT_TIMESTAMP
WHERE id = $1 WHERE id = $1
RETURNING id, category, product, quantity, unit, supplier, invoice, notes, status, created_at, updated_at, approved_at RETURNING id, category, product, quantity, unit, supplier, invoice, notes, status, purchase_order_id, created_at, updated_at, approved_at
`, [id]); `, [id]);
const lotResult = await client.query(` const lotResult = await client.query(`
@@ -981,8 +1050,12 @@ const approveReceipt = async (id) => {
`Recebimento aprovado${receipt.invoice ? ` · NF ${receipt.invoice}` : ''}` `Recebimento aprovado${receipt.invoice ? ` · NF ${receipt.invoice}` : ''}`
]); ]);
if (receipt.purchase_order_id) {
await client.query(`UPDATE supply_purchase_orders SET status = 'received', updated_at = CURRENT_TIMESTAMP WHERE id = $1 AND status = 'approved';`, [receipt.purchase_order_id]);
}
await client.query('COMMIT'); await client.query('COMMIT');
return mapReceipt(updatedReceiptResult.rows[0]); return mapReceipt({ ...updatedReceiptResult.rows[0], purchase_order_number: receipt.purchase_order_number, purchase_order_status: receipt.purchase_order_id ? 'received' : '' });
} catch (error) { } catch (error) {
await client.query('ROLLBACK'); await client.query('ROLLBACK');
throw error; throw error;
@@ -1032,6 +1105,49 @@ const createFabricPlan = async (payload) => {
return mapFabricPlan(result.rows[0]); return mapFabricPlan(result.rows[0]);
}; };
const TRANSFORMATION_STAGES = new Set(['knitting', 'dyeing', 'finishing']);
const TRANSFORMATION_STATUSES = new Set(['planned', 'in_progress', 'completed', 'canceled']);
const createMaterialTransformation = async (payload = {}) => {
const stage = normalizeText(payload.stage).toLowerCase();
const inputMaterial = normalizeText(payload.inputMaterial);
const outputMaterial = normalizeText(payload.outputMaterial);
const inputQuantityKg = normalizeNumber(payload.inputQuantityKg);
const expectedLossPercent = normalizeNonNegativeNumber(payload.expectedLossPercent) ?? 0;
if (!TRANSFORMATION_STAGES.has(stage)) throw createValidationError('Etapa de transformação inválida.');
if (!inputMaterial || !outputMaterial) throw createValidationError('Material de entrada e saída são obrigatórios.');
if (!inputQuantityKg) throw createValidationError('Quantidade de entrada deve ser maior que zero.');
if (expectedLossPercent >= 100) throw createValidationError('A perda esperada deve ser menor que 100%.');
const expectedOutputKg = Number((inputQuantityKg * (1 - (expectedLossPercent / 100))).toFixed(4));
const result = await pool.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
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, 'planned', $11, CURRENT_TIMESTAMP)
RETURNING id, batch_code, stage, input_material, input_quantity_kg, output_material, output_color,
expected_loss_percent, expected_output_kg, supplier, priority, status, notes, created_at, updated_at
`, [
normalizeText(payload.batchCode) || null, stage, inputMaterial, inputQuantityKg, outputMaterial,
normalizeText(payload.outputColor) || null, expectedLossPercent, expectedOutputKg,
normalizeText(payload.supplier) || null, normalizeText(payload.priority) || 'Normal', normalizeText(payload.notes) || null
]);
return mapMaterialTransformation(result.rows[0]);
};
const updateMaterialTransformationStatus = async (id, status) => {
const normalizedStatus = normalizeText(status).toLowerCase();
if (!TRANSFORMATION_STATUSES.has(normalizedStatus)) throw createValidationError('Status da transformação inválido.');
const result = await pool.query(`
UPDATE supply_material_transformations
SET status = $2, updated_at = CURRENT_TIMESTAMP
WHERE id = $1
RETURNING id, batch_code, stage, input_material, input_quantity_kg, output_material, output_color,
expected_loss_percent, expected_output_kg, supplier, priority, status, notes, created_at, updated_at
`, [id, normalizedStatus]);
if (!result.rows[0]) throw createValidationError('Transformação não encontrada.');
return mapMaterialTransformation(result.rows[0]);
};
const deleteFabricPlan = async (id) => { const deleteFabricPlan = async (id) => {
await pool.query(` await pool.query(`
UPDATE supply_fabric_plans UPDATE supply_fabric_plans
@@ -1243,6 +1359,7 @@ module.exports = {
approveReceipt, approveReceipt,
consumeLotForProduction, consumeLotForProduction,
createFabricPlan, createFabricPlan,
createMaterialTransformation,
createPurchaseOrder, createPurchaseOrder,
createReceipt, createReceipt,
deleteFabricPlan, deleteFabricPlan,
@@ -1251,6 +1368,7 @@ module.exports = {
buildProjectPurchaseNeeds, buildProjectPurchaseNeeds,
buildPurchaseNeeds, buildPurchaseNeeds,
listFabricPlans, listFabricPlans,
listMaterialTransformations,
listMaterialDetails, listMaterialDetails,
listKnownSuppliers, listKnownSuppliers,
listPurchaseOrders, listPurchaseOrders,
@@ -1259,5 +1377,6 @@ module.exports = {
listPurchaseNeeds, listPurchaseNeeds,
listReceipts, listReceipts,
saveMaterialDetails, saveMaterialDetails,
updateMaterialTransformationStatus,
updatePurchaseOrderStatus updatePurchaseOrderStatus
}; };

View File

@@ -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

View File

@@ -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() });

View 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

View File

@@ -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'));
@@ -13,6 +13,7 @@ const Cutting = React.lazy(() => import('./pages/Cutting'));
const PlanningIssues = React.lazy(() => import('./pages/PlanningIssues')); const PlanningIssues = React.lazy(() => import('./pages/PlanningIssues'));
const ProductionOrders = React.lazy(() => import('./pages/ProductionOrders')); const ProductionOrders = React.lazy(() => import('./pages/ProductionOrders'));
const ProductionPlans = React.lazy(() => import('./pages/ProductionPlans')); const ProductionPlans = React.lazy(() => import('./pages/ProductionPlans'));
const ProductionQuality = React.lazy(() => import('./pages/ProductionQuality'));
const Supplies = React.lazy(() => import('./pages/Supplies')); const Supplies = React.lazy(() => import('./pages/Supplies'));
const Clients = React.lazy(() => import('./pages/Clients')); const Clients = React.lazy(() => import('./pages/Clients'));
const ClientDetails = React.lazy(() => import('./pages/ClientDetails')); const ClientDetails = React.lazy(() => import('./pages/ClientDetails'));
@@ -33,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" />
@@ -66,6 +60,7 @@ function App() {
<Route path="stock-alerts" element={<Navigate to="/products" replace />} /> <Route path="stock-alerts" element={<Navigate to="/products" replace />} />
<Route path="production-orders" element={<ProductionOrders />} /> <Route path="production-orders" element={<ProductionOrders />} />
<Route path="production-plans" element={<ProductionPlans />} /> <Route path="production-plans" element={<ProductionPlans />} />
<Route path="production-quality" element={<ProductionQuality />} />
<Route path="clients" element={<Clients />} /> <Route path="clients" element={<Clients />} />
<Route path="clients/:clientToken" element={<ClientDetails />} /> <Route path="clients/:clientToken" element={<ClientDetails />} />
<Route path="sellers" element={<Sellers />} /> <Route path="sellers" element={<Sellers />} />
@@ -73,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>

View File

@@ -2,7 +2,7 @@ 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, 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',
@@ -92,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 (

View File

@@ -13,6 +13,7 @@ type PaginationControlsProps = {
onPageChange: (page: number) => void; onPageChange: (page: number) => void;
onPageSizeChange: (pageSize: number) => void; onPageSizeChange: (pageSize: number) => void;
className?: string; className?: string;
stacked?: boolean;
}; };
const clampPage = (page: number, totalPages: number) => { const clampPage = (page: number, totalPages: number) => {
@@ -32,7 +33,8 @@ const PaginationControls = ({
endIndex, endIndex,
onPageChange, onPageChange,
onPageSizeChange, onPageSizeChange,
className = 'px-6 py-4 border-t border-zinc-100 dark:border-dark-border' className = 'px-6 py-4 border-t border-zinc-100 dark:border-dark-border',
stacked = false
}: PaginationControlsProps) => { }: PaginationControlsProps) => {
const safeTotalPages = Math.max(1, totalPages); const safeTotalPages = Math.max(1, totalPages);
const safeCurrentPage = clampPage(currentPage, safeTotalPages); const safeCurrentPage = clampPage(currentPage, safeTotalPages);
@@ -44,7 +46,7 @@ const PaginationControls = ({
}; };
return ( return (
<div className={`${className} flex flex-col items-center justify-between gap-4 lg:flex-row`}> <div className={`${className} ${stacked ? 'flex flex-col items-center gap-3' : 'flex flex-col items-center justify-between gap-4 lg:flex-row'}`}>
<div className="flex flex-wrap items-center justify-center gap-2 text-sm text-zinc-500 dark:text-dark-muted"> <div className="flex flex-wrap items-center justify-center gap-2 text-sm text-zinc-500 dark:text-dark-muted">
<span>Mostrar</span> <span>Mostrar</span>
<select <select
@@ -59,7 +61,7 @@ const PaginationControls = ({
<span>{pageSizeLabel}</span> <span>{pageSizeLabel}</span>
</div> </div>
<div className="flex flex-wrap items-center justify-center gap-3 text-sm"> <div className={stacked ? 'flex flex-col items-center gap-2 text-sm' : 'flex flex-wrap items-center justify-center gap-3 text-sm'}>
<span className="text-center text-zinc-500 dark:text-dark-muted"> <span className="text-center text-zinc-500 dark:text-dark-muted">
Mostrando {totalItems > 0 ? startIndex + 1 : 0} a {endIndex} de {totalItems} {itemLabel} Mostrando {totalItems > 0 ? startIndex + 1 : 0} a {endIndex} de {totalItems} {itemLabel}
</span> </span>

View File

@@ -1,4 +1,4 @@
import type { AuthUser, CampaignPreview, CampaignProcessSummary, CampaignQueueSummary, CatalogCategory, CatalogCategoryPayload, CatalogProduct, CatalogProductPayload, CatalogSummary, ClientAnalyticsItem, ClientDetailsAnalytics, ClientFilterOptions, ClientMetadataFilters, ClientPurchasePatternAnalytics, ConsumptionReference, ConsumptionReferencePayload, CreateProductionOrdersResult, CreateUserResult, CutPlanLotReservationPayload, CuttingSettings, DashboardAnalytics, DateRange, GraphsStockBalanceHistoryItem, GraphsStockBalancePayload, ManagedUser, OperationalUser, OrderData, OlistRunDetails, OlistStatus, ProductAnalyticsItem, ProductComposition, ProductCompositionImportSummary, ProductCompositionPayload, ProductDetailsAnalytics, ProductionOrderExecutionPayload, ProductionOrderImportData, ProductionOrderItem, ProductionOrderPayload, ProductionOrderStatus, ProductionOrderSummary, ProductionPlan, ProductionPlanPayload, RfmAnalytics, SellerAnalyticsItem, SellerDetailsAnalytics, StockData, SupplyFabricPlan, SupplyFabricPlanPayload, SupplyInventoryAdjustmentPayload, SupplyLot, SupplyMaterialDetailsPayload, SupplyProductionExitPayload, SupplyPurchaseNeed, SupplyPurchaseOrder, SupplyPurchaseOrderPayload, SupplyPurchaseOrderStatus, SupplyReceipt, SupplyReceiptPayload, SupplySummary } from './types'; import type { AuthUser, CampaignPreview, CampaignProcessSummary, CampaignQueueSummary, CatalogCategory, CatalogCategoryPayload, CatalogProduct, CatalogProductPayload, CatalogSummary, ClientAnalyticsItem, ClientDetailsAnalytics, ClientFilterOptions, ClientMetadataFilters, ClientPurchasePatternAnalytics, ConsumptionReference, ConsumptionReferencePayload, CreateProductionOrdersResult, CreateUserResult, CutPlanLotReservationPayload, CuttingSettings, DashboardAnalytics, DateRange, GraphsStockBalanceHistoryItem, GraphsStockBalancePayload, ManagedUser, MaterialTransformationStatus, OperationalUser, OrderData, OlistRunDetails, OlistStatus, ProductAnalyticsItem, ProductComposition, ProductCompositionImportSummary, ProductCompositionPayload, ProductDetailsAnalytics, ProductionOrderDefectPayload, ProductionOrderExecutionPayload, ProductionOrderImportData, ProductionOrderItem, ProductionOrderPayload, ProductionOrderStatus, ProductionOrderSummary, ProductionPlan, ProductionPlanPayload, ProductionPlanReviewPayload, ProductionWorkstation, ProductionWorkstationPayload, RfmAnalytics, SellerAnalyticsItem, SellerDetailsAnalytics, StockData, SupplyFabricPlan, SupplyFabricPlanPayload, SupplyInventoryAdjustmentPayload, SupplyLot, SupplyMaterialDetailsPayload, SupplyMaterialTransformation, SupplyMaterialTransformationPayload, SupplyProductionExitPayload, SupplyPurchaseNeed, SupplyPurchaseOrder, SupplyPurchaseOrderPayload, SupplyPurchaseOrderStatus, SupplyReceipt, SupplyReceiptPayload, SupplySummary } from './types';
import { formatDateParam } from './dateRanges'; import { formatDateParam } from './dateRanges';
const API_URL = import.meta.env.VITE_API_URL || '/api'; const API_URL = import.meta.env.VITE_API_URL || '/api';
@@ -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 () => {
@@ -281,6 +285,16 @@ export const updateProductionPlanStatus = async (id: number, status: ProductionO
return data as ProductionPlan; return data as ProductionPlan;
}; };
export const saveProductionPlanReview = async (id: number, payload: ProductionPlanReviewPayload): Promise<ProductionPlan> => {
const response = await authFetch(`/production-plans/${id}/review`, {
method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(payload)
});
const data = await response.json().catch(() => null);
if (!response.ok) throw new Error(data?.error || 'Não foi possível salvar a revisão operacional.');
analyticsCache.clear();
return data as ProductionPlan;
};
export const updateProductionOrderStatus = async (id: number, status: ProductionOrderStatus): Promise<ProductionOrderItem> => { export const updateProductionOrderStatus = async (id: number, status: ProductionOrderStatus): Promise<ProductionOrderItem> => {
const response = await authFetch(`/production-orders/${id}/status`, { const response = await authFetch(`/production-orders/${id}/status`, {
method: 'PATCH', method: 'PATCH',
@@ -306,6 +320,46 @@ export const updateProductionOrderExecution = async (id: number, payload: Produc
return data as ProductionOrderItem; return data as ProductionOrderItem;
}; };
export const fetchProductionWorkstations = async (includeInactive = false): Promise<ProductionWorkstation[]> => {
const response = await authFetch(`/production-workstations${includeInactive ? '?includeInactive=true' : ''}`, { cache: 'no-store' });
if (!response.ok) throw new Error('Não foi possível carregar os postos de trabalho.');
return response.json() as Promise<ProductionWorkstation[]>;
};
export const createProductionWorkstation = async (payload: ProductionWorkstationPayload): Promise<ProductionWorkstation> => {
const response = await authFetch('/production-workstations', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload)
});
const data = await response.json().catch(() => null);
if (!response.ok) throw new Error(data?.error || 'Não foi possível criar o posto de trabalho.');
return data as ProductionWorkstation;
};
export const updateProductionWorkstation = async (id: number, payload: ProductionWorkstationPayload): Promise<ProductionWorkstation> => {
const response = await authFetch(`/production-workstations/${id}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload)
});
const data = await response.json().catch(() => null);
if (!response.ok) throw new Error(data?.error || 'Não foi possível atualizar o posto de trabalho.');
return data as ProductionWorkstation;
};
export const addProductionOrderDefect = async (id: number, payload: ProductionOrderDefectPayload): Promise<ProductionOrderItem> => {
const response = await authFetch(`/production-orders/${id}/defects`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload)
});
const data = await response.json().catch(() => null);
if (!response.ok) throw new Error(data?.error || 'Não foi possível registrar o defeito.');
analyticsCache.clear();
return data as ProductionOrderItem;
};
export const fetchCuttingSettings = async (): Promise<CuttingSettings> => { export const fetchCuttingSettings = async (): Promise<CuttingSettings> => {
try { try {
const response = await authFetch('/cutting-settings'); const response = await authFetch('/cutting-settings');
@@ -692,6 +746,35 @@ export const deleteSupplyFabricPlan = async (id: number): Promise<void> => {
} }
}; };
export const fetchSupplyMaterialTransformations = async (): Promise<SupplyMaterialTransformation[]> => {
try {
const response = await authFetch('/supply/material-transformations');
if (!response.ok) return [];
return await response.json();
} catch (error) {
console.error('Fetch material transformations failed', error);
return [];
}
};
export const createSupplyMaterialTransformation = async (payload: SupplyMaterialTransformationPayload): Promise<SupplyMaterialTransformation> => {
const response = await authFetch('/supply/material-transformations', {
method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(payload)
});
const data = await response.json().catch(() => null);
if (!response.ok) throw new Error(data?.error || 'Não foi possível salvar a transformação.');
return data as SupplyMaterialTransformation;
};
export const updateSupplyMaterialTransformationStatus = async (id: number, status: MaterialTransformationStatus): Promise<SupplyMaterialTransformation> => {
const response = await authFetch(`/supply/material-transformations/${id}/status`, {
method: 'PATCH', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ status })
});
const data = await response.json().catch(() => null);
if (!response.ok) throw new Error(data?.error || 'Não foi possível atualizar a transformação.');
return data as SupplyMaterialTransformation;
};
export const fetchSupplyPurchaseNeeds = async (): Promise<SupplyPurchaseNeed[]> => { export const fetchSupplyPurchaseNeeds = async (): Promise<SupplyPurchaseNeed[]> => {
try { try {
const response = await authFetch('/supply/purchase-needs'); const response = await authFetch('/supply/purchase-needs');

View File

@@ -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,7 +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 [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[]>([]);
@@ -209,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;
@@ -225,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()
@@ -391,13 +405,22 @@ const Cutting = () => {
}; };
}, [cutPlan.needRows, materialReadinessByProductId]); }, [cutPlan.needRows, materialReadinessByProductId]);
const getPreviewQuantity = useCallback((row: CutPlanSkuRow) => {
const override = Number((previewQuantityOverrides[row.id] || '').replace(',', '.'));
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 + row.suggestedCutQuantity, 0) selectedPreviewRows.reduce((total, row) => total + getPreviewQuantity(row), 0)
), [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]);
@@ -421,12 +444,12 @@ const Cutting = () => {
unit: component.unit || 'UN', unit: component.unit || 'UN',
quantity: 0 quantity: 0
}; };
current.quantity += row.suggestedCutQuantity * component.quantityPerUnit; current.quantity += getPreviewQuantity(row) * component.quantityPerUnit;
requirements.set(key, current); requirements.set(key, current);
}); });
}); });
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, selectedPreviewRows]); }, [compositionsByProductId, getPreviewQuantity, selectedPreviewRows]);
const selectedLotReservations = useMemo(() => ( const selectedLotReservations = useMemo(() => (
selectedMaterialRequirements.flatMap(requirement => { selectedMaterialRequirements.flatMap(requirement => {
@@ -442,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);
@@ -674,7 +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({});
setLotSelections({}); setLotSelections({});
setProductionPlanForm({ name: cutPlanReference, responsibleUserId: null, responsibleName: '', expectedDate: '', notes: '' }); setProductionPlanForm({ name: cutPlanReference, responsibleUserId: null, responsibleName: '', expectedDate: '', notes: '' });
setIsPlanDetailsOpen(false); setIsPlanDetailsOpen(false);
@@ -685,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)
@@ -697,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);
}); });
}; };
@@ -729,7 +750,7 @@ const Cutting = () => {
issueDate, issueDate,
productSku: row.id, productSku: row.id,
productDescription: row.name, productDescription: row.name,
quantity: row.suggestedCutQuantity, quantity: getPreviewQuantity(row),
unit: 'UN', unit: 'UN',
integrationStatus: 'Local', integrationStatus: 'Local',
components: (compositionsByProductId.get(normalizeMaterialKey(row.id))?.components || []) components: (compositionsByProductId.get(normalizeMaterialKey(row.id))?.components || [])
@@ -739,7 +760,7 @@ const Cutting = () => {
componentSku: component.componentSku, componentSku: component.componentSku,
componentName: component.componentName, componentName: component.componentName,
quantityPerUnit: component.quantityPerUnit, quantityPerUnit: component.quantityPerUnit,
totalQuantity: row.suggestedCutQuantity * component.quantityPerUnit, totalQuantity: getPreviewQuantity(row) * component.quantityPerUnit,
unit: component.unit unit: component.unit
})), })),
markers: [ markers: [
@@ -760,6 +781,7 @@ const Cutting = () => {
stock: row.stock, stock: row.stock,
openProductionQuantity: row.openProductionQuantity, openProductionQuantity: row.openProductionQuantity,
availableQuantity: row.availableQuantity, availableQuantity: row.availableQuantity,
manuallyAdjustedQuantity: getPreviewQuantity(row) !== row.suggestedCutQuantity,
issues: row.issues issues: row.issues
} }
})), selectedLotReservations, { })), selectedLotReservations, {
@@ -772,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);
@@ -991,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="flex items-center justify-between gap-4 px-3 py-2.5"> <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">
@@ -1002,20 +1024,49 @@ const Cutting = () => {
<span className="text-[10px] font-semibold text-dark-muted">{row.size || 'Sem tamanho'}</span> <span className="text-[10px] font-semibold text-dark-muted">{row.size || 'Sem tamanho'}</span>
</div> </div>
</div> </div>
<span className="shrink-0 text-sm font-bold text-brand-primary">{formatNumber(row.suggestedCutQuantity)} un.</span> <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}`} />
</label>
</div> </div>
))} ))}
</div> </div>
<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">
@@ -1030,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>
@@ -1057,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>
@@ -1077,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}
@@ -1085,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"
@@ -1107,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"
> >

View File

@@ -1,11 +1,11 @@
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 { 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';
import { consumeSupplyLotForProduction, exportToCSV, fetchProductionOrders, fetchSupplySummary, updateProductionOrderExecution, updateProductionOrderStatus } from '../dataService'; import { addProductionOrderDefect, consumeSupplyLotForProduction, exportToCSV, fetchProductionOrders, fetchProductionWorkstations, fetchSupplySummary, updateProductionOrderExecution, updateProductionOrderStatus } from '../dataService';
import type { DateRange, ProductionOrderItem, ProductionOrderStatus, ProductionOrderSummary, SupplyLot } from '../types'; import type { DateRange, ProductionOrderItem, ProductionOrderStatus, ProductionOrderSummary, ProductionWorkstation, SupplyLot } from '../types';
type ProductionOrderStatusTab = 'all' | 'open' | 'cutting' | 'sewing' | 'review' | 'finished' | 'canceled'; type ProductionOrderStatusTab = 'all' | 'open' | 'cutting' | 'sewing' | 'review' | 'finished' | 'canceled';
@@ -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">
@@ -140,11 +165,14 @@ const ProductionOrders = () => {
const [statusFilter, setStatusFilter] = useState<ProductionOrderStatusTab>('all'); const [statusFilter, setStatusFilter] = useState<ProductionOrderStatusTab>('all');
const [summary, setSummary] = useState<ProductionOrderSummary>(emptySummary); const [summary, setSummary] = useState<ProductionOrderSummary>(emptySummary);
const [supplyLots, setSupplyLots] = useState<SupplyLot[]>([]); const [supplyLots, setSupplyLots] = useState<SupplyLot[]>([]);
const [workstations, setWorkstations] = useState<ProductionWorkstation[]>([]);
const [isLoading, setIsLoading] = useState(true); const [isLoading, setIsLoading] = useState(true);
const [isSupplyBusy, setIsSupplyBusy] = useState(false); const [isSupplyBusy, setIsSupplyBusy] = useState(false);
const [busyExecutionOrderId, setBusyExecutionOrderId] = useState<number | null>(null); const [busyExecutionOrderId, setBusyExecutionOrderId] = useState<number | null>(null);
const [supplyMessage, setSupplyMessage] = useState(''); const [supplyMessage, setSupplyMessage] = useState('');
const [executionMessage, setExecutionMessage] = useState(''); const [executionMessage, setExecutionMessage] = useState('');
const [busyDefectOrderId, setBusyDefectOrderId] = useState<number | null>(null);
const [defectMessage, setDefectMessage] = useState('');
const [busyStatusOrderId, setBusyStatusOrderId] = useState<number | null>(null); const [busyStatusOrderId, setBusyStatusOrderId] = useState<number | null>(null);
const [statusMessage, setStatusMessage] = useState(''); const [statusMessage, setStatusMessage] = useState('');
const [selectedOrderId, setSelectedOrderId] = useState<number | null>(null); const [selectedOrderId, setSelectedOrderId] = useState<number | null>(null);
@@ -169,13 +197,15 @@ const ProductionOrders = () => {
const load = async () => { const load = async () => {
setIsLoading(true); setIsLoading(true);
const [nextSummary, nextSupplySummary] = await Promise.all([ const [nextSummary, nextSupplySummary, nextWorkstations] = await Promise.all([
fetchProductionOrders(dateRange, { search: searchTerm }), fetchProductionOrders(dateRange, { search: searchTerm }),
fetchSupplySummary() fetchSupplySummary(),
fetchProductionWorkstations()
]); ]);
if (isMounted) { if (isMounted) {
setSummary(nextSummary); setSummary(nextSummary);
setSupplyLots(nextSupplySummary.lots); setSupplyLots(nextSupplySummary.lots);
setWorkstations(nextWorkstations);
setIsLoading(false); setIsLoading(false);
} }
}; };
@@ -211,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 });
@@ -237,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;
@@ -302,6 +335,7 @@ 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(),
markerReference: String(form.get('markerReference') || '').trim(),
}); });
const nextSummary = await fetchProductionOrders(dateRange, { search: searchTerm }, { force: true }); const nextSummary = await fetchProductionOrders(dateRange, { search: searchTerm }, { force: true });
setSummary(nextSummary); setSummary(nextSummary);
@@ -313,6 +347,35 @@ const ProductionOrders = () => {
} }
}; };
const handleDefectSave = async (event: FormEvent<HTMLFormElement>, order: ProductionOrderItem) => {
event.preventDefault();
const form = new FormData(event.currentTarget);
const quantity = Number(String(form.get('quantity') || '').replace(',', '.'));
const workstationId = Number(form.get('workstationId'));
if (!workstationId || !Number.isFinite(quantity) || quantity <= 0) return;
setBusyDefectOrderId(order.id);
setDefectMessage('');
try {
await addProductionOrderDefect(order.id, {
workstationId,
defectType: String(form.get('defectType') || '').trim(),
origin: String(form.get('origin') || '').trim(),
quantity,
lotCode: String(form.get('lotCode') || '').trim(),
notes: String(form.get('notes') || '').trim(),
});
event.currentTarget.reset();
const nextSummary = await fetchProductionOrders(dateRange, { search: searchTerm }, { force: true });
setSummary(nextSummary);
setDefectMessage(`Defeito registrado na OP ${order.number || `#${order.id}`}.`);
} catch (error) {
setDefectMessage(error instanceof Error ? error.message : 'Não foi possível registrar o defeito.');
} finally {
setBusyDefectOrderId(null);
}
};
const printProductionSheet = (order: ProductionOrderItem) => { const printProductionSheet = (order: ProductionOrderItem) => {
const printWindow = window.open('', '_blank', 'noopener,noreferrer'); const printWindow = window.open('', '_blank', 'noopener,noreferrer');
if (!printWindow) { if (!printWindow) {
@@ -344,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>
@@ -363,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">
@@ -372,25 +429,41 @@ 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 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-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>}
{(canRecordOperationalData || order.defects.length > 0) && <section>
<div>
<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>
</div>
{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">
<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">Quantidade<input required name="quantity" inputMode="decimal" 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">Lote (opcional)<input name="lotCode" defaultValue={order.lotCode} 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 lote" /></label>
<label className="text-xs font-bold text-dark-muted sm:col-span-2">Origem (opcional)<input name="origin" 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.: recebimento, corte, costura" /></label>
<label className="text-xs font-bold text-dark-muted sm:col-span-2">Observação (opcional)<input name="notes" 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="Detalhe necessário para a revisão" /></label>
</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>}
</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 && 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>
<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>
@@ -402,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>
@@ -421,8 +494,8 @@ 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>
<Link to="/production-plans" className="mt-3 inline-flex items-center gap-2 text-sm font-bold text-brand-primary transition-colors hover:text-brand-primary/80"><ClipboardList className="h-4 w-4" />Ver planos de produção</Link> <div 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
dateRange={dateRange} dateRange={dateRange}
@@ -484,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" />
@@ -601,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>
@@ -626,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>
@@ -660,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}
@@ -675,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>
); );
}; };

View File

@@ -1,8 +1,8 @@
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, ClipboardList, Factory, Loader2, PlayCircle, UserRound, 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, updateProductionPlan, updateProductionPlanStatus } from '../dataService'; import { fetchOperationalUsers, fetchProductionPlans, saveProductionPlanReview, updateProductionPlan, updateProductionPlanStatus } from '../dataService';
import type { OperationalUser, ProductionOrderStatus, ProductionPlan } from '../types'; import type { OperationalUser, ProductionOrderStatus, ProductionPlan, ProductionPlanReviewDecision } from '../types';
const statusOptions: Array<{ value: ProductionOrderStatus; label: string; className: string }> = [ const statusOptions: Array<{ value: ProductionOrderStatus; label: string; className: string }> = [
{ value: 'open', label: 'Em aberto', className: 'border-amber-400/35 bg-amber-400/10 text-amber-300' }, { value: 'open', label: 'Em aberto', className: 'border-amber-400/35 bg-amber-400/10 text-amber-300' },
@@ -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,9 +37,23 @@ 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 [isReviewUserPickerOpen, setIsReviewUserPickerOpen] = useState(false);
const [statusFilter, setStatusFilter] = useState<ProductionPlanStatusTab>('all');
const [reviewForm, setReviewForm] = useState({
decision: 'pending' as ProductionPlanReviewDecision,
materialsReserved: false,
cutSpecificationReady: false,
sizeAndColorReviewed: false,
responsibleAndDateDefined: false,
notes: '',
reviewedBy: '',
});
const [message, setMessage] = useState(''); const [message, setMessage] = useState('');
const loadPlans = useCallback(async () => { const loadPlans = useCallback(async () => {
@@ -42,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(() => {
@@ -50,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;
@@ -92,15 +152,86 @@ 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 checklist = plan.review?.checklist || {};
setReviewForm({
decision: plan.review?.decision || 'pending',
materialsReserved: Boolean(checklist.materialsReserved),
cutSpecificationReady: Boolean(checklist.cutSpecificationReady),
sizeAndColorReviewed: Boolean(checklist.sizeAndColorReviewed),
responsibleAndDateDefined: Boolean(checklist.responsibleAndDateDefined),
notes: plan.review?.notes || '',
reviewedBy: plan.review?.reviewedBy || plan.responsibleName || '',
});
setResponsiblePickerPlanId(null);
setIsReviewUserPickerOpen(false);
setReviewingPlanId(plan.id);
};
const submitReview = async (plan: ProductionPlan) => {
setBusyPlanId(plan.id);
setMessage('');
try {
const updated = await saveProductionPlanReview(plan.id, {
decision: reviewForm.decision,
checklist: {
materialsReserved: reviewForm.materialsReserved,
cutSpecificationReady: reviewForm.cutSpecificationReady,
sizeAndColorReviewed: reviewForm.sizeAndColorReviewed,
responsibleAndDateDefined: reviewForm.responsibleAndDateDefined,
},
notes: reviewForm.notes,
reviewedBy: reviewForm.reviewedBy,
});
setPlans(current => current.map(item => item.id === updated.id ? updated : item));
setReviewingPlanId(null);
setMessage(updated.review?.decision === 'approved' ? `${updated.number} liberado para operação.` : `Revisão operacional de ${updated.number} registrada.`);
} catch (error) {
setMessage(error instanceof Error ? error.message : 'Não foi possível salvar a revisão.');
} finally {
setBusyPlanId(null);
}
};
const reviewDecisionLabel: Record<ProductionPlanReviewDecision, string> = { pending: 'Em revisão', approved: 'Liberado', adjustments_required: 'Ajustes necessários' };
const reviewDecisionStyle = (decision?: ProductionPlanReviewDecision) => decision === 'approved'
? 'border-emerald-400/30 bg-emerald-400/10 text-emerald-300'
: decision === 'adjustments_required'
? 'border-amber-400/30 bg-amber-400/10 text-amber-300'
: '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">
@@ -111,28 +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="grid gap-4 p-5 xl:grid-cols-[minmax(240px,1.3fr)_minmax(150px,.7fr)_minmax(160px,.7fr)_minmax(190px,.75fr)_180px] xl:items-center"> <div key={plan.id} className="p-5">
<div className="min-w-0"><div className="flex flex-wrap items-center gap-2"><span className="font-mono text-xs font-bold text-brand-primary">{plan.number}</span><span className={`rounded-full border px-2.5 py-1 text-xs font-bold ${statusStyle(plan.status)}`}>{plan.statusLabel}</span></div><p className="mt-2 truncate font-bold text-dark-text" title={plan.name}>{plan.name}</p><p className="mt-1 text-xs font-semibold text-dark-muted">{plan.responsibleName ? `Responsável: ${plan.responsibleName}` : plan.contractor ? `Responsável: ${plan.contractor}` : 'Sem responsável definido'} · previsão {formatDate(plan.expectedDate)}</p>{plan.notes && <p className="mt-1 truncate text-xs font-semibold text-dark-muted" title={plan.notes}>{plan.notes}</p>}</div> <div className="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="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="mt-4 grid gap-3 border-t border-dark-border pt-4 sm:grid-cols-2 xl:grid-cols-4">
<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">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="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"><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>
<label className="text-xs font-bold text-dark-muted">Mover plano<select value={plan.status} disabled={busyPlanId === plan.id} onChange={(event) => void movePlan(plan, event.target.value)} className="mt-1 h-10 w-full rounded-lg border border-dark-border bg-dark-input px-3 text-sm font-bold text-dark-text outline-none focus:border-brand-primary disabled:opacity-60 cursor-pointer"><option value={plan.status}>{busyPlanId === plan.id ? 'Atualizando...' : plan.statusLabel}</option>{statusOptions.filter(option => option.value !== plan.status).map(option => <option key={option.value} value={option.value}>{option.label}</option>)}</select></label> <div 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="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 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>
); );
}; };

View File

@@ -0,0 +1,161 @@
import { type FormEvent, useCallback, useEffect, useMemo, useState } from 'react';
import { Link, useOutletContext } from 'react-router-dom';
import { AlertTriangle, ArrowLeft, ClipboardCheck, Factory, Loader2, Pencil, Plus, Save, Settings2, X } from 'lucide-react';
import DateRangePicker from '../components/DateRangePicker';
import { createProductionWorkstation, fetchProductionOrders, fetchProductionWorkstations, updateProductionWorkstation } from '../dataService';
import type { DateRange, ProductionOrderSummary, ProductionWorkstation } from '../types';
const emptySummary: ProductionOrderSummary = {
orders: [],
counts: { all: 0, open: 0, cutting: 0, sewing: 0, review: 0, finished: 0, canceled: 0 }
};
const inputClassName = 'mt-1 h-10 w-full rounded-lg border border-dark-border bg-dark-input px-3 text-sm font-semibold text-dark-text outline-none placeholder:text-dark-muted focus:border-brand-primary';
const formatQuantity = (value: number) => new Intl.NumberFormat('pt-BR', { maximumFractionDigits: 2 }).format(value);
const formatDateTime = (value: string | null) => value ? new Intl.DateTimeFormat('pt-BR', { dateStyle: 'short', timeStyle: 'short' }).format(new Date(value)) : '—';
const ProductionQuality = () => {
const { dateRange, setDateRange, refreshInterval, setRefreshInterval } = useOutletContext<{
dateRange: DateRange;
setDateRange: (range: DateRange) => void;
refreshInterval: number;
setRefreshInterval: (interval: number) => void;
}>();
const [summary, setSummary] = useState<ProductionOrderSummary>(emptySummary);
const [workstations, setWorkstations] = useState<ProductionWorkstation[]>([]);
const [isLoading, setIsLoading] = useState(true);
const [isSaving, setIsSaving] = useState(false);
const [message, setMessage] = useState('');
const [editingStation, setEditingStation] = useState<ProductionWorkstation | null>(null);
const [stationForm, setStationForm] = useState({ name: '', code: '', color: '#38bdf8', sortOrder: '50', isActive: true });
const load = useCallback(async () => {
setIsLoading(true);
try {
const [nextSummary, nextWorkstations] = await Promise.all([
fetchProductionOrders(dateRange, undefined, { force: true }),
fetchProductionWorkstations(true)
]);
setSummary(nextSummary);
setWorkstations(nextWorkstations);
} catch (error) {
setMessage(error instanceof Error ? error.message : 'Não foi possível carregar a qualidade da produção.');
} finally {
setIsLoading(false);
}
}, [dateRange]);
useEffect(() => {
const timer = window.setTimeout(() => void load(), 0);
return () => window.clearTimeout(timer);
}, [load]);
useEffect(() => {
if (!refreshInterval) return undefined;
const interval = window.setInterval(() => void load(), refreshInterval);
return () => window.clearInterval(interval);
}, [load, refreshInterval]);
const defects = useMemo(() => summary.orders.flatMap(order => order.defects.map(defect => ({
...defect,
orderId: order.id,
orderNumber: order.number || `OP #${order.id}`,
productDescription: order.productDescription,
unit: order.unit
}))), [summary.orders]);
const totalDefectiveQuantity = useMemo(() => defects.reduce((total, defect) => total + defect.quantity, 0), [defects]);
const affectedOrders = useMemo(() => new Set(defects.map(defect => defect.orderId)).size, [defects]);
const fabricHoleDefects = useMemo(() => defects.filter(defect => /\b(?:furo|furos|furado|perfura[çc][ãa]o)\b/i.test(defect.defectType)), [defects]);
const fabricHoleQuantity = useMemo(() => fabricHoleDefects.reduce((total, defect) => total + defect.quantity, 0), [fabricHoleDefects]);
const fabricHoleLots = useMemo(() => new Set(fabricHoleDefects.map(defect => defect.lotCode).filter(Boolean)).size, [fabricHoleDefects]);
const workstationSummary = useMemo(() => {
const grouped = new Map<string, { label: string; quantity: number; occurrences: number }>();
defects.forEach(defect => {
const label = defect.workstationName || 'Sem posto';
const current = grouped.get(label) || { label, quantity: 0, occurrences: 0 };
current.quantity += defect.quantity;
current.occurrences += 1;
grouped.set(label, current);
});
return [...grouped.values()].sort((a, b) => b.quantity - a.quantity);
}, [defects]);
const defectTypeSummary = useMemo(() => {
const grouped = new Map<string, { label: string; quantity: number; occurrences: number }>();
defects.forEach(defect => {
const label = defect.defectType || 'Sem tipo';
const current = grouped.get(label) || { label, quantity: 0, occurrences: 0 };
current.quantity += defect.quantity;
current.occurrences += 1;
grouped.set(label, current);
});
return [...grouped.values()].sort((a, b) => b.quantity - a.quantity);
}, [defects]);
const recentDefects = useMemo(() => [...defects].sort((a, b) => new Date(b.occurredAt || 0).getTime() - new Date(a.occurredAt || 0).getTime()).slice(0, 12), [defects]);
const resetStationForm = () => {
setEditingStation(null);
setStationForm({ name: '', code: '', color: '#38bdf8', sortOrder: String((workstations.at(-1)?.sortOrder || 0) + 10), isActive: true });
};
const editStation = (station: ProductionWorkstation) => {
setEditingStation(station);
setStationForm({ name: station.name, code: station.code, color: station.color, sortOrder: String(station.sortOrder), isActive: station.isActive });
};
const saveStation = async (event: FormEvent<HTMLFormElement>) => {
event.preventDefault();
setIsSaving(true);
setMessage('');
try {
const payload = { ...stationForm, sortOrder: Number(stationForm.sortOrder || 0) };
if (editingStation) await updateProductionWorkstation(editingStation.id, payload);
else await createProductionWorkstation(payload);
await load();
setMessage(editingStation ? 'Posto de trabalho atualizado.' : 'Posto de trabalho criado.');
resetStationForm();
} catch (error) {
setMessage(error instanceof Error ? error.message : 'Não foi possível salvar o posto de trabalho.');
} finally {
setIsSaving(false);
}
};
return (
<div className="space-y-6">
<div className="flex flex-col gap-4 xl:flex-row xl:items-end xl:justify-between">
<div>
<Link to="/production-orders" className="mb-3 inline-flex items-center gap-2 text-sm font-bold text-dark-muted transition-colors hover:text-dark-text"><ArrowLeft className="h-4 w-4" />Ordens de Produção</Link>
<h1 className="text-2xl font-bold text-dark-text">Qualidade e postos</h1>
<p className="mt-2 font-medium text-dark-muted">Acompanhe defeitos por origem e mantenha as etapas reais da operação.</p>
</div>
<DateRangePicker dateRange={dateRange} onChange={setDateRange} refreshInterval={refreshInterval} setRefreshInterval={setRefreshInterval} onManualRefresh={() => void load()} />
</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>}
{isLoading ? <div className="flex min-h-72 items-center justify-center rounded-2xl border border-dark-border bg-dark-card text-brand-primary"><Loader2 className="h-8 w-8 animate-spin" /></div> : <>
<div className="grid grid-cols-1 gap-4 md:grid-cols-2 xl:grid-cols-4">
<div className="rounded-2xl border border-dark-border bg-dark-card p-5 shadow-sm"><div className="flex items-center justify-between"><p className="text-xs font-bold uppercase tracking-widest text-dark-muted">Defeitos registrados</p><AlertTriangle className="h-5 w-5 text-amber-300" /></div><p className="mt-3 text-3xl font-bold text-dark-text">{defects.length}</p><p className="mt-1 text-xs font-semibold text-dark-muted">Ocorrências no período</p></div>
<div className="rounded-2xl border border-dark-border bg-dark-card p-5 shadow-sm"><div className="flex items-center justify-between"><p className="text-xs font-bold uppercase tracking-widest text-dark-muted">Peças afetadas</p><ClipboardCheck className="h-5 w-5 text-amber-300" /></div><p className="mt-3 text-3xl font-bold text-amber-200">{formatQuantity(totalDefectiveQuantity)}</p><p className="mt-1 text-xs font-semibold text-dark-muted">Somadas pelos apontamentos</p></div>
<div className="rounded-2xl border border-dark-border bg-dark-card p-5 shadow-sm"><div className="flex items-center justify-between"><p className="text-xs font-bold uppercase tracking-widest text-dark-muted">OPs com ocorrência</p><Factory className="h-5 w-5 text-violet-300" /></div><p className="mt-3 text-3xl font-bold text-dark-text">{affectedOrders}</p><p className="mt-1 text-xs font-semibold text-dark-muted">De {summary.orders.length} OPs no período</p></div>
<div className="rounded-2xl border border-amber-400/25 bg-amber-400/[0.045] p-5 shadow-sm"><div className="flex items-center justify-between"><p className="text-xs font-bold uppercase tracking-widest text-amber-200">Furos na malha</p><AlertTriangle className="h-5 w-5 text-amber-300" /></div><p className="mt-3 text-3xl font-bold text-amber-200">{formatQuantity(fabricHoleQuantity)}</p><p className="mt-1 text-xs font-semibold text-dark-muted">{fabricHoleDefects.length} apontamentos · {fabricHoleLots} {fabricHoleLots === 1 ? 'lote' : 'lotes'}</p></div>
</div>
<div className="grid grid-cols-1 gap-6 xl:grid-cols-2">
<section className="overflow-hidden rounded-2xl border border-dark-border bg-dark-card shadow-sm"><div className="border-b border-dark-border p-5"><h2 className="font-bold text-dark-text">Levantamento de defeitos</h2><p className="mt-1 text-xs font-semibold text-dark-muted">Priorize o maior impacto antes de atacar ocorrências isoladas.</p></div><div className="divide-y divide-dark-border">{workstationSummary.length ? workstationSummary.map(item => <div key={item.label} className="flex items-center justify-between gap-4 p-4"><div><p className="text-sm font-bold text-dark-text">{item.label}</p><p className="mt-1 text-xs font-semibold text-dark-muted">{item.occurrences} {item.occurrences === 1 ? 'ocorrência' : 'ocorrências'}</p></div><p className="text-lg font-bold text-amber-200">{formatQuantity(item.quantity)}</p></div>) : <p className="p-5 text-sm font-semibold text-dark-muted">Nenhum defeito registrado no período.</p>}</div></section>
<section className="overflow-hidden rounded-2xl border border-dark-border bg-dark-card shadow-sm"><div className="border-b border-dark-border p-5"><h2 className="font-bold text-dark-text">Tipos mais recorrentes</h2><p className="mt-1 text-xs font-semibold text-dark-muted">Use estes dados para orientar revisão, fornecedor e correção de processo.</p></div><div className="divide-y divide-dark-border">{defectTypeSummary.length ? defectTypeSummary.map(item => <div key={item.label} className="flex items-center justify-between gap-4 p-4"><div><p className="text-sm font-bold text-dark-text">{item.label}</p><p className="mt-1 text-xs font-semibold text-dark-muted">{item.occurrences} {item.occurrences === 1 ? 'ocorrência' : 'ocorrências'}</p></div><p className="text-lg font-bold text-amber-200">{formatQuantity(item.quantity)}</p></div>) : <p className="p-5 text-sm font-semibold text-dark-muted">Nenhum tipo de defeito registrado no período.</p>}</div></section>
</div>
<section className="overflow-hidden rounded-2xl border border-dark-border bg-dark-card shadow-sm"><div className="flex flex-col gap-2 border-b border-dark-border p-5 sm:flex-row sm:items-start sm:justify-between"><div><h2 className="font-bold text-dark-text">Furos na malha</h2><p className="mt-1 text-xs font-semibold text-dark-muted">Apontamentos cujo tipo de defeito contém “furo”, “furado” ou “perfuração”. Registre sempre o lote para identificar a origem.</p></div><span className="w-fit rounded-full border border-amber-400/30 bg-amber-400/10 px-2.5 py-1 text-xs font-bold text-amber-300">{formatQuantity(fabricHoleQuantity)} peças</span></div><div className="divide-y divide-dark-border">{fabricHoleDefects.length ? fabricHoleDefects.slice(0, 10).map(defect => <div key={defect.id} className="grid gap-3 p-4 sm:grid-cols-[minmax(0,1fr)_auto] sm:items-center"><div><div className="flex flex-wrap items-center gap-2"><span className="font-mono text-xs font-bold text-brand-primary">{defect.orderNumber}</span>{defect.lotCode && <span className="rounded-full border border-dark-border bg-dark-input px-2 py-0.5 text-[10px] font-bold text-dark-muted">Lote {defect.lotCode}</span>}</div><p className="mt-2 text-sm font-bold text-dark-text">{defect.productDescription}</p><p className="mt-1 text-xs font-semibold text-dark-muted">{defect.workstationName || 'Sem posto'} · {defect.origin || 'origem não informada'}</p></div><p className="text-sm font-bold text-amber-200">{formatQuantity(defect.quantity)} {defect.unit}</p></div>) : <p className="p-5 text-sm font-semibold text-dark-muted">Nenhum furo de malha registrado no período. Ao apontar o defeito na OP, use “Furo na malha” e informe o lote.</p>}</div></section>
<div className="grid grid-cols-1 gap-6 xl:grid-cols-[minmax(0,1.2fr)_minmax(380px,.8fr)]">
<section className="overflow-hidden rounded-2xl border border-dark-border bg-dark-card shadow-sm"><div className="border-b border-dark-border p-5"><h2 className="font-bold text-dark-text">Ocorrências recentes</h2><p className="mt-1 text-xs font-semibold text-dark-muted">Rastreabilidade por OP, produto, posto e lote.</p></div><div className="divide-y divide-dark-border">{recentDefects.length ? recentDefects.map(defect => <div key={defect.id} className="grid gap-3 p-4 sm:grid-cols-[minmax(0,1fr)_auto] sm:items-start"><div><div className="flex flex-wrap items-center gap-2"><span className="font-mono text-xs font-bold text-brand-primary">{defect.orderNumber}</span><span className="rounded-full border border-dark-border bg-dark-input px-2 py-0.5 text-[10px] font-bold text-dark-muted">{defect.workstationName || 'Sem posto'}</span></div><p className="mt-2 text-sm font-bold text-dark-text">{defect.defectType}</p><p className="mt-1 text-xs font-semibold text-dark-muted">{defect.productDescription} · {defect.origin || 'origem não informada'}{defect.lotCode ? ` · lote ${defect.lotCode}` : ''}</p>{defect.notes && <p className="mt-1 text-xs font-medium text-dark-muted">{defect.notes}</p>}</div><div className="text-right"><p className="text-sm font-bold text-amber-200">{formatQuantity(defect.quantity)} {defect.unit}</p><p className="mt-1 text-[11px] font-semibold text-dark-muted">{formatDateTime(defect.occurredAt)}</p></div></div>) : <p className="p-5 text-sm font-semibold text-dark-muted">Nenhuma ocorrência registrada no período.</p>}</div></section>
<section className="rounded-2xl border border-dark-border bg-dark-card p-5 shadow-sm"><div className="flex items-center justify-between gap-3"><div><h2 className="font-bold text-dark-text">Postos de trabalho</h2><p className="mt-1 text-xs font-semibold text-dark-muted">Etapas disponíveis para o apontamento da operação.</p></div><Settings2 className="h-5 w-5 text-brand-primary" /></div><form onSubmit={saveStation} className="mt-5 rounded-xl border border-dark-border bg-dark-input/40 p-3"><div className="flex items-center justify-between gap-3"><p className="text-xs font-bold text-dark-text">{editingStation ? 'Editar posto' : 'Novo posto'}</p>{editingStation && <button type="button" onClick={resetStationForm} className="inline-flex h-7 items-center gap-1 text-xs font-bold text-dark-muted hover:text-dark-text cursor-pointer"><X className="h-3.5 w-3.5" />Cancelar</button>}</div><div className="mt-3 grid grid-cols-1 gap-3 sm:grid-cols-2 xl:grid-cols-1"><label className="text-xs font-bold text-dark-muted">Nome<input required value={stationForm.name} onChange={(event) => setStationForm(current => ({ ...current, name: event.target.value }))} className={inputClassName} placeholder="Ex.: Bordado" /></label><label className="text-xs font-bold text-dark-muted">Código (opcional)<input value={stationForm.code} onChange={(event) => setStationForm(current => ({ ...current, code: event.target.value }))} className={inputClassName} placeholder="bordado" /></label><div className="grid grid-cols-2 gap-3"><label className="text-xs font-bold text-dark-muted">Ordem<input inputMode="numeric" value={stationForm.sortOrder} onChange={(event) => setStationForm(current => ({ ...current, sortOrder: event.target.value }))} className={inputClassName} /></label><label className="text-xs font-bold text-dark-muted">Cor<input type="color" value={stationForm.color} onChange={(event) => setStationForm(current => ({ ...current, color: event.target.value }))} className="mt-1 h-10 w-full cursor-pointer rounded-lg border border-dark-border bg-dark-input p-1" /></label></div><label className="flex items-center gap-2 text-xs font-bold text-dark-muted"><input type="checkbox" checked={stationForm.isActive} onChange={(event) => setStationForm(current => ({ ...current, isActive: event.target.checked }))} className="h-4 w-4 accent-brand-primary" />Disponível para novos apontamentos</label></div><button type="submit" disabled={isSaving} className="mt-4 inline-flex h-10 w-full items-center justify-center gap-2 rounded-lg border border-brand-primary/35 bg-brand-primary/10 px-3 text-sm font-bold text-brand-primary transition-colors hover:bg-brand-primary/20 disabled:opacity-50 cursor-pointer">{editingStation ? <Save className="h-4 w-4" /> : <Plus className="h-4 w-4" />}{isSaving ? 'Salvando...' : editingStation ? 'Salvar posto' : 'Adicionar posto'}</button></form><div className="mt-4 divide-y divide-dark-border rounded-xl border border-dark-border">{workstations.map(station => <div key={station.id} className="flex items-center gap-3 p-3"><span className="h-3 w-3 shrink-0 rounded-full" style={{ backgroundColor: station.color }} /><div className="min-w-0 flex-1"><p className="truncate text-sm font-bold text-dark-text">{station.name}</p><p className="text-xs font-semibold text-dark-muted">{station.code} · posição {station.sortOrder}{station.isActive ? '' : ' · inativo'}</p></div><button type="button" onClick={() => editStation(station)} className="inline-flex h-8 w-8 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={`Editar ${station.name}`}><Pencil className="h-3.5 w-3.5" /></button></div>)}</div></section>
</div>
</>}
</div>
);
};
export default ProductionQuality;

View File

@@ -197,6 +197,7 @@ const Registrations = () => {
const [isLoading, setIsLoading] = useState(true); const [isLoading, setIsLoading] = useState(true);
const [activeTab, setActiveTab] = useState<RegistrationTab>('products'); const [activeTab, setActiveTab] = useState<RegistrationTab>('products');
const [productFilter, setProductFilter] = useState<'all' | CatalogProductType>('all'); const [productFilter, setProductFilter] = useState<'all' | CatalogProductType>('all');
const [referenceSearch, setReferenceSearch] = useState('');
const [pagination, setPagination] = useState<RegistrationPagination>({ const [pagination, setPagination] = useState<RegistrationPagination>({
products: { page: 1, pageSize: 10 }, products: { page: 1, pageSize: 10 },
categories: { page: 1, pageSize: 10 }, categories: { page: 1, pageSize: 10 },
@@ -381,10 +382,20 @@ const Registrations = () => {
const categoryStartIndex = (safeCategoryPage - 1) * pagination.categories.pageSize; const categoryStartIndex = (safeCategoryPage - 1) * pagination.categories.pageSize;
const paginatedCategories = catalog.categories.slice(categoryStartIndex, categoryStartIndex + pagination.categories.pageSize); const paginatedCategories = catalog.categories.slice(categoryStartIndex, categoryStartIndex + pagination.categories.pageSize);
const referenceTotalPages = Math.ceil(catalog.consumptionReferences.length / pagination.references.pageSize); const filteredReferences = useMemo(() => {
const query = referenceSearch.trim().toLocaleLowerCase('pt-BR');
if (!query) return catalog.consumptionReferences;
return catalog.consumptionReferences.filter(reference => (
`${reference.productSku} ${reference.productName} ${reference.materialSku} ${reference.materialName} ${reference.color}`
.toLocaleLowerCase('pt-BR')
.includes(query)
));
}, [catalog.consumptionReferences, referenceSearch]);
const referenceTotalPages = Math.ceil(filteredReferences.length / pagination.references.pageSize);
const safeReferencePage = Math.min(pagination.references.page, referenceTotalPages || 1); const safeReferencePage = Math.min(pagination.references.page, referenceTotalPages || 1);
const referenceStartIndex = (safeReferencePage - 1) * pagination.references.pageSize; const referenceStartIndex = (safeReferencePage - 1) * pagination.references.pageSize;
const paginatedReferences = catalog.consumptionReferences.slice(referenceStartIndex, referenceStartIndex + pagination.references.pageSize); const paginatedReferences = filteredReferences.slice(referenceStartIndex, referenceStartIndex + pagination.references.pageSize);
const runAction = async (action: () => Promise<void>, successMessage: string) => { const runAction = async (action: () => Promise<void>, successMessage: string) => {
setStatus('saving'); setStatus('saving');
@@ -985,7 +996,7 @@ const Registrations = () => {
)} )}
{activeTab === 'references' && ( {activeTab === 'references' && (
<div className="grid grid-cols-1 gap-6 xl:grid-cols-[1fr_420px]"> <div className="grid grid-cols-1 gap-6 xl:grid-cols-2">
<div className={listPanelClassName}> <div className={listPanelClassName}>
<div className={listHeaderClassName}> <div className={listHeaderClassName}>
<div> <div>
@@ -993,9 +1004,23 @@ const Registrations = () => {
<p className="mt-1 text-xs font-semibold text-dark-muted">Rendimento pç/kg por produto, malha, cor e tamanho.</p> <p className="mt-1 text-xs font-semibold text-dark-muted">Rendimento pç/kg por produto, malha, cor e tamanho.</p>
</div> </div>
<span className="rounded-full border border-dark-border bg-dark-input px-3 py-1 text-xs font-bold text-dark-muted"> <span className="rounded-full border border-dark-border bg-dark-input px-3 py-1 text-xs font-bold text-dark-muted">
{catalog.consumptionReferences.length} referencias {referenceSearch ? `${filteredReferences.length} de ${catalog.consumptionReferences.length}` : catalog.consumptionReferences.length} referencias
</span> </span>
</div> </div>
<div className="border-b border-dark-border p-4">
<label className="relative block">
<Search className="pointer-events-none absolute left-3 top-3 h-4 w-4 text-dark-muted" />
<input
value={referenceSearch}
onChange={(event) => {
setReferenceSearch(event.target.value);
updatePagination('references', { page: 1 });
}}
placeholder="Buscar por produto, SKU ou material..."
className={`${inputClassName} pl-9`}
/>
</label>
</div>
<div className="divide-y divide-dark-border"> <div className="divide-y divide-dark-border">
{paginatedReferences.map(reference => ( {paginatedReferences.map(reference => (
<div key={reference.id} className="flex flex-col gap-3 p-4 md:flex-row md:items-start md:justify-between"> <div key={reference.id} className="flex flex-col gap-3 p-4 md:flex-row md:items-start md:justify-between">
@@ -1054,14 +1079,14 @@ const Registrations = () => {
</div> </div>
</div> </div>
))} ))}
{!catalog.consumptionReferences.length && ( {!filteredReferences.length && (
<div className={emptyStateClassName}> <div className={emptyStateClassName}>
Nenhuma referencia cadastrada. {referenceSearch ? 'Nenhuma referência encontrada.' : 'Nenhuma referencia cadastrada.'}
</div> </div>
)} )}
</div> </div>
<PaginationControls <PaginationControls
totalItems={catalog.consumptionReferences.length} totalItems={filteredReferences.length}
currentPage={safeReferencePage} currentPage={safeReferencePage}
totalPages={referenceTotalPages} totalPages={referenceTotalPages}
pageSize={pagination.references.pageSize} pageSize={pagination.references.pageSize}
@@ -1069,9 +1094,10 @@ const Registrations = () => {
itemLabel="referências" itemLabel="referências"
pageSizeLabel="por página" pageSizeLabel="por página"
startIndex={referenceStartIndex} startIndex={referenceStartIndex}
endIndex={Math.min(referenceStartIndex + pagination.references.pageSize, catalog.consumptionReferences.length)} endIndex={Math.min(referenceStartIndex + pagination.references.pageSize, filteredReferences.length)}
onPageChange={(page) => updatePagination('references', { page })} onPageChange={(page) => updatePagination('references', { page })}
onPageSizeChange={(pageSize) => updatePagination('references', { page: 1, pageSize })} onPageSizeChange={(pageSize) => updatePagination('references', { page: 1, pageSize })}
stacked
/> />
</div> </div>

File diff suppressed because one or more lines are too long

View File

@@ -90,6 +90,44 @@ export interface ProductionOrderLotReservation {
lotSupplier: string; lotSupplier: string;
} }
export interface ProductionWorkstation {
id: number;
code: string;
name: string;
color: string;
sortOrder: number;
isActive: boolean;
}
export type ProductionWorkstationPayload = {
code?: string;
name: string;
color?: string;
sortOrder?: number;
isActive?: boolean;
};
export interface ProductionOrderDefect {
id: number;
workstationId: number | null;
workstationName: string;
defectType: string;
origin: string;
quantity: number;
lotCode: string;
notes: string;
occurredAt: string | null;
}
export type ProductionOrderDefectPayload = {
workstationId?: number | null;
defectType: string;
origin?: string;
quantity: number;
lotCode?: string;
notes?: string;
};
export interface ProductionOrderItem { export interface ProductionOrderItem {
id: number; id: number;
tinyId: string; tinyId: string;
@@ -121,6 +159,9 @@ export interface ProductionOrderItem {
rejectedQuantity: number; rejectedQuantity: number;
finishedQuantity: number | null; finishedQuantity: number | null;
qualityNotes: string; qualityNotes: string;
cutLayers: number | null;
markerReference: string;
defects: ProductionOrderDefect[];
lotReservations: ProductionOrderLotReservation[]; lotReservations: ProductionOrderLotReservation[];
markers: ProductionOrderMarker[]; markers: ProductionOrderMarker[];
components: ProductionOrderComponent[]; components: ProductionOrderComponent[];
@@ -193,10 +234,35 @@ export interface ProductionPlan {
actualCutQuantity: number; actualCutQuantity: number;
finishedQuantity: number; finishedQuantity: number;
statusCounts: Record<string, number>; statusCounts: Record<string, number>;
review: ProductionPlanReview | null;
createdAt: string | null; createdAt: string | null;
updatedAt: string | null; updatedAt: string | null;
} }
export type ProductionPlanReviewDecision = 'pending' | 'approved' | 'adjustments_required';
export interface ProductionPlanReview {
id: number;
decision: ProductionPlanReviewDecision;
checklist: {
materialsReserved?: boolean;
cutSpecificationReady?: boolean;
sizeAndColorReviewed?: boolean;
responsibleAndDateDefined?: boolean;
};
notes: string;
reviewedBy: string;
reviewedAt: string | null;
updatedAt: string | null;
}
export type ProductionPlanReviewPayload = {
decision: ProductionPlanReviewDecision;
checklist: ProductionPlanReview['checklist'];
notes?: string;
reviewedBy?: string;
};
export type ProductionPlanPayload = { export type ProductionPlanPayload = {
id?: number; id?: number;
name: string; name: string;
@@ -221,6 +287,8 @@ export type ProductionOrderExecutionPayload = {
rejectedQuantity?: number | null; rejectedQuantity?: number | null;
finishedQuantity?: number | null; finishedQuantity?: number | null;
qualityNotes?: string; qualityNotes?: string;
cutLayers?: number | null;
markerReference?: string;
}; };
export interface CreateProductionOrdersResult { export interface CreateProductionOrdersResult {
@@ -450,6 +518,9 @@ export interface SupplyReceipt {
invoice: string; invoice: string;
notes: string; notes: string;
status: SupplyReceiptStatus; status: SupplyReceiptStatus;
purchaseOrderId: number | null;
purchaseOrderNumber: string;
purchaseOrderStatus: string;
createdAt: string; createdAt: string;
updatedAt: string; updatedAt: string;
approvedAt: string | null; approvedAt: string | null;
@@ -496,7 +567,28 @@ export interface SupplyFabricPlan {
updatedAt: string; updatedAt: string;
} }
export type SupplyPurchaseOrderStatus = 'draft' | 'sent' | 'approved' | 'received' | 'canceled'; export type MaterialTransformationStage = 'knitting' | 'dyeing' | 'finishing';
export type MaterialTransformationStatus = 'planned' | 'in_progress' | 'completed' | 'canceled';
export interface SupplyMaterialTransformation {
id: number;
batchCode: string;
stage: MaterialTransformationStage;
inputMaterial: string;
inputQuantityKg: number;
outputMaterial: string;
outputColor: string;
expectedLossPercent: number;
expectedOutputKg: number;
supplier: string;
priority: string;
status: MaterialTransformationStatus;
notes: string;
createdAt: string;
updatedAt: string;
}
export type SupplyPurchaseOrderStatus = 'draft' | 'pending_financial' | 'sent' | 'approved' | 'received' | 'canceled';
export interface SupplyPurchaseOrderItem { export interface SupplyPurchaseOrderItem {
id: number; id: number;
@@ -596,6 +688,19 @@ export type SupplyFabricPlanPayload = {
priority?: string; priority?: string;
}; };
export type SupplyMaterialTransformationPayload = {
batchCode?: string;
stage: MaterialTransformationStage;
inputMaterial: string;
inputQuantityKg: number | string;
outputMaterial: string;
outputColor?: string;
expectedLossPercent?: number | string;
supplier?: string;
priority?: string;
notes?: string;
};
export type SupplyInventoryAdjustmentPayload = { export type SupplyInventoryAdjustmentPayload = {
countedQuantity: number | string; countedQuantity: number | string;
reason: string; reason: string;