Compare commits
9 Commits
38add708ff
...
e8a684b0ab
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e8a684b0ab | ||
|
|
458efa073d | ||
|
|
ddff880215 | ||
|
|
886969cce0 | ||
|
|
791246c55e | ||
|
|
442b23dc62 | ||
|
|
da6f3b2955 | ||
|
|
2c15719b13 | ||
|
|
7e65feac37 |
@@ -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(`
|
||||
ALTER TABLE production_orders
|
||||
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(`
|
||||
CREATE TABLE IF NOT EXISTS supply_purchase_orders (
|
||||
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(`
|
||||
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(`
|
||||
ALTER TABLE production_orders
|
||||
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 rejected_quantity NUMERIC(14, 4) NOT NULL DEFAULT 0,
|
||||
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(() => {});
|
||||
|
||||
await pool.query(`
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
const express = require('express');
|
||||
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();
|
||||
|
||||
@@ -35,12 +35,28 @@ router.get('/production-plans', verifyToken, async (req, res) => {
|
||||
|
||||
router.get('/production-workstations', verifyToken, async (req, res) => {
|
||||
try {
|
||||
res.json(await listProductionWorkstations());
|
||||
res.json(await listProductionWorkstations({ includeInactive: req.query?.includeInactive === 'true' }));
|
||||
} catch (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) => {
|
||||
try {
|
||||
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) => {
|
||||
try {
|
||||
res.status(201).json(await upsertTinyProductionOrderDetail(req.body || {}));
|
||||
|
||||
@@ -5,15 +5,18 @@ const {
|
||||
approveReceipt,
|
||||
consumeLotForProduction,
|
||||
createFabricPlan,
|
||||
createMaterialTransformation,
|
||||
createPurchaseOrder,
|
||||
createReceipt,
|
||||
deleteFabricPlan,
|
||||
deleteReceipt,
|
||||
getSupplySummary,
|
||||
listFabricPlans,
|
||||
listMaterialTransformations,
|
||||
listKnownSuppliers,
|
||||
listPurchaseOrders,
|
||||
saveMaterialDetails,
|
||||
updateMaterialTransformationStatus,
|
||||
updatePurchaseOrderStatus,
|
||||
listLots,
|
||||
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) => {
|
||||
try {
|
||||
res.json(await listPurchaseNeeds());
|
||||
|
||||
@@ -51,6 +51,14 @@ const formatDate = (value) => {
|
||||
|
||||
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 normalizedValue = typeof value === 'string' && value.includes(',')
|
||||
? value.replace(/\./g, '').replace(',', '.')
|
||||
@@ -314,6 +322,8 @@ const mapProductionOrderRow = (row) => {
|
||||
rejectedQuantity: Number(row.rejected_quantity || 0),
|
||||
finishedQuantity: row.finished_quantity === null ? null : Number(row.finished_quantity),
|
||||
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) : [],
|
||||
lotReservations: Array.isArray(row.lot_reservations) ? row.lot_reservations.map(mapLotReservation) : [],
|
||||
markers: Array.isArray(row.markers) ? row.markers.filter(Boolean) : [],
|
||||
@@ -343,10 +353,57 @@ const mapProductionPlanRow = (row) => ({
|
||||
actualCutQuantity: Number(row.actual_cut_quantity || 0),
|
||||
finishedQuantity: Number(row.finished_quantity || 0),
|
||||
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,
|
||||
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 = {}) => ({
|
||||
id: normalizeInteger(payload.id),
|
||||
name: normalizeText(payload.name) || normalizeText(fallback.name),
|
||||
@@ -380,12 +437,16 @@ const getProductionPlanById = async (id, client = pool) => {
|
||||
WHERE production_plan_id = plan.id
|
||||
GROUP BY status
|
||||
) 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
|
||||
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 production_plan_reviews review ON review.production_plan_id = plan.id
|
||||
WHERE plan.id = $1
|
||||
GROUP BY plan.id, responsible.id;
|
||||
GROUP BY plan.id, responsible.id, review.id;
|
||||
`, [id]);
|
||||
return result.rows[0] ? mapProductionPlanRow(result.rows[0]) : null;
|
||||
};
|
||||
@@ -467,6 +528,8 @@ const getOrderById = async (id, client = pool) => {
|
||||
po.rejected_quantity,
|
||||
po.finished_quantity,
|
||||
po.quality_notes,
|
||||
po.cut_layers,
|
||||
po.marker_reference,
|
||||
po.created_at,
|
||||
po.updated_at,
|
||||
COALESCE(
|
||||
@@ -605,6 +668,8 @@ const baseProductionOrderSelect = `
|
||||
po.rejected_quantity,
|
||||
po.finished_quantity,
|
||||
po.quality_notes,
|
||||
po.cut_layers,
|
||||
po.marker_reference,
|
||||
po.created_at,
|
||||
po.updated_at,
|
||||
COALESCE(
|
||||
@@ -781,11 +846,15 @@ const listProductionPlans = async () => {
|
||||
WHERE production_plan_id = plan.id
|
||||
GROUP BY status
|
||||
) 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
|
||||
LEFT JOIN production_orders po ON po.production_plan_id = plan.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;
|
||||
`);
|
||||
return result.rows.map(mapProductionPlanRow);
|
||||
@@ -1779,15 +1848,19 @@ const updateProductionOrderExecution = async (id, payload = {}) => {
|
||||
const rejectedQuantity = normalizeNonNegativeQuantity(payload.rejectedQuantity);
|
||||
const finishedQuantity = normalizeNonNegativeQuantity(payload.finishedQuantity);
|
||||
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.');
|
||||
error.statusCode = 400;
|
||||
throw error;
|
||||
}
|
||||
|
||||
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
|
||||
WHERE id = $1;
|
||||
`, [id]);
|
||||
@@ -1801,6 +1874,14 @@ const updateProductionOrderExecution = async (id, payload = {}) => {
|
||||
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 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) {
|
||||
const error = new Error('As perdas não podem ser maiores que a quantidade cortada.');
|
||||
@@ -1819,21 +1900,88 @@ const updateProductionOrderExecution = async (id, payload = {}) => {
|
||||
rejected_quantity = $2,
|
||||
finished_quantity = $3,
|
||||
quality_notes = NULLIF($4, ''),
|
||||
cut_layers = $5,
|
||||
marker_reference = NULLIF($6, ''),
|
||||
updated_at = CURRENT_TIMESTAMP
|
||||
WHERE id = $5;
|
||||
`, [effectiveActual, effectiveRejected, effectiveFinished, qualityNotes, id]);
|
||||
WHERE id = $7;
|
||||
`, [effectiveActual, effectiveRejected, effectiveFinished, qualityNotes, effectiveLayers, effectiveMarkerReference, 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(`
|
||||
SELECT id, code, name, color, sort_order, is_active
|
||||
FROM production_workstations
|
||||
WHERE is_active = TRUE
|
||||
${includeInactive ? '' : 'WHERE is_active = TRUE'}
|
||||
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 = {}) => {
|
||||
@@ -1864,10 +2012,12 @@ const addProductionOrderDefect = async (id, payload = {}) => {
|
||||
|
||||
module.exports = {
|
||||
createProductionOrders,
|
||||
createProductionWorkstation,
|
||||
deleteManualProductComposition,
|
||||
getProductComposition,
|
||||
importTinyProductCompositionExport,
|
||||
listProductionPlans,
|
||||
saveProductionPlanReview,
|
||||
listProductionWorkstations,
|
||||
listProductCompositions,
|
||||
listProductionOrders,
|
||||
@@ -1877,6 +2027,7 @@ module.exports = {
|
||||
upsertTinyProductionOrderDetail,
|
||||
updateProductionPlan,
|
||||
updateProductionPlanStatus,
|
||||
updateProductionWorkstation,
|
||||
addProductionOrderDefect,
|
||||
updateProductionOrderExecution,
|
||||
updateProductionOrderStatus
|
||||
|
||||
@@ -46,6 +46,9 @@ const mapReceipt = (row) => ({
|
||||
invoice: row.invoice || '',
|
||||
notes: row.notes || '',
|
||||
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,
|
||||
updatedAt: row.updated_at,
|
||||
approvedAt: row.approved_at
|
||||
@@ -92,6 +95,24 @@ const mapFabricPlan = (row) => ({
|
||||
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) => ({
|
||||
materialKey: row.material_key,
|
||||
displayName: row.display_name || '',
|
||||
@@ -101,7 +122,7 @@ const mapMaterialDetails = (row) => ({
|
||||
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) => ({
|
||||
id: row.id,
|
||||
@@ -130,9 +151,12 @@ const createValidationError = (message) => {
|
||||
|
||||
const listReceipts = async () => {
|
||||
const result = await pool.query(`
|
||||
SELECT id, category, product, quantity, unit, supplier, invoice, notes, status, created_at, updated_at, approved_at
|
||||
FROM supply_receipts
|
||||
ORDER BY created_at DESC, id DESC
|
||||
SELECT receipt.id, receipt.category, receipt.product, receipt.quantity, receipt.unit, receipt.supplier, receipt.invoice,
|
||||
receipt.notes, receipt.status, receipt.purchase_order_id, receipt.created_at, receipt.updated_at, receipt.approved_at,
|
||||
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);
|
||||
};
|
||||
@@ -182,6 +206,19 @@ const listFabricPlans = async () => {
|
||||
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 result = await pool.query(`
|
||||
SELECT material_key, display_name, preferred_supplier, notes, created_at, updated_at
|
||||
@@ -899,7 +936,10 @@ const createReceipt = async (payload) => {
|
||||
if (!product) throw createValidationError('Produto ou material é obrigatório.');
|
||||
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 (
|
||||
category, product, quantity, unit, supplier, invoice, notes, status, updated_at
|
||||
)
|
||||
@@ -914,8 +954,30 @@ const createReceipt = async (payload) => {
|
||||
normalizeText(payload.invoice) || null,
|
||||
normalizeText(payload.notes) || null
|
||||
]);
|
||||
|
||||
return mapReceipt(result.rows[0]);
|
||||
const receipt = 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) => {
|
||||
@@ -925,10 +987,13 @@ const approveReceipt = async (id) => {
|
||||
await client.query('BEGIN');
|
||||
|
||||
const receiptResult = await client.query(`
|
||||
SELECT id, category, product, quantity, unit, supplier, invoice, notes, status, created_at, updated_at, approved_at
|
||||
FROM supply_receipts
|
||||
WHERE id = $1
|
||||
FOR UPDATE
|
||||
SELECT receipt.id, receipt.category, receipt.product, receipt.quantity, receipt.unit, receipt.supplier, receipt.invoice,
|
||||
receipt.notes, receipt.status, receipt.purchase_order_id, receipt.created_at, receipt.updated_at, receipt.approved_at,
|
||||
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
|
||||
WHERE receipt.id = $1
|
||||
FOR UPDATE OF receipt
|
||||
`, [id]);
|
||||
|
||||
if (!receiptResult.rowCount) {
|
||||
@@ -941,13 +1006,17 @@ const approveReceipt = async (id) => {
|
||||
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(`
|
||||
UPDATE supply_receipts
|
||||
SET status = 'approved',
|
||||
approved_at = CURRENT_TIMESTAMP,
|
||||
updated_at = CURRENT_TIMESTAMP
|
||||
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]);
|
||||
|
||||
const lotResult = await client.query(`
|
||||
@@ -981,8 +1050,12 @@ const approveReceipt = async (id) => {
|
||||
`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');
|
||||
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) {
|
||||
await client.query('ROLLBACK');
|
||||
throw error;
|
||||
@@ -1032,6 +1105,49 @@ const createFabricPlan = async (payload) => {
|
||||
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) => {
|
||||
await pool.query(`
|
||||
UPDATE supply_fabric_plans
|
||||
@@ -1243,6 +1359,7 @@ module.exports = {
|
||||
approveReceipt,
|
||||
consumeLotForProduction,
|
||||
createFabricPlan,
|
||||
createMaterialTransformation,
|
||||
createPurchaseOrder,
|
||||
createReceipt,
|
||||
deleteFabricPlan,
|
||||
@@ -1251,6 +1368,7 @@ module.exports = {
|
||||
buildProjectPurchaseNeeds,
|
||||
buildPurchaseNeeds,
|
||||
listFabricPlans,
|
||||
listMaterialTransformations,
|
||||
listMaterialDetails,
|
||||
listKnownSuppliers,
|
||||
listPurchaseOrders,
|
||||
@@ -1259,5 +1377,6 @@ module.exports = {
|
||||
listPurchaseNeeds,
|
||||
listReceipts,
|
||||
saveMaterialDetails,
|
||||
updateMaterialTransformationStatus,
|
||||
updatePurchaseOrderStatus
|
||||
};
|
||||
|
||||
@@ -13,6 +13,7 @@ const Cutting = React.lazy(() => import('./pages/Cutting'));
|
||||
const PlanningIssues = React.lazy(() => import('./pages/PlanningIssues'));
|
||||
const ProductionOrders = React.lazy(() => import('./pages/ProductionOrders'));
|
||||
const ProductionPlans = React.lazy(() => import('./pages/ProductionPlans'));
|
||||
const ProductionQuality = React.lazy(() => import('./pages/ProductionQuality'));
|
||||
const Supplies = React.lazy(() => import('./pages/Supplies'));
|
||||
const Clients = React.lazy(() => import('./pages/Clients'));
|
||||
const ClientDetails = React.lazy(() => import('./pages/ClientDetails'));
|
||||
@@ -66,6 +67,7 @@ function App() {
|
||||
<Route path="stock-alerts" element={<Navigate to="/products" replace />} />
|
||||
<Route path="production-orders" element={<ProductionOrders />} />
|
||||
<Route path="production-plans" element={<ProductionPlans />} />
|
||||
<Route path="production-quality" element={<ProductionQuality />} />
|
||||
<Route path="clients" element={<Clients />} />
|
||||
<Route path="clients/:clientToken" element={<ClientDetails />} />
|
||||
<Route path="sellers" element={<Sellers />} />
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
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, ShieldCheck, Moon, Sun, Tags, Boxes, ClipboardList, Menu, X, BriefcaseBusiness, Radio } from 'lucide-react';
|
||||
import type { DateRange, OrderData } from '../types';
|
||||
import { isSuperAdmin, logout } from '../dataService';
|
||||
import { rangeForLastDays } from '../dateRanges';
|
||||
@@ -81,6 +81,9 @@ const Layout = () => {
|
||||
{ name: 'Dados Pendentes', href: '/planning-issues', icon: ClipboardList },
|
||||
{ name: 'Cadastros', href: '/registrations', icon: Tags },
|
||||
{ name: 'Suprimentos', href: '/supplies', icon: Boxes },
|
||||
{ name: 'Planos de Produção', href: '/production-plans', icon: ClipboardList },
|
||||
{ name: 'Ordens de Produção', href: '/production-orders', icon: Package },
|
||||
{ name: 'Qualidade', href: '/production-quality', icon: ShieldCheck },
|
||||
],
|
||||
},
|
||||
{
|
||||
|
||||
@@ -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';
|
||||
|
||||
const API_URL = import.meta.env.VITE_API_URL || '/api';
|
||||
@@ -281,6 +281,16 @@ export const updateProductionPlanStatus = async (id: number, status: ProductionO
|
||||
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> => {
|
||||
const response = await authFetch(`/production-orders/${id}/status`, {
|
||||
method: 'PATCH',
|
||||
@@ -306,6 +316,46 @@ export const updateProductionOrderExecution = async (id: number, payload: Produc
|
||||
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> => {
|
||||
try {
|
||||
const response = await authFetch('/cutting-settings');
|
||||
@@ -692,6 +742,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[]> => {
|
||||
try {
|
||||
const response = await authFetch('/supply/purchase-needs');
|
||||
|
||||
@@ -168,6 +168,9 @@ const Cutting = () => {
|
||||
const [previewPage, setPreviewPage] = useState(1);
|
||||
const [previewItemsPerPage, setPreviewItemsPerPage] = useState(10);
|
||||
const [selectedPreviewRowIds, setSelectedPreviewRowIds] = useState<string[]>([]);
|
||||
const [previewQuantityOverrides, setPreviewQuantityOverrides] = useState<Record<string, string>>({});
|
||||
const [previewLayers, setPreviewLayers] = useState<Record<string, string>>({});
|
||||
const [previewMarkerReferences, setPreviewMarkerReferences] = useState<Record<string, string>>({});
|
||||
const [lotSelections, setLotSelections] = useState<Record<string, { lotId: string; quantity: string }>>({});
|
||||
const [productionPlanForm, setProductionPlanForm] = useState({ name: '', responsibleUserId: null as number | null, responsibleName: '', expectedDate: '', notes: '' });
|
||||
const [operationalUsers, setOperationalUsers] = useState<OperationalUser[]>([]);
|
||||
@@ -391,13 +394,18 @@ const Cutting = () => {
|
||||
};
|
||||
}, [cutPlan.needRows, materialReadinessByProductId]);
|
||||
|
||||
const getPreviewQuantity = (row: CutPlanSkuRow) => {
|
||||
const override = Number((previewQuantityOverrides[row.id] || '').replace(',', '.'));
|
||||
return Number.isFinite(override) && override > 0 ? Math.round(override) : row.suggestedCutQuantity;
|
||||
};
|
||||
|
||||
const selectedPreviewRows = useMemo(() => {
|
||||
const selectedIds = new Set(selectedPreviewRowIds);
|
||||
return rowsAvailableForOrder.filter(row => selectedIds.has(row.id));
|
||||
}, [rowsAvailableForOrder, selectedPreviewRowIds]);
|
||||
const orderPreviewTotalUnits = useMemo(() => (
|
||||
selectedPreviewRows.reduce((total, row) => total + row.suggestedCutQuantity, 0)
|
||||
), [selectedPreviewRows]);
|
||||
selectedPreviewRows.reduce((total, row) => total + getPreviewQuantity(row), 0)
|
||||
), [previewQuantityOverrides, selectedPreviewRows]);
|
||||
const selectedResponsibleUser = useMemo(() => (
|
||||
operationalUsers.find((user) => user.id === productionPlanForm.responsibleUserId && user.name === productionPlanForm.responsibleName) || null
|
||||
), [operationalUsers, productionPlanForm.responsibleName, productionPlanForm.responsibleUserId]);
|
||||
@@ -421,12 +429,12 @@ const Cutting = () => {
|
||||
unit: component.unit || 'UN',
|
||||
quantity: 0
|
||||
};
|
||||
current.quantity += row.suggestedCutQuantity * component.quantityPerUnit;
|
||||
current.quantity += getPreviewQuantity(row) * component.quantityPerUnit;
|
||||
requirements.set(key, current);
|
||||
});
|
||||
});
|
||||
return Array.from(requirements.values()).sort((a, b) => a.label.localeCompare(b.label, 'pt-BR'));
|
||||
}, [compositionsByProductId, selectedPreviewRows]);
|
||||
}, [compositionsByProductId, previewQuantityOverrides, selectedPreviewRows]);
|
||||
|
||||
const selectedLotReservations = useMemo(() => (
|
||||
selectedMaterialRequirements.flatMap(requirement => {
|
||||
@@ -675,6 +683,9 @@ const Cutting = () => {
|
||||
setGenerationMessage('');
|
||||
setPreviewPage(1);
|
||||
setSelectedPreviewRowIds(rowsAvailableForOrder.map(row => row.id));
|
||||
setPreviewQuantityOverrides({});
|
||||
setPreviewLayers({});
|
||||
setPreviewMarkerReferences({});
|
||||
setLotSelections({});
|
||||
setProductionPlanForm({ name: cutPlanReference, responsibleUserId: null, responsibleName: '', expectedDate: '', notes: '' });
|
||||
setIsPlanDetailsOpen(false);
|
||||
@@ -729,7 +740,7 @@ const Cutting = () => {
|
||||
issueDate,
|
||||
productSku: row.id,
|
||||
productDescription: row.name,
|
||||
quantity: row.suggestedCutQuantity,
|
||||
quantity: getPreviewQuantity(row),
|
||||
unit: 'UN',
|
||||
integrationStatus: 'Local',
|
||||
components: (compositionsByProductId.get(normalizeMaterialKey(row.id))?.components || [])
|
||||
@@ -739,12 +750,14 @@ const Cutting = () => {
|
||||
componentSku: component.componentSku,
|
||||
componentName: component.componentName,
|
||||
quantityPerUnit: component.quantityPerUnit,
|
||||
totalQuantity: row.suggestedCutQuantity * component.quantityPerUnit,
|
||||
totalQuantity: getPreviewQuantity(row) * component.quantityPerUnit,
|
||||
unit: component.unit
|
||||
})),
|
||||
markers: [
|
||||
{ label: row.family.materialLabel, color: '#38bdf8' },
|
||||
...(row.color ? [{ label: row.color, color: '#52DFA0' }] : []),
|
||||
...(Number(previewLayers[row.id]) > 0 ? [{ label: `${previewLayers[row.id]} camadas`, color: '#c084fc' }] : []),
|
||||
...(previewMarkerReferences[row.id]?.trim() ? [{ label: `Risco ${previewMarkerReferences[row.id].trim()}`, color: '#38bdf8' }] : []),
|
||||
{ label: 'Material confirmado', color: '#52DFA0' },
|
||||
...(row.issues.length ? [{ label: 'Dados pendentes', color: '#f59e0b' }] : [])
|
||||
],
|
||||
@@ -760,6 +773,9 @@ const Cutting = () => {
|
||||
stock: row.stock,
|
||||
openProductionQuantity: row.openProductionQuantity,
|
||||
availableQuantity: row.availableQuantity,
|
||||
plannedLayers: Number(previewLayers[row.id]) || null,
|
||||
markerReference: previewMarkerReferences[row.id]?.trim() || '',
|
||||
manuallyAdjustedQuantity: getPreviewQuantity(row) !== row.suggestedCutQuantity,
|
||||
issues: row.issues
|
||||
}
|
||||
})), selectedLotReservations, {
|
||||
@@ -992,7 +1008,7 @@ const Cutting = () => {
|
||||
</div>
|
||||
<div className="mt-3 divide-y divide-dark-border overflow-hidden rounded-lg border border-dark-border bg-dark-card">
|
||||
{selectedPreviewRows.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_100px_190px] lg:items-center">
|
||||
<div className="min-w-0">
|
||||
<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">
|
||||
@@ -1002,10 +1018,19 @@ const Cutting = () => {
|
||||
<span className="text-[10px] font-semibold text-dark-muted">{row.size || 'Sem tamanho'}</span>
|
||||
</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>
|
||||
<label className="text-[10px] font-bold uppercase tracking-wider text-dark-muted">Camadas
|
||||
<input inputMode="numeric" value={previewLayers[row.id] ?? ''} onChange={(event) => setPreviewLayers(current => ({ ...current, [row.id]: event.target.value }))} className="mt-1 h-9 w-full rounded-lg border border-dark-border bg-dark-input px-2 text-right text-sm font-bold text-dark-text outline-none focus:border-brand-primary" placeholder="Opcional" aria-label={`Camadas planejadas para ${row.name}`} />
|
||||
</label>
|
||||
<label className="text-[10px] font-bold uppercase tracking-wider text-dark-muted">Risco / encaixe
|
||||
<input value={previewMarkerReferences[row.id] ?? ''} onChange={(event) => setPreviewMarkerReferences(current => ({ ...current, [row.id]: event.target.value }))} className="mt-1 h-9 w-full rounded-lg border border-dark-border bg-dark-input px-2 text-sm font-bold text-dark-text outline-none placeholder:text-dark-muted focus:border-brand-primary" placeholder="Código ou arquivo" aria-label={`Risco ou encaixe para ${row.name}`} />
|
||||
</label>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<p className="mt-3 text-xs font-semibold text-dark-muted">A quantidade sugerida vem da cobertura. Ajustes manuais ficam marcados na OP; camadas e risco são referências de preparação, e o resultado real é confirmado durante a execução.</p>
|
||||
</div>
|
||||
</div>}
|
||||
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
import { Fragment, type FormEvent, useCallback, useEffect, useMemo, useState } from 'react';
|
||||
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, XCircle } from 'lucide-react';
|
||||
import DateRangePicker from '../components/DateRangePicker';
|
||||
import PaginationControls from '../components/PaginationControls';
|
||||
import RefreshStatus from '../components/RefreshStatus';
|
||||
import { consumeSupplyLotForProduction, exportToCSV, fetchProductionOrders, fetchSupplySummary, updateProductionOrderExecution, updateProductionOrderStatus } from '../dataService';
|
||||
import type { DateRange, ProductionOrderItem, ProductionOrderStatus, ProductionOrderSummary, SupplyLot } from '../types';
|
||||
import { addProductionOrderDefect, consumeSupplyLotForProduction, exportToCSV, fetchProductionOrders, fetchProductionWorkstations, fetchSupplySummary, updateProductionOrderExecution, updateProductionOrderStatus } from '../dataService';
|
||||
import type { DateRange, ProductionOrderItem, ProductionOrderStatus, ProductionOrderSummary, ProductionWorkstation, SupplyLot } from '../types';
|
||||
|
||||
type ProductionOrderStatusTab = 'all' | 'open' | 'cutting' | 'sewing' | 'review' | 'finished' | 'canceled';
|
||||
|
||||
@@ -140,11 +140,14 @@ const ProductionOrders = () => {
|
||||
const [statusFilter, setStatusFilter] = useState<ProductionOrderStatusTab>('all');
|
||||
const [summary, setSummary] = useState<ProductionOrderSummary>(emptySummary);
|
||||
const [supplyLots, setSupplyLots] = useState<SupplyLot[]>([]);
|
||||
const [workstations, setWorkstations] = useState<ProductionWorkstation[]>([]);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [isSupplyBusy, setIsSupplyBusy] = useState(false);
|
||||
const [busyExecutionOrderId, setBusyExecutionOrderId] = useState<number | null>(null);
|
||||
const [supplyMessage, setSupplyMessage] = useState('');
|
||||
const [executionMessage, setExecutionMessage] = useState('');
|
||||
const [busyDefectOrderId, setBusyDefectOrderId] = useState<number | null>(null);
|
||||
const [defectMessage, setDefectMessage] = useState('');
|
||||
const [busyStatusOrderId, setBusyStatusOrderId] = useState<number | null>(null);
|
||||
const [statusMessage, setStatusMessage] = useState('');
|
||||
const [selectedOrderId, setSelectedOrderId] = useState<number | null>(null);
|
||||
@@ -169,13 +172,15 @@ const ProductionOrders = () => {
|
||||
|
||||
const load = async () => {
|
||||
setIsLoading(true);
|
||||
const [nextSummary, nextSupplySummary] = await Promise.all([
|
||||
const [nextSummary, nextSupplySummary, nextWorkstations] = await Promise.all([
|
||||
fetchProductionOrders(dateRange, { search: searchTerm }),
|
||||
fetchSupplySummary()
|
||||
fetchSupplySummary(),
|
||||
fetchProductionWorkstations()
|
||||
]);
|
||||
if (isMounted) {
|
||||
setSummary(nextSummary);
|
||||
setSupplyLots(nextSupplySummary.lots);
|
||||
setWorkstations(nextWorkstations);
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
@@ -302,6 +307,8 @@ const ProductionOrders = () => {
|
||||
rejectedQuantity: parseOptionalQuantity('rejectedQuantity'),
|
||||
finishedQuantity: parseOptionalQuantity('finishedQuantity'),
|
||||
qualityNotes: String(form.get('qualityNotes') || '').trim(),
|
||||
cutLayers: parseOptionalQuantity('cutLayers'),
|
||||
markerReference: String(form.get('markerReference') || '').trim(),
|
||||
});
|
||||
const nextSummary = await fetchProductionOrders(dateRange, { search: searchTerm }, { force: true });
|
||||
setSummary(nextSummary);
|
||||
@@ -313,6 +320,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 printWindow = window.open('', '_blank', 'noopener,noreferrer');
|
||||
if (!printWindow) {
|
||||
@@ -385,6 +421,8 @@ const ProductionOrders = () => {
|
||||
<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">Aprovado final<input name="finishedQuantity" inputMode="decimal" defaultValue={order.finishedQuantity ?? ''} className="mt-1.5 h-11 w-full rounded-lg border border-dark-border bg-dark-input px-3 text-sm font-semibold text-dark-text outline-none placeholder:text-dark-muted focus:border-brand-primary" placeholder="Após revisão" /></label>
|
||||
<label className="text-xs font-bold text-dark-muted">Camadas cortadas<input name="cutLayers" inputMode="numeric" defaultValue={order.cutLayers ?? ''} className="mt-1.5 h-11 w-full rounded-lg border border-dark-border bg-dark-input px-3 text-sm font-semibold text-dark-text outline-none placeholder:text-dark-muted focus:border-brand-primary" placeholder="Ex.: 12" /></label>
|
||||
<label className="text-xs font-bold text-dark-muted sm:col-span-2">Risco / encaixe<input name="markerReference" defaultValue={order.markerReference} className="mt-1.5 h-11 w-full rounded-lg border border-dark-border bg-dark-input px-3 text-sm font-semibold text-dark-text outline-none placeholder:text-dark-muted focus:border-brand-primary" placeholder="Código do risco, encaixe ou arquivo" /></label>
|
||||
<label className="text-xs font-bold text-dark-muted sm:col-span-3">Motivo de perda / observação<textarea name="qualityNotes" defaultValue={order.qualityNotes} className="mt-1.5 min-h-20 w-full rounded-lg border border-dark-border bg-dark-input px-3 py-2.5 text-sm font-semibold text-dark-text outline-none placeholder:text-dark-muted focus:border-brand-primary" placeholder="Ex.: 4 peças com mancha de tinta; 2 recuperadas na revisão." /></label>
|
||||
</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>
|
||||
@@ -392,6 +430,27 @@ const ProductionOrders = () => {
|
||||
</form>
|
||||
</section>
|
||||
|
||||
<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>
|
||||
<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 && <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>
|
||||
<h3 className="text-xs font-bold uppercase tracking-widest text-dark-muted">Composição</h3>
|
||||
{order.components.length ? <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">Material</th><th className="px-3 py-2.5 text-right font-bold">Por unidade</th><th className="px-3 py-2.5 text-right font-bold">Total</th></tr></thead><tbody className="divide-y divide-dark-border">{order.components.map(component => <tr key={component.id}><td className="px-3 py-3"><p className="font-bold text-dark-text">{component.componentName}</p><p className="mt-0.5 font-mono text-[10px] font-semibold text-dark-muted">{component.componentSku || 'Sem SKU'}</p></td><td className="px-3 py-3 text-right font-semibold text-dark-muted">{formatQuantity(component.quantityPerUnit)} {component.unit}</td><td className="px-3 py-3 text-right font-bold text-dark-text">{formatQuantity(component.totalQuantity)} {component.unit}</td></tr>)}</tbody></table></div> : <p className="mt-3 rounded-xl border border-dark-border bg-dark-card px-4 py-3 text-sm font-semibold text-dark-muted">Sem composição sincronizada.</p>}
|
||||
@@ -422,7 +481,7 @@ const ProductionOrders = () => {
|
||||
</Link>
|
||||
<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>
|
||||
<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>
|
||||
<DateRangePicker
|
||||
dateRange={dateRange}
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { ArrowLeft, Check, CheckCircle2, ChevronDown, ClipboardList, Factory, Loader2, PlayCircle, UserRound, type LucideIcon } from 'lucide-react';
|
||||
import { fetchOperationalUsers, fetchProductionPlans, updateProductionPlan, updateProductionPlanStatus } from '../dataService';
|
||||
import type { OperationalUser, ProductionOrderStatus, ProductionPlan } from '../types';
|
||||
import { ArrowLeft, Check, CheckCircle2, ChevronDown, ClipboardCheck, ClipboardList, Factory, Loader2, PlayCircle, UserRound, X, type LucideIcon } from 'lucide-react';
|
||||
import { fetchOperationalUsers, fetchProductionPlans, saveProductionPlanReview, updateProductionPlan, updateProductionPlanStatus } from '../dataService';
|
||||
import type { OperationalUser, ProductionOrderStatus, ProductionPlan, ProductionPlanReviewDecision } from '../types';
|
||||
|
||||
const statusOptions: Array<{ value: ProductionOrderStatus; label: string; className: string }> = [
|
||||
{ value: 'open', label: 'Em aberto', className: 'border-amber-400/35 bg-amber-400/10 text-amber-300' },
|
||||
@@ -28,6 +28,16 @@ const ProductionPlans = () => {
|
||||
const [busyPlanId, setBusyPlanId] = useState<number | null>(null);
|
||||
const [operationalUsers, setOperationalUsers] = useState<OperationalUser[]>([]);
|
||||
const [responsiblePickerPlanId, setResponsiblePickerPlanId] = useState<number | null>(null);
|
||||
const [reviewingPlanId, setReviewingPlanId] = useState<number | null>(null);
|
||||
const [reviewForm, setReviewForm] = useState({
|
||||
decision: 'pending' as ProductionPlanReviewDecision,
|
||||
materialsReserved: false,
|
||||
cutSpecificationReady: false,
|
||||
sizeAndColorReviewed: false,
|
||||
responsibleAndDateDefined: false,
|
||||
notes: '',
|
||||
reviewedBy: '',
|
||||
});
|
||||
const [message, setMessage] = useState('');
|
||||
|
||||
const loadPlans = useCallback(async () => {
|
||||
@@ -92,6 +102,53 @@ const ProductionPlans = () => {
|
||||
}
|
||||
};
|
||||
|
||||
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);
|
||||
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 (
|
||||
<div className="space-y-6">
|
||||
<div className="flex flex-col gap-4 lg:flex-row lg:items-end lg:justify-between">
|
||||
@@ -122,12 +179,15 @@ const ProductionPlans = () => {
|
||||
{isLoading ? <div className="flex min-h-56 items-center justify-center text-brand-primary"><Loader2 className="h-7 w-7 animate-spin" /></div> : plans.length ? (
|
||||
<div className="divide-y divide-dark-border">
|
||||
{plans.map(plan => (
|
||||
<div key={plan.id} className="grid gap-4 p-5 xl:grid-cols-[minmax(240px,1.3fr)_minmax(150px,.7fr)_minmax(160px,.7fr)_minmax(190px,.75fr)_180px] xl:items-center">
|
||||
<div className="min-w-0"><div className="flex flex-wrap items-center gap-2"><span className="font-mono text-xs font-bold text-brand-primary">{plan.number}</span><span className={`rounded-full border px-2.5 py-1 text-xs font-bold ${statusStyle(plan.status)}`}>{plan.statusLabel}</span></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 key={plan.id} className="p-5">
|
||||
<div className="grid gap-4 xl:grid-cols-[minmax(240px,1.3fr)_minmax(150px,.7fr)_minmax(160px,.7fr)_minmax(190px,.75fr)_180px] xl:items-center">
|
||||
<div className="min-w-0"><div className="flex flex-wrap items-center gap-2"><span className="font-mono text-xs font-bold text-brand-primary">{plan.number}</span><span className={`rounded-full border px-2.5 py-1 text-xs font-bold ${statusStyle(plan.status)}`}>{plan.statusLabel}</span>{plan.review && <span className={`rounded-full border px-2 py-1 text-[10px] font-bold ${reviewDecisionStyle(plan.review.decision)}`}>{reviewDecisionLabel[plan.review.decision]}</span>}</div><p className="mt-2 truncate font-bold text-dark-text" title={plan.name}>{plan.name}</p><p className="mt-1 text-xs font-semibold text-dark-muted">{plan.responsibleName ? `Responsável: ${plan.responsibleName}` : plan.contractor ? `Responsável: ${plan.contractor}` : 'Sem responsável definido'} · previsão {formatDate(plan.expectedDate)}</p>{plan.notes && <p className="mt-1 truncate text-xs font-semibold text-dark-muted" title={plan.notes}>{plan.notes}</p>}</div>
|
||||
<div className="text-sm"><p className="text-xs font-bold uppercase tracking-wider text-dark-muted">Escopo</p><p className="mt-1 font-bold text-dark-text">{plan.orderCount} {plan.orderCount === 1 ? 'OP' : 'OPs'} · {formatNumber(plan.totalQuantity)} un.</p><p className="mt-1 text-xs font-semibold text-dark-muted">Cortado {formatNumber(plan.actualCutQuantity)} · final {formatNumber(plan.finishedQuantity)}</p></div>
|
||||
<div className="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="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>
|
||||
<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="flex gap-2 xl:flex-col"><button type="button" disabled={busyPlanId === plan.id} onClick={() => openReview(plan)} className="inline-flex h-10 flex-1 items-center justify-center gap-2 rounded-lg border border-dark-border bg-dark-input px-3 text-xs font-bold text-dark-text transition-colors hover:border-brand-primary disabled:opacity-60 cursor-pointer"><ClipboardCheck className="h-4 w-4 text-brand-primary" />Checklist</button><label className="flex-1 text-xs font-bold text-dark-muted">Mover plano<select value={plan.status} disabled={busyPlanId === plan.id} onChange={(event) => void movePlan(plan, event.target.value)} className="mt-1 h-10 w-full rounded-lg border border-dark-border bg-dark-input px-3 text-sm font-bold text-dark-text outline-none focus:border-brand-primary disabled:opacity-60 cursor-pointer"><option value={plan.status}>{busyPlanId === plan.id ? 'Atualizando...' : plan.statusLabel}</option>{statusOptions.filter(option => option.value !== plan.status).map(option => <option key={option.value} value={option.value}>{option.label}</option>)}</select></label></div>
|
||||
</div>
|
||||
{reviewingPlanId === plan.id && <div className="mt-5 rounded-xl border border-brand-primary/25 bg-brand-primary/[0.045] p-4"><div className="flex items-start justify-between gap-4"><div><h3 className="text-sm font-bold text-dark-text">Liberação operacional</h3><p className="mt-1 text-xs font-semibold text-dark-muted">Registre a decisão antes de avançar o plano. Este registro não muda o estoque nem substitui o recebimento.</p></div><button type="button" onClick={() => setReviewingPlanId(null)} className="inline-flex h-8 w-8 items-center justify-center rounded-lg border border-dark-border bg-dark-input text-dark-muted hover:text-dark-text cursor-pointer" aria-label="Fechar checklist"><X className="h-4 w-4" /></button></div><div className="mt-4 grid gap-3 md:grid-cols-2 xl:grid-cols-4">{[{ key: 'materialsReserved', label: 'Materiais reservados' }, { key: 'cutSpecificationReady', label: 'Ficha e corte definidos' }, { key: 'sizeAndColorReviewed', label: 'Tamanhos e cores revisados' }, { key: 'responsibleAndDateDefined', label: 'Responsável e data definidos' }].map(item => <label key={item.key} className="flex items-center gap-2 rounded-lg border border-dark-border bg-dark-card px-3 py-2.5 text-xs font-bold text-dark-text"><input type="checkbox" checked={reviewForm[item.key as keyof Pick<typeof reviewForm, 'materialsReserved' | 'cutSpecificationReady' | 'sizeAndColorReviewed' | 'responsibleAndDateDefined'>]} onChange={(event) => setReviewForm(current => ({ ...current, [item.key]: event.target.checked }))} className="h-4 w-4 accent-brand-primary" />{item.label}</label>)}</div><div className="mt-4 grid gap-3 md:grid-cols-[180px_minmax(0,1fr)_minmax(0,1fr)_auto]"><label className="text-xs font-bold text-dark-muted">Decisão<select value={reviewForm.decision} onChange={(event) => setReviewForm(current => ({ ...current, decision: event.target.value as ProductionPlanReviewDecision }))} className="mt-1 h-10 w-full rounded-lg border border-dark-border bg-dark-input px-3 text-sm font-bold text-dark-text outline-none focus:border-brand-primary"><option value="pending">Em revisão</option><option value="approved">Liberar para operação</option><option value="adjustments_required">Solicitar ajustes</option></select></label><label className="text-xs font-bold text-dark-muted">Revisado por<input value={reviewForm.reviewedBy} onChange={(event) => setReviewForm(current => ({ ...current, reviewedBy: event.target.value }))} className="mt-1 h-10 w-full rounded-lg border border-dark-border bg-dark-input px-3 text-sm font-semibold text-dark-text outline-none focus:border-brand-primary" placeholder="Nome do usuário" /></label><label className="text-xs font-bold text-dark-muted">Observação<input value={reviewForm.notes} onChange={(event) => setReviewForm(current => ({ ...current, notes: event.target.value }))} className="mt-1 h-10 w-full rounded-lg border border-dark-border bg-dark-input px-3 text-sm font-semibold text-dark-text outline-none focus:border-brand-primary" placeholder="Ajuste, orientação ou motivo" /></label><button type="button" disabled={busyPlanId === plan.id} onClick={() => void submitReview(plan)} className="mt-5 inline-flex h-10 items-center justify-center gap-2 rounded-lg bg-brand-primary px-4 text-xs font-bold text-brand-contrast disabled:opacity-60 cursor-pointer"><Check className="h-4 w-4" />Salvar</button></div></div>}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
161
src/pages/ProductionQuality.tsx
Normal file
161
src/pages/ProductionQuality.tsx
Normal 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;
|
||||
File diff suppressed because one or more lines are too long
107
src/types.ts
107
src/types.ts
@@ -90,6 +90,44 @@ export interface ProductionOrderLotReservation {
|
||||
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 {
|
||||
id: number;
|
||||
tinyId: string;
|
||||
@@ -121,6 +159,9 @@ export interface ProductionOrderItem {
|
||||
rejectedQuantity: number;
|
||||
finishedQuantity: number | null;
|
||||
qualityNotes: string;
|
||||
cutLayers: number | null;
|
||||
markerReference: string;
|
||||
defects: ProductionOrderDefect[];
|
||||
lotReservations: ProductionOrderLotReservation[];
|
||||
markers: ProductionOrderMarker[];
|
||||
components: ProductionOrderComponent[];
|
||||
@@ -193,10 +234,35 @@ export interface ProductionPlan {
|
||||
actualCutQuantity: number;
|
||||
finishedQuantity: number;
|
||||
statusCounts: Record<string, number>;
|
||||
review: ProductionPlanReview | null;
|
||||
createdAt: 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 = {
|
||||
id?: number;
|
||||
name: string;
|
||||
@@ -221,6 +287,8 @@ export type ProductionOrderExecutionPayload = {
|
||||
rejectedQuantity?: number | null;
|
||||
finishedQuantity?: number | null;
|
||||
qualityNotes?: string;
|
||||
cutLayers?: number | null;
|
||||
markerReference?: string;
|
||||
};
|
||||
|
||||
export interface CreateProductionOrdersResult {
|
||||
@@ -450,6 +518,9 @@ export interface SupplyReceipt {
|
||||
invoice: string;
|
||||
notes: string;
|
||||
status: SupplyReceiptStatus;
|
||||
purchaseOrderId: number | null;
|
||||
purchaseOrderNumber: string;
|
||||
purchaseOrderStatus: string;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
approvedAt: string | null;
|
||||
@@ -496,7 +567,28 @@ export interface SupplyFabricPlan {
|
||||
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 {
|
||||
id: number;
|
||||
@@ -596,6 +688,19 @@ export type SupplyFabricPlanPayload = {
|
||||
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 = {
|
||||
countedQuantity: number | string;
|
||||
reason: string;
|
||||
|
||||
Reference in New Issue
Block a user