Compare commits
3 Commits
afaa18ca14
...
bc05fb4504
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
bc05fb4504 | ||
|
|
060b4da907 | ||
|
|
8f3f2f3e93 |
@@ -93,11 +93,11 @@ npm run dev
|
||||
Default local URLs:
|
||||
|
||||
```text
|
||||
Frontend: http://127.0.0.1:3001
|
||||
Frontend: http://127.0.0.1:3002
|
||||
Backend: http://127.0.0.1:3004
|
||||
```
|
||||
|
||||
Vite may choose a different frontend port if `3001` is already in use.
|
||||
Vite may choose a different frontend port if `3002` is already in use.
|
||||
|
||||
## Environment
|
||||
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
const express = require('express');
|
||||
const { verifyToken } = require('../auth');
|
||||
const {
|
||||
adjustInventoryLot,
|
||||
approveReceipt,
|
||||
consumeLotForProduction,
|
||||
createFabricPlan,
|
||||
createReceipt,
|
||||
deleteFabricPlan,
|
||||
@@ -65,6 +67,22 @@ router.get('/supply/lots', verifyToken, async (req, res, next) => {
|
||||
}
|
||||
});
|
||||
|
||||
router.post('/supply/lots/:id/inventory-adjustment', verifyToken, async (req, res, next) => {
|
||||
try {
|
||||
res.json(await adjustInventoryLot(req.params.id, req.body || {}));
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
router.post('/supply/lots/:id/production-exit', verifyToken, async (req, res, next) => {
|
||||
try {
|
||||
res.json(await consumeLotForProduction(req.params.id, req.body || {}));
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
router.get('/supply/movements', verifyToken, async (req, res, next) => {
|
||||
try {
|
||||
res.json(await listMovements());
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
const { pool } = require('../db');
|
||||
|
||||
const DEFAULT_SUPPLY_LOOKBACK_DAYS = 30;
|
||||
const DEFAULT_SUPPLY_COVERAGE_DAYS = 30;
|
||||
|
||||
const normalizeText = (value) => String(value || '').replace(/\s+/g, ' ').trim();
|
||||
|
||||
const normalizeNumber = (value) => {
|
||||
@@ -8,11 +11,19 @@ const normalizeNumber = (value) => {
|
||||
return Number.isFinite(number) && number > 0 ? number : null;
|
||||
};
|
||||
|
||||
const normalizeNonNegativeNumber = (value) => {
|
||||
if (value === '' || value === null || value === undefined) return null;
|
||||
const number = Number(String(value).replace(',', '.'));
|
||||
return Number.isFinite(number) && number >= 0 ? number : null;
|
||||
};
|
||||
|
||||
const normalizeKey = (value) => normalizeText(value)
|
||||
.normalize('NFD')
|
||||
.replace(/[\u0300-\u036f]/g, '')
|
||||
.toLowerCase();
|
||||
|
||||
const normalizeSku = (value) => normalizeText(value).toUpperCase();
|
||||
|
||||
const mapReceipt = (row) => ({
|
||||
id: row.id,
|
||||
category: row.category,
|
||||
@@ -181,6 +192,268 @@ const buildPurchaseNeeds = (plans, lots, receipts) => {
|
||||
});
|
||||
};
|
||||
|
||||
const getReferenceYield = (reference) => {
|
||||
if (reference.general_yield !== null && Number(reference.general_yield) > 0) {
|
||||
return Number(reference.general_yield);
|
||||
}
|
||||
|
||||
const sizeYields = reference.size_yields && typeof reference.size_yields === 'object'
|
||||
? Object.values(reference.size_yields).map(Number).filter(value => Number.isFinite(value) && value > 0)
|
||||
: [];
|
||||
|
||||
if (!sizeYields.length) return null;
|
||||
return sizeYields.reduce((total, value) => total + value, 0) / sizeYields.length;
|
||||
};
|
||||
|
||||
const listProjectDemandRows = async () => {
|
||||
const result = await pool.query(`
|
||||
WITH bounds AS (
|
||||
SELECT MAX(data_pedido_date) AS end_date
|
||||
FROM orders
|
||||
WHERE data_pedido_date IS NOT NULL
|
||||
),
|
||||
period_orders AS (
|
||||
SELECT
|
||||
produto_id,
|
||||
MAX(produto_descricao) AS product_name,
|
||||
SUM(quantidade)::numeric AS quantity_sold
|
||||
FROM orders, bounds
|
||||
WHERE data_pedido_date IS NOT NULL
|
||||
AND bounds.end_date IS NOT NULL
|
||||
AND data_pedido_date >= (bounds.end_date - ($1::int - 1) * INTERVAL '1 day')::date
|
||||
AND data_pedido_date <= bounds.end_date
|
||||
GROUP BY produto_id
|
||||
)
|
||||
SELECT
|
||||
COALESCE(period_orders.produto_id, stock.produto_id) AS product_id,
|
||||
COALESCE(NULLIF(period_orders.product_name, ''), NULLIF(stock.nome, ''), 'Produto sem nome') AS product_name,
|
||||
COALESCE(period_orders.quantity_sold, 0)::numeric AS quantity_sold,
|
||||
COALESCE(stock.saldo, 0)::numeric AS stock_quantity
|
||||
FROM period_orders
|
||||
FULL OUTER JOIN stock ON stock.produto_id = period_orders.produto_id
|
||||
WHERE COALESCE(period_orders.produto_id, stock.produto_id, '') <> ''
|
||||
ORDER BY quantity_sold DESC, product_name;
|
||||
`, [DEFAULT_SUPPLY_LOOKBACK_DAYS]);
|
||||
|
||||
return result.rows;
|
||||
};
|
||||
|
||||
const listConsumptionReferenceRows = async () => {
|
||||
const result = await pool.query(`
|
||||
SELECT
|
||||
r.product_id,
|
||||
p.sku AS product_sku,
|
||||
p.name AS product_name,
|
||||
r.material_product_id,
|
||||
m.sku AS material_sku,
|
||||
m.name AS material_name,
|
||||
r.color,
|
||||
r.general_yield,
|
||||
r.size_yields
|
||||
FROM consumption_references r
|
||||
JOIN catalog_products p ON p.id = r.product_id
|
||||
LEFT JOIN catalog_products m ON m.id = r.material_product_id
|
||||
ORDER BY p.sku, r.color NULLS FIRST;
|
||||
`);
|
||||
|
||||
return result.rows;
|
||||
};
|
||||
|
||||
const mergeNeedLine = (needsByMaterial, key, patch) => {
|
||||
const current = needsByMaterial.get(key) || {
|
||||
material: patch.material,
|
||||
plannedKg: 0,
|
||||
stockKg: 0,
|
||||
pendingKg: 0,
|
||||
purchaseKg: 0,
|
||||
priority: patch.priority || 'Normal',
|
||||
suppliers: new Set(),
|
||||
colors: new Set(),
|
||||
unit: patch.unit || 'kg',
|
||||
source: patch.source || 'manual_plan',
|
||||
missingReference: Boolean(patch.missingReference),
|
||||
products: []
|
||||
};
|
||||
|
||||
current.plannedKg += patch.plannedKg || 0;
|
||||
current.priority = patch.priority === 'Crítico' || current.priority === 'Crítico'
|
||||
? 'Crítico'
|
||||
: patch.priority === 'Atenção' || current.priority === 'Atenção'
|
||||
? 'Atenção'
|
||||
: current.priority;
|
||||
current.missingReference = current.missingReference || Boolean(patch.missingReference);
|
||||
current.source = current.source === patch.source ? current.source : 'mixed';
|
||||
if (patch.supplier) current.suppliers.add(patch.supplier);
|
||||
if (patch.color) current.colors.add(patch.color);
|
||||
if (patch.product) current.products.push(patch.product);
|
||||
|
||||
needsByMaterial.set(key, current);
|
||||
return current;
|
||||
};
|
||||
|
||||
const buildProjectPurchaseNeeds = async (lots, receipts) => {
|
||||
const [demandRows, referenceRows] = await Promise.all([
|
||||
listProjectDemandRows(),
|
||||
listConsumptionReferenceRows()
|
||||
]);
|
||||
const referencesBySku = new Map(referenceRows.map(reference => [normalizeSku(reference.product_sku), reference]));
|
||||
const needsByMaterial = new Map();
|
||||
|
||||
demandRows.forEach(row => {
|
||||
const productId = normalizeSku(row.product_id);
|
||||
if (!productId) return;
|
||||
|
||||
const quantitySold = Number(row.quantity_sold || 0);
|
||||
const stockQuantity = Number(row.stock_quantity || 0);
|
||||
const projectedDemand = quantitySold * (DEFAULT_SUPPLY_COVERAGE_DAYS / DEFAULT_SUPPLY_LOOKBACK_DAYS);
|
||||
const suggestedQuantity = Math.max(Math.ceil(projectedDemand - stockQuantity), 0);
|
||||
if (suggestedQuantity <= 0) return;
|
||||
|
||||
const reference = referencesBySku.get(productId);
|
||||
if (!reference) {
|
||||
mergeNeedLine(needsByMaterial, `missing:${productId}`, {
|
||||
material: `Cadastrar consumo: ${normalizeText(row.product_name) || productId}`,
|
||||
plannedKg: suggestedQuantity,
|
||||
priority: 'Crítico',
|
||||
unit: 'un.',
|
||||
source: 'project_demand',
|
||||
missingReference: true,
|
||||
product: {
|
||||
productId,
|
||||
name: normalizeText(row.product_name),
|
||||
suggestedQuantity,
|
||||
quantitySold,
|
||||
stockQuantity
|
||||
}
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const yieldPerKg = getReferenceYield(reference);
|
||||
if (!yieldPerKg) {
|
||||
mergeNeedLine(needsByMaterial, `missing-yield:${productId}`, {
|
||||
material: `Cadastrar rendimento: ${normalizeText(row.product_name) || productId}`,
|
||||
plannedKg: suggestedQuantity,
|
||||
priority: 'Crítico',
|
||||
unit: 'un.',
|
||||
source: 'project_demand',
|
||||
missingReference: true,
|
||||
color: reference.color,
|
||||
product: {
|
||||
productId,
|
||||
name: normalizeText(row.product_name),
|
||||
suggestedQuantity,
|
||||
quantitySold,
|
||||
stockQuantity
|
||||
}
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const materialName = normalizeText(reference.material_name) || normalizeText(reference.material_sku) || 'Material sem cadastro';
|
||||
mergeNeedLine(needsByMaterial, normalizeKey(materialName), {
|
||||
material: materialName,
|
||||
plannedKg: suggestedQuantity / yieldPerKg,
|
||||
priority: 'Atenção',
|
||||
unit: 'kg',
|
||||
source: 'project_demand',
|
||||
color: reference.color,
|
||||
product: {
|
||||
productId,
|
||||
name: normalizeText(row.product_name),
|
||||
suggestedQuantity,
|
||||
quantitySold,
|
||||
stockQuantity,
|
||||
yieldPerKg
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
lots.forEach(lot => {
|
||||
if (lot.unit !== 'kg') return;
|
||||
const need = needsByMaterial.get(normalizeKey(lot.product));
|
||||
if (need && need.unit === 'kg') need.stockKg += lot.quantity;
|
||||
});
|
||||
|
||||
receipts.forEach(receipt => {
|
||||
if (receipt.status !== 'pending' || receipt.unit !== 'kg') return;
|
||||
const need = needsByMaterial.get(normalizeKey(receipt.product));
|
||||
if (need && need.unit === 'kg') need.pendingKg += receipt.quantity;
|
||||
});
|
||||
|
||||
return Array.from(needsByMaterial.values()).map(need => {
|
||||
const purchaseKg = Math.max(need.plannedKg - need.stockKg - need.pendingKg, 0);
|
||||
let status = 'ok';
|
||||
if (purchaseKg > 0 && (need.priority === 'Crítico' || need.stockKg === 0)) status = 'critical';
|
||||
else if (purchaseKg > 0) status = 'attention';
|
||||
|
||||
return {
|
||||
material: need.material,
|
||||
plannedKg: need.plannedKg,
|
||||
stockKg: need.stockKg,
|
||||
pendingKg: need.pendingKg,
|
||||
purchaseKg,
|
||||
priority: need.priority,
|
||||
status,
|
||||
suppliers: Array.from(need.suppliers),
|
||||
colors: Array.from(need.colors),
|
||||
unit: need.unit,
|
||||
source: need.source,
|
||||
missingReference: need.missingReference,
|
||||
products: need.products
|
||||
};
|
||||
});
|
||||
};
|
||||
|
||||
const mergePurchaseNeeds = (manualNeeds, projectNeeds) => {
|
||||
const mergedByKey = new Map();
|
||||
|
||||
[...manualNeeds, ...projectNeeds].forEach(need => {
|
||||
const key = `${need.unit || 'kg'}:${normalizeKey(need.material)}:${need.missingReference ? 'missing' : 'mapped'}`;
|
||||
const current = mergedByKey.get(key);
|
||||
if (!current) {
|
||||
mergedByKey.set(key, {
|
||||
...need,
|
||||
suppliers: new Set(need.suppliers || []),
|
||||
colors: new Set(need.colors || []),
|
||||
products: [...(need.products || [])]
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
current.plannedKg += need.plannedKg;
|
||||
current.stockKg += need.stockKg;
|
||||
current.pendingKg += need.pendingKg;
|
||||
current.purchaseKg += need.purchaseKg;
|
||||
current.priority = need.priority === 'Crítico' || current.priority === 'Crítico'
|
||||
? 'Crítico'
|
||||
: need.priority === 'Atenção' || current.priority === 'Atenção'
|
||||
? 'Atenção'
|
||||
: current.priority;
|
||||
current.status = current.status === 'critical' || need.status === 'critical'
|
||||
? 'critical'
|
||||
: current.status === 'attention' || need.status === 'attention'
|
||||
? 'attention'
|
||||
: 'ok';
|
||||
current.source = current.source === need.source ? current.source : 'mixed';
|
||||
current.missingReference = current.missingReference || Boolean(need.missingReference);
|
||||
(need.suppliers || []).forEach(supplier => current.suppliers.add(supplier));
|
||||
(need.colors || []).forEach(color => current.colors.add(color));
|
||||
current.products.push(...(need.products || []));
|
||||
});
|
||||
|
||||
return Array.from(mergedByKey.values())
|
||||
.map(need => ({
|
||||
...need,
|
||||
suppliers: Array.from(need.suppliers),
|
||||
colors: Array.from(need.colors)
|
||||
}))
|
||||
.sort((a, b) => {
|
||||
const statusOrder = { critical: 1, attention: 2, ok: 3 };
|
||||
return statusOrder[a.status] - statusOrder[b.status] || b.purchaseKg - a.purchaseKg || a.material.localeCompare(b.material);
|
||||
});
|
||||
};
|
||||
|
||||
const buildStats = (receipts, lots, purchaseNeeds) => {
|
||||
const totalQuantityKg = lots.reduce((total, lot) => (
|
||||
lot.unit === 'kg' ? total + lot.quantity : total
|
||||
@@ -203,7 +476,9 @@ const getSupplySummary = async () => {
|
||||
listMovements(),
|
||||
listFabricPlans()
|
||||
]);
|
||||
const purchaseNeeds = buildPurchaseNeeds(fabricPlans, lots, receipts);
|
||||
const manualPurchaseNeeds = buildPurchaseNeeds(fabricPlans, lots, receipts);
|
||||
const projectPurchaseNeeds = await buildProjectPurchaseNeeds(lots, receipts);
|
||||
const purchaseNeeds = mergePurchaseNeeds(manualPurchaseNeeds, projectPurchaseNeeds);
|
||||
|
||||
return {
|
||||
receipts,
|
||||
@@ -222,7 +497,10 @@ const listPurchaseNeeds = async () => {
|
||||
listReceipts()
|
||||
]);
|
||||
|
||||
return buildPurchaseNeeds(plans, lots, receipts);
|
||||
return mergePurchaseNeeds(
|
||||
buildPurchaseNeeds(plans, lots, receipts),
|
||||
await buildProjectPurchaseNeeds(lots, receipts)
|
||||
);
|
||||
};
|
||||
|
||||
const createReceipt = async (payload) => {
|
||||
@@ -377,13 +655,138 @@ const deleteFabricPlan = async (id) => {
|
||||
`, [id]);
|
||||
};
|
||||
|
||||
const updateLotQuantity = async (client, lotId, quantity) => {
|
||||
const status = quantity > 0 ? 'active' : 'depleted';
|
||||
const result = await client.query(`
|
||||
UPDATE supply_stock_lots
|
||||
SET quantity = $2,
|
||||
status = $3,
|
||||
updated_at = CURRENT_TIMESTAMP
|
||||
WHERE id = $1
|
||||
RETURNING id, receipt_id, category, product, quantity, unit, supplier, invoice, status, created_at, updated_at
|
||||
`, [lotId, quantity, status]);
|
||||
|
||||
return mapLot(result.rows[0]);
|
||||
};
|
||||
|
||||
const adjustInventoryLot = async (id, payload) => {
|
||||
const countedQuantity = normalizeNonNegativeNumber(payload.countedQuantity);
|
||||
const reason = normalizeText(payload.reason);
|
||||
|
||||
if (countedQuantity === null) throw createValidationError('Quantidade contada deve ser zero ou maior.');
|
||||
if (!reason) throw createValidationError('Justificativa do ajuste é obrigatória.');
|
||||
|
||||
const client = await pool.connect();
|
||||
|
||||
try {
|
||||
await client.query('BEGIN');
|
||||
|
||||
const lotResult = await client.query(`
|
||||
SELECT id, receipt_id, category, product, quantity, unit, supplier, invoice, status, created_at, updated_at
|
||||
FROM supply_stock_lots
|
||||
WHERE id = $1
|
||||
FOR UPDATE
|
||||
`, [id]);
|
||||
|
||||
if (!lotResult.rowCount) throw createValidationError('Lote não encontrado.');
|
||||
|
||||
const lot = lotResult.rows[0];
|
||||
const currentQuantity = Number(lot.quantity);
|
||||
const difference = countedQuantity - currentQuantity;
|
||||
const updatedLot = await updateLotQuantity(client, lot.id, countedQuantity);
|
||||
|
||||
await client.query(`
|
||||
INSERT INTO supply_movements (
|
||||
lot_id, type, category, product, quantity, unit, reason
|
||||
)
|
||||
VALUES ($1, 'inventory_adjustment', $2, $3, $4, $5, $6)
|
||||
`, [
|
||||
lot.id,
|
||||
lot.category,
|
||||
lot.product,
|
||||
difference,
|
||||
lot.unit,
|
||||
`${reason} · sistema ${currentQuantity} ${lot.unit} · contado ${countedQuantity} ${lot.unit}`
|
||||
]);
|
||||
|
||||
await client.query('COMMIT');
|
||||
return updatedLot;
|
||||
} catch (error) {
|
||||
await client.query('ROLLBACK');
|
||||
throw error;
|
||||
} finally {
|
||||
client.release();
|
||||
}
|
||||
};
|
||||
|
||||
const consumeLotForProduction = async (id, payload) => {
|
||||
const quantity = normalizeNumber(payload.quantity);
|
||||
const reason = normalizeText(payload.reason);
|
||||
const productionOrderNumber = normalizeText(payload.productionOrderNumber);
|
||||
|
||||
if (!quantity) throw createValidationError('Quantidade de saída deve ser maior que zero.');
|
||||
if (!productionOrderNumber && !reason) throw createValidationError('Informe a OP ou uma justificativa para a saída.');
|
||||
|
||||
const client = await pool.connect();
|
||||
|
||||
try {
|
||||
await client.query('BEGIN');
|
||||
|
||||
const lotResult = await client.query(`
|
||||
SELECT id, receipt_id, category, product, quantity, unit, supplier, invoice, status, created_at, updated_at
|
||||
FROM supply_stock_lots
|
||||
WHERE id = $1
|
||||
FOR UPDATE
|
||||
`, [id]);
|
||||
|
||||
if (!lotResult.rowCount) throw createValidationError('Lote não encontrado.');
|
||||
|
||||
const lot = lotResult.rows[0];
|
||||
const currentQuantity = Number(lot.quantity);
|
||||
if (lot.status !== 'active' || currentQuantity <= 0) throw createValidationError('Lote sem saldo disponível.');
|
||||
if (quantity > currentQuantity) throw createValidationError('Quantidade de saída maior que o saldo do lote.');
|
||||
|
||||
const updatedLot = await updateLotQuantity(client, lot.id, currentQuantity - quantity);
|
||||
const movementReason = [
|
||||
productionOrderNumber ? `OP ${productionOrderNumber}` : '',
|
||||
reason
|
||||
].filter(Boolean).join(' · ');
|
||||
|
||||
await client.query(`
|
||||
INSERT INTO supply_movements (
|
||||
lot_id, type, category, product, quantity, unit, reason
|
||||
)
|
||||
VALUES ($1, 'production_exit', $2, $3, $4, $5, $6)
|
||||
`, [
|
||||
lot.id,
|
||||
lot.category,
|
||||
lot.product,
|
||||
-quantity,
|
||||
lot.unit,
|
||||
movementReason || 'Saída para produção'
|
||||
]);
|
||||
|
||||
await client.query('COMMIT');
|
||||
return updatedLot;
|
||||
} catch (error) {
|
||||
await client.query('ROLLBACK');
|
||||
throw error;
|
||||
} finally {
|
||||
client.release();
|
||||
}
|
||||
};
|
||||
|
||||
module.exports = {
|
||||
adjustInventoryLot,
|
||||
approveReceipt,
|
||||
consumeLotForProduction,
|
||||
createFabricPlan,
|
||||
createReceipt,
|
||||
deleteFabricPlan,
|
||||
deleteReceipt,
|
||||
getSupplySummary,
|
||||
buildProjectPurchaseNeeds,
|
||||
buildPurchaseNeeds,
|
||||
listFabricPlans,
|
||||
listLots,
|
||||
listMovements,
|
||||
|
||||
@@ -37,7 +37,7 @@ services:
|
||||
image: gitea.blyzer.com.br/blyzer/graphs-frontend:latest
|
||||
container_name: graph_frontend
|
||||
ports:
|
||||
- "3005:80"
|
||||
- "3002:80"
|
||||
depends_on:
|
||||
- backend
|
||||
restart: unless-stopped
|
||||
|
||||
14
src/catalogLinks.ts
Normal file
14
src/catalogLinks.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
type SkuEditParams = {
|
||||
sku: string;
|
||||
name?: string;
|
||||
color?: string;
|
||||
size?: string;
|
||||
};
|
||||
|
||||
export const buildSkuEditPath = ({ sku, name = '', color = '', size = '' }: SkuEditParams) => {
|
||||
const params = new URLSearchParams({ tab: 'products', sku });
|
||||
if (name) params.set('name', name);
|
||||
if (color) params.set('color', color);
|
||||
if (size) params.set('size', size);
|
||||
return `/registrations?${params.toString()}`;
|
||||
};
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { AuthUser, CampaignPreview, CampaignProcessSummary, CampaignQueueSummary, CatalogCategory, CatalogCategoryPayload, CatalogProduct, CatalogProductPayload, CatalogSummary, ClientAnalyticsItem, ClientDetailsAnalytics, ClientFilterOptions, ClientMetadataFilters, ClientPurchasePatternAnalytics, ConsumptionReference, ConsumptionReferencePayload, CreateUserResult, CuttingSettings, DashboardAnalytics, DateRange, ManagedUser, OrderData, ProductAnalyticsItem, ProductDetailsAnalytics, ProductionOrderSummary, RfmAnalytics, StockData, SupplyFabricPlan, SupplyFabricPlanPayload, SupplyPurchaseNeed, SupplyReceipt, SupplyReceiptPayload, SupplySummary } from './types';
|
||||
import type { AuthUser, CampaignPreview, CampaignProcessSummary, CampaignQueueSummary, CatalogCategory, CatalogCategoryPayload, CatalogProduct, CatalogProductPayload, CatalogSummary, ClientAnalyticsItem, ClientDetailsAnalytics, ClientFilterOptions, ClientMetadataFilters, ClientPurchasePatternAnalytics, ConsumptionReference, ConsumptionReferencePayload, CreateUserResult, CuttingSettings, DashboardAnalytics, DateRange, ManagedUser, OrderData, ProductAnalyticsItem, ProductDetailsAnalytics, ProductionOrderSummary, RfmAnalytics, StockData, SupplyFabricPlan, SupplyFabricPlanPayload, SupplyInventoryAdjustmentPayload, SupplyLot, SupplyProductionExitPayload, SupplyPurchaseNeed, SupplyReceipt, SupplyReceiptPayload, SupplySummary } from './types';
|
||||
import { formatDateParam } from './dateRanges';
|
||||
|
||||
const API_URL = import.meta.env.VITE_API_URL || '/api';
|
||||
@@ -399,6 +399,36 @@ export const fetchSupplyPurchaseNeeds = async (): Promise<SupplyPurchaseNeed[]>
|
||||
}
|
||||
};
|
||||
|
||||
export const adjustSupplyLotInventory = async (lotId: number, payload: SupplyInventoryAdjustmentPayload): Promise<SupplyLot> => {
|
||||
const response = await authFetch(`/supply/lots/${lotId}/inventory-adjustment`, {
|
||||
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 ajustar o inventário.');
|
||||
}
|
||||
|
||||
return data as SupplyLot;
|
||||
};
|
||||
|
||||
export const consumeSupplyLotForProduction = async (lotId: number, payload: SupplyProductionExitPayload): Promise<SupplyLot> => {
|
||||
const response = await authFetch(`/supply/lots/${lotId}/production-exit`, {
|
||||
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 a saída para produção.');
|
||||
}
|
||||
|
||||
return data as SupplyLot;
|
||||
};
|
||||
|
||||
export const fetchDashboardAnalytics = async (dateRange: DateRange, options?: CacheOptions): Promise<DashboardAnalytics | null> => {
|
||||
const path = `/analytics/dashboard?${buildDateRangeParams(dateRange).toString()}`;
|
||||
return getCachedAnalytics(path, async () => {
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { Link, useOutletContext } from 'react-router-dom';
|
||||
import { AlertTriangle, ArrowLeft, ClipboardList, Download, Layers3, Package, Palette, RotateCcw, Ruler, Save as SaveIcon, Scissors, Search, Settings2, X } from 'lucide-react';
|
||||
import { AlertTriangle, ArrowLeft, ClipboardList, Download, Eye, Layers3, Palette, Pencil, RotateCcw, Ruler, Save as SaveIcon, Scissors, Search, Settings2, X } from 'lucide-react';
|
||||
import DateRangePicker from '../components/DateRangePicker';
|
||||
import PaginationControls from '../components/PaginationControls';
|
||||
import ProductColorBadge from '../components/ProductColorBadge';
|
||||
import RefreshStatus from '../components/RefreshStatus';
|
||||
import { buildSkuEditPath } from '../catalogLinks';
|
||||
import { CUT_FAMILY_RULES, buildCutPlan, buildOpenProductionByProductId, type CutFamilyKey, type CutIssue, type CutPlanSkuRow, type CutProductOverride } from '../analytics/cutting';
|
||||
import { exportToCSV, fetchCuttingSettings, fetchProductAnalytics, fetchProductionOrders, saveCuttingSettings } from '../dataService';
|
||||
import type { CuttingSettings, DateRange, ProductAnalyticsItem, ProductionOrderItem } from '../types';
|
||||
@@ -945,13 +946,24 @@ const Cutting = () => {
|
||||
</td>
|
||||
<td className="px-4 py-2.5">{renderIssueBadge(row)}</td>
|
||||
<td className="px-4 py-2.5 text-right">
|
||||
<div className="flex justify-end gap-2">
|
||||
<Link
|
||||
to={buildSkuEditPath({ sku: row.id, name: row.name, color: row.color, size: row.size })}
|
||||
className="inline-flex h-8 w-8 items-center justify-center rounded-lg bg-dark-input text-dark-text transition-colors hover:bg-dark-border"
|
||||
title={`Editar SKU ${row.id}`}
|
||||
aria-label={`Editar SKU ${row.id}`}
|
||||
>
|
||||
<Pencil className="h-3.5 w-3.5" />
|
||||
</Link>
|
||||
<Link
|
||||
to={`/products/${row.id}`}
|
||||
className="inline-flex items-center whitespace-nowrap rounded-lg bg-brand-primary/10 px-3 py-1.5 text-xs font-bold text-brand-primary transition-opacity hover:opacity-80"
|
||||
className="inline-flex h-8 w-8 items-center justify-center rounded-lg bg-brand-primary/10 text-brand-primary transition-opacity hover:opacity-80"
|
||||
title={`Ver SKU ${row.id}`}
|
||||
aria-label={`Ver SKU ${row.id}`}
|
||||
>
|
||||
<Package className="mr-1.5 h-3.5 w-3.5" />
|
||||
Ver SKU
|
||||
<Eye className="h-3.5 w-3.5" />
|
||||
</Link>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { Link, useOutletContext, useParams } from 'react-router-dom';
|
||||
import { DollarSign, Package, Palette, Ruler, TrendingDown, TrendingUp, Warehouse } from 'lucide-react';
|
||||
import { DollarSign, Eye, Package, Palette, Pencil, Ruler, TrendingDown, Warehouse } from 'lucide-react';
|
||||
import BackButton from '../components/BackButton';
|
||||
import DateRangePicker from '../components/DateRangePicker';
|
||||
import PaginationControls from '../components/PaginationControls';
|
||||
import ProductColorBadge, { ProductColorSwatch } from '../components/ProductColorBadge';
|
||||
import RefreshStatus from '../components/RefreshStatus';
|
||||
import { buildSkuEditPath } from '../catalogLinks';
|
||||
import { buildOpenProductionByProductId } from '../analytics/cutting';
|
||||
import { fetchProductAnalytics, fetchProductionOrders } from '../dataService';
|
||||
import { decodeProductGroupKey, normalizeProductText, parseProductName } from '../productParsing';
|
||||
@@ -541,13 +542,24 @@ const ProductGroupDetails = () => {
|
||||
<td className="px-6 py-2.5 whitespace-nowrap text-zinc-500 dark:text-dark-muted">{formatNumber(row.dailySales, 2)} un./dia</td>
|
||||
<td className="px-6 py-2.5 whitespace-nowrap font-bold text-brand-primary">{formatCurrency(row.revenue)}</td>
|
||||
<td className="px-4 py-2.5 text-right">
|
||||
<div className="flex justify-end gap-2">
|
||||
<Link
|
||||
to={buildSkuEditPath({ sku: row.id, name: row.name, color: row.color, size: row.size })}
|
||||
className="inline-flex h-8 w-8 items-center justify-center rounded-lg bg-dark-input text-dark-text transition-colors hover:bg-dark-border"
|
||||
title={`Editar SKU ${row.id}`}
|
||||
aria-label={`Editar SKU ${row.id}`}
|
||||
>
|
||||
<Pencil className="h-3.5 w-3.5" />
|
||||
</Link>
|
||||
<Link
|
||||
to={`/products/${row.id}`}
|
||||
className="inline-flex items-center whitespace-nowrap rounded-lg bg-brand-primary/10 px-3 py-1.5 text-xs font-bold text-brand-primary transition-opacity hover:opacity-80"
|
||||
className="inline-flex h-8 w-8 items-center justify-center rounded-lg bg-brand-primary/10 text-brand-primary transition-opacity hover:opacity-80"
|
||||
title={`Ver SKU ${row.id}`}
|
||||
aria-label={`Ver SKU ${row.id}`}
|
||||
>
|
||||
<TrendingUp className="mr-1.5 h-3.5 w-3.5" />
|
||||
Ver SKU
|
||||
<Eye className="h-3.5 w-3.5" />
|
||||
</Link>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { type FormEvent, useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { Link, useOutletContext } from 'react-router-dom';
|
||||
import { ArrowLeft, CalendarDays, CheckCircle2, ClipboardList, Clock3, Download, PackageCheck, Search } from 'lucide-react';
|
||||
import DateRangePicker from '../components/DateRangePicker';
|
||||
import PaginationControls from '../components/PaginationControls';
|
||||
import RefreshStatus from '../components/RefreshStatus';
|
||||
import { exportToCSV, fetchProductionOrders } from '../dataService';
|
||||
import type { DateRange, ProductionOrderItem, ProductionOrderStatus, ProductionOrderSummary } from '../types';
|
||||
import { consumeSupplyLotForProduction, exportToCSV, fetchProductionOrders, fetchSupplySummary } from '../dataService';
|
||||
import type { DateRange, ProductionOrderItem, ProductionOrderStatus, ProductionOrderSummary, SupplyLot } from '../types';
|
||||
|
||||
type ProductionOrderStatusTab = 'all' | 'open' | 'in_progress' | 'finished' | 'canceled';
|
||||
|
||||
@@ -117,9 +117,18 @@ const ProductionOrders = () => {
|
||||
const [searchTerm, setSearchTerm] = useState('');
|
||||
const [statusFilter, setStatusFilter] = useState<ProductionOrderStatusTab>('all');
|
||||
const [summary, setSummary] = useState<ProductionOrderSummary>(emptySummary);
|
||||
const [supplyLots, setSupplyLots] = useState<SupplyLot[]>([]);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [isSupplyBusy, setIsSupplyBusy] = useState(false);
|
||||
const [supplyMessage, setSupplyMessage] = useState('');
|
||||
const [currentPage, setCurrentPage] = useState(1);
|
||||
const [itemsPerPage, setItemsPerPage] = useState(20);
|
||||
const [exitForm, setExitForm] = useState({
|
||||
orderId: '',
|
||||
lotId: '',
|
||||
quantity: '',
|
||||
reason: '',
|
||||
});
|
||||
|
||||
const loadProductionOrders = useCallback(async (options?: { force?: boolean }) => {
|
||||
setIsLoading(true);
|
||||
@@ -133,9 +142,13 @@ const ProductionOrders = () => {
|
||||
|
||||
const load = async () => {
|
||||
setIsLoading(true);
|
||||
const nextSummary = await fetchProductionOrders(dateRange, { search: searchTerm });
|
||||
const [nextSummary, nextSupplySummary] = await Promise.all([
|
||||
fetchProductionOrders(dateRange, { search: searchTerm }),
|
||||
fetchSupplySummary()
|
||||
]);
|
||||
if (isMounted) {
|
||||
setSummary(nextSummary);
|
||||
setSupplyLots(nextSupplySummary.lots);
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
@@ -177,6 +190,38 @@ const ProductionOrders = () => {
|
||||
void loadProductionOrders({ force: true });
|
||||
};
|
||||
|
||||
const refreshSupplyLots = async () => {
|
||||
const supplySummary = await fetchSupplySummary();
|
||||
setSupplyLots(supplySummary.lots);
|
||||
};
|
||||
|
||||
const selectedOrder = summary.orders.find(order => `${order.id}` === exitForm.orderId);
|
||||
const selectedLot = supplyLots.find(lot => `${lot.id}` === exitForm.lotId);
|
||||
|
||||
const handleProductionExit = async (event: FormEvent<HTMLFormElement>) => {
|
||||
event.preventDefault();
|
||||
const lotId = Number(exitForm.lotId);
|
||||
const quantity = Number(exitForm.quantity.replace(',', '.'));
|
||||
if (!lotId || !selectedOrder || !Number.isFinite(quantity) || quantity <= 0) return;
|
||||
|
||||
setIsSupplyBusy(true);
|
||||
setSupplyMessage('');
|
||||
try {
|
||||
await consumeSupplyLotForProduction(lotId, {
|
||||
quantity,
|
||||
productionOrderNumber: selectedOrder.number || `${selectedOrder.id}`,
|
||||
reason: exitForm.reason.trim() || selectedOrder.productDescription,
|
||||
});
|
||||
await refreshSupplyLots();
|
||||
setExitForm({ orderId: '', lotId: '', quantity: '', reason: '' });
|
||||
setSupplyMessage('Saída de material registrada no estoque.');
|
||||
} catch (error) {
|
||||
setSupplyMessage(error instanceof Error ? error.message : 'Não foi possível baixar o material.');
|
||||
} finally {
|
||||
setIsSupplyBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleExport = () => {
|
||||
const exportData = filteredOrders.map(order => ({
|
||||
'Numero': order.number,
|
||||
@@ -274,6 +319,84 @@ const ProductionOrders = () => {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleProductionExit} className="rounded-2xl border border-dark-border bg-dark-card p-5 shadow-sm">
|
||||
<div className="flex flex-col gap-2 lg:flex-row lg:items-start lg:justify-between">
|
||||
<div>
|
||||
<h2 className="text-base font-bold text-dark-text">Baixa de material da OP</h2>
|
||||
<p className="mt-1 text-sm font-semibold text-dark-muted">Consome um lote do estoque e registra a saída nas movimentações.</p>
|
||||
</div>
|
||||
{selectedLot && (
|
||||
<span className="w-fit rounded-full border border-dark-border bg-dark-input px-2.5 py-1 text-xs font-bold text-dark-muted">
|
||||
Saldo lote #{selectedLot.id}: {formatQuantity(selectedLot.quantity)} {selectedLot.unit}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="mt-4 grid grid-cols-1 gap-3 xl:grid-cols-[1.4fr_1.4fr_120px_1fr_auto]">
|
||||
<label className="text-xs font-bold text-dark-muted">
|
||||
Ordem de produção
|
||||
<select
|
||||
value={exitForm.orderId}
|
||||
onChange={(event) => setExitForm(current => ({ ...current, orderId: 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"
|
||||
>
|
||||
<option value="">Selecione...</option>
|
||||
{summary.orders.filter(order => order.status !== 'finished' && order.status !== 'canceled').map(order => (
|
||||
<option key={order.id} value={order.id}>
|
||||
{order.number || `#${order.id}`} · {order.productDescription}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
<label className="text-xs font-bold text-dark-muted">
|
||||
Lote de material
|
||||
<select
|
||||
value={exitForm.lotId}
|
||||
onChange={(event) => setExitForm(current => ({ ...current, lotId: event.target.value }))}
|
||||
className="mt-1 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"
|
||||
>
|
||||
<option value="">Selecione...</option>
|
||||
{supplyLots.map(lot => (
|
||||
<option key={lot.id} value={lot.id}>
|
||||
#{lot.id} · {lot.product} · {formatQuantity(lot.quantity)} {lot.unit}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
<label className="text-xs font-bold text-dark-muted">
|
||||
Quantidade
|
||||
<input
|
||||
inputMode="decimal"
|
||||
value={exitForm.quantity}
|
||||
onChange={(event) => setExitForm(current => ({ ...current, quantity: event.target.value }))}
|
||||
className="mt-1 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"
|
||||
placeholder="kg"
|
||||
/>
|
||||
</label>
|
||||
<label className="text-xs font-bold text-dark-muted">
|
||||
Motivo
|
||||
<input
|
||||
value={exitForm.reason}
|
||||
onChange={(event) => setExitForm(current => ({ ...current, reason: 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 placeholder:text-dark-muted focus:border-brand-primary"
|
||||
placeholder="ex: corte do pedido"
|
||||
/>
|
||||
</label>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={isSupplyBusy || !supplyLots.length}
|
||||
className="mt-5 inline-flex h-10 items-center justify-center gap-2 rounded-lg border border-dark-border bg-dark-input px-3 text-sm font-bold text-dark-text transition-colors hover:border-brand-primary disabled:cursor-not-allowed disabled:opacity-50"
|
||||
>
|
||||
<PackageCheck className="h-4 w-4 text-brand-primary" />
|
||||
Baixar
|
||||
</button>
|
||||
</div>
|
||||
{supplyMessage && (
|
||||
<div className="mt-3 rounded-lg border border-dark-border bg-dark-input px-3 py-2 text-sm font-bold text-dark-muted">
|
||||
{supplyMessage}
|
||||
</div>
|
||||
)}
|
||||
</form>
|
||||
|
||||
<div className="overflow-hidden rounded-2xl border border-dark-border bg-dark-card shadow-sm">
|
||||
<div className="border-b border-dark-border p-5">
|
||||
<div className="flex flex-col gap-4 xl:flex-row xl:items-center xl:justify-between">
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { Link, useOutletContext } from 'react-router-dom';
|
||||
import { Download, Filter, Package, PackageCheck, Search, TrendingDown, TrendingUp, X } from 'lucide-react';
|
||||
import { Download, Eye, Filter, Package, PackageCheck, Pencil, Search, TrendingDown, X } from 'lucide-react';
|
||||
import { buildSkuEditPath } from '../catalogLinks';
|
||||
import DateRangePicker from '../components/DateRangePicker';
|
||||
import PaginationControls from '../components/PaginationControls';
|
||||
import RefreshStatus from '../components/RefreshStatus';
|
||||
@@ -630,7 +631,7 @@ const Products = () => {
|
||||
<col className="w-[130px]" />
|
||||
<col className="w-[130px]" />
|
||||
<col className="w-[140px]" />
|
||||
<col className="w-[150px]" />
|
||||
<col className="w-[120px]" />
|
||||
</colgroup>
|
||||
<thead className="bg-zinc-50 dark:bg-dark-header border-b border-zinc-100 dark:border-dark-border text-zinc-500 dark:text-dark-muted">
|
||||
<tr>
|
||||
@@ -684,13 +685,26 @@ const Products = () => {
|
||||
</td>
|
||||
<td className="px-6 py-2.5 text-brand-primary font-bold whitespace-nowrap">{formatCurrency(product.revenue)}</td>
|
||||
<td className="px-4 py-2.5 text-right">
|
||||
<div className="flex justify-end gap-2">
|
||||
{viewMode === 'sku' && (
|
||||
<Link
|
||||
to={buildSkuEditPath({ sku: product.id, name: product.name, color: product.color, size: product.size })}
|
||||
className="inline-flex h-8 w-8 items-center justify-center rounded-lg bg-dark-input text-dark-text transition-colors hover:bg-dark-border cursor-pointer"
|
||||
title={`Editar SKU ${product.id}`}
|
||||
aria-label={`Editar SKU ${product.id}`}
|
||||
>
|
||||
<Pencil className="h-3.5 w-3.5" />
|
||||
</Link>
|
||||
)}
|
||||
<Link
|
||||
to={viewMode === 'group' ? `/products/groups/${product.groupKey}` : `/products/${product.id}`}
|
||||
className="inline-flex items-center whitespace-nowrap text-xs font-bold text-brand-primary hover:opacity-80 transition-opacity bg-brand-primary/10 px-3 py-1.5 rounded-lg cursor-pointer"
|
||||
className="inline-flex h-8 w-8 items-center justify-center rounded-lg bg-brand-primary/10 text-brand-primary transition-opacity hover:opacity-80 cursor-pointer"
|
||||
title={viewMode === 'group' ? `Ver grupo ${product.name}` : `Ver SKU ${product.id}`}
|
||||
aria-label={viewMode === 'group' ? `Ver grupo ${product.name}` : `Ver SKU ${product.id}`}
|
||||
>
|
||||
<TrendingUp className="w-3.5 h-3.5 mr-1.5" />
|
||||
{viewMode === 'group' ? 'Ver Grupo' : 'Ver Gráfico'}
|
||||
<Eye className="h-3.5 w-3.5" />
|
||||
</Link>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { useSearchParams } from 'react-router-dom';
|
||||
import { Loader2, Package, RefreshCw, Ruler, Save, Tags, Trash2 } from 'lucide-react';
|
||||
import {
|
||||
deleteCatalogCategory,
|
||||
@@ -35,6 +36,21 @@ const defaultSizeAreas: Record<string, string> = {
|
||||
G5: '1.40'
|
||||
};
|
||||
|
||||
const defaultProductForm = {
|
||||
type: 'finished_product' as CatalogProductType,
|
||||
sku: '',
|
||||
name: '',
|
||||
categoryId: '',
|
||||
composition: '',
|
||||
notes: '',
|
||||
gramature: '',
|
||||
materialYield: '',
|
||||
widthCm: '',
|
||||
color: '',
|
||||
subcategory: 'malha',
|
||||
sizes: ['P', 'M', 'G', 'GG'] as string[]
|
||||
};
|
||||
|
||||
const rawMaterialSubcategories = [
|
||||
{ value: 'fio', label: 'Fio' },
|
||||
{ value: 'malha', label: 'Malha' },
|
||||
@@ -77,28 +93,17 @@ const getAverageYield = (reference: ConsumptionReference) => {
|
||||
};
|
||||
|
||||
const Registrations = () => {
|
||||
const [searchParams] = useSearchParams();
|
||||
const [catalog, setCatalog] = useState<CatalogSummary>(emptyCatalog);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [activeTab, setActiveTab] = useState<RegistrationTab>('products');
|
||||
const [productFilter, setProductFilter] = useState<'all' | CatalogProductType>('all');
|
||||
const [status, setStatus] = useState<SaveStatus>('idle');
|
||||
const [feedback, setFeedback] = useState('');
|
||||
const appliedSkuPrefillRef = useRef('');
|
||||
|
||||
const [categoryForm, setCategoryForm] = useState({ name: '', description: '' });
|
||||
const [productForm, setProductForm] = useState({
|
||||
type: 'finished_product' as CatalogProductType,
|
||||
sku: '',
|
||||
name: '',
|
||||
categoryId: '',
|
||||
composition: '',
|
||||
notes: '',
|
||||
gramature: '',
|
||||
materialYield: '',
|
||||
widthCm: '',
|
||||
color: '',
|
||||
subcategory: 'malha',
|
||||
sizes: ['P', 'M', 'G', 'GG'] as string[]
|
||||
});
|
||||
const [productForm, setProductForm] = useState(defaultProductForm);
|
||||
const [referenceForm, setReferenceForm] = useState({
|
||||
productId: '',
|
||||
materialProductId: '',
|
||||
@@ -136,6 +141,58 @@ const Registrations = () => {
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const requestedTab = searchParams.get('tab');
|
||||
const sku = (searchParams.get('sku') || '').trim();
|
||||
if (!sku && !(requestedTab === 'products' || requestedTab === 'categories' || requestedTab === 'references')) return;
|
||||
|
||||
const prefillKey = `${sku}|${searchParams.get('name') || ''}|${catalog.products.length}`;
|
||||
if (appliedSkuPrefillRef.current === prefillKey) return;
|
||||
appliedSkuPrefillRef.current = prefillKey;
|
||||
|
||||
queueMicrotask(() => {
|
||||
if (requestedTab === 'products' || requestedTab === 'categories' || requestedTab === 'references') {
|
||||
setActiveTab(requestedTab);
|
||||
}
|
||||
|
||||
if (!sku) return;
|
||||
|
||||
const existingProduct = catalog.products.find(product => product.sku.toLowerCase() === sku.toLowerCase());
|
||||
setActiveTab('products');
|
||||
setProductFilter('all');
|
||||
setStatus('idle');
|
||||
|
||||
if (existingProduct) {
|
||||
setProductForm({
|
||||
type: existingProduct.type,
|
||||
sku: existingProduct.sku,
|
||||
name: existingProduct.name,
|
||||
categoryId: existingProduct.categoryId ? String(existingProduct.categoryId) : '',
|
||||
composition: existingProduct.composition || '',
|
||||
notes: existingProduct.notes || '',
|
||||
gramature: existingProduct.gramature ? String(existingProduct.gramature) : '',
|
||||
materialYield: existingProduct.materialYield ? String(existingProduct.materialYield) : '',
|
||||
widthCm: existingProduct.widthCm ? String(existingProduct.widthCm) : '',
|
||||
color: existingProduct.color || searchParams.get('color') || '',
|
||||
subcategory: existingProduct.subcategory || 'malha',
|
||||
sizes: existingProduct.sizes?.length ? existingProduct.sizes : defaultProductForm.sizes
|
||||
});
|
||||
setFeedback(`Editando SKU ${existingProduct.sku}.`);
|
||||
return;
|
||||
}
|
||||
|
||||
const requestedSize = (searchParams.get('size') || '').trim().toUpperCase();
|
||||
setProductForm({
|
||||
...defaultProductForm,
|
||||
sku,
|
||||
name: searchParams.get('name') || '',
|
||||
color: searchParams.get('color') || '',
|
||||
sizes: requestedSize ? [requestedSize] : defaultProductForm.sizes
|
||||
});
|
||||
setFeedback(`Novo cadastro para SKU ${sku}.`);
|
||||
});
|
||||
}, [catalog.products, searchParams]);
|
||||
|
||||
const finishedProducts = useMemo(
|
||||
() => catalog.products.filter(product => product.type === 'finished_product'),
|
||||
[catalog.products]
|
||||
@@ -464,6 +521,16 @@ const Registrations = () => {
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<label className={`block ${labelClassName}`}>
|
||||
Cor
|
||||
<input
|
||||
value={productForm.color}
|
||||
onChange={(event) => setProductForm(current => ({ ...current, color: event.target.value }))}
|
||||
placeholder="ex: Preto"
|
||||
className={inputClassName}
|
||||
/>
|
||||
</label>
|
||||
<div>
|
||||
<p className="mb-2 text-xs font-bold text-dark-muted">Tamanhos disponiveis</p>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
@@ -479,6 +546,7 @@ const Registrations = () => {
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
<label className={`block ${labelClassName}`}>
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { Link, useOutletContext, useSearchParams } from 'react-router-dom';
|
||||
import { AlertTriangle, ArrowLeft, CheckCircle2, Download, Package, Search, TrendingUp } from 'lucide-react';
|
||||
import { AlertTriangle, ArrowLeft, CheckCircle2, Download, Eye, Package, Pencil, Search, TrendingUp } from 'lucide-react';
|
||||
import DateRangePicker from '../components/DateRangePicker';
|
||||
import PaginationControls from '../components/PaginationControls';
|
||||
import RefreshStatus from '../components/RefreshStatus';
|
||||
import { buildSkuEditPath } from '../catalogLinks';
|
||||
import { buildOpenProductionByProductId } from '../analytics/cutting';
|
||||
import type { DateRange, ProductAnalyticsItem, ProductionOrderItem } from '../types';
|
||||
import { exportToCSV, fetchProductAnalytics, fetchProductionOrders } from '../dataService';
|
||||
@@ -572,12 +573,26 @@ const Replenishment = () => {
|
||||
</td>
|
||||
<td className="px-6 py-2.5 font-bold text-zinc-900 dark:text-dark-text whitespace-nowrap">{formatDays(row.daysOfCover)}</td>
|
||||
<td className="px-4 py-2.5 text-right">
|
||||
<div className="flex justify-end gap-2">
|
||||
{viewMode === 'sku' && (
|
||||
<Link
|
||||
to={buildSkuEditPath({ sku: row.id, name: row.name, color: row.color, size: row.size })}
|
||||
className="inline-flex h-8 w-8 items-center justify-center rounded-lg bg-dark-input text-dark-text transition-colors hover:bg-dark-border cursor-pointer"
|
||||
title={`Editar SKU ${row.id}`}
|
||||
aria-label={`Editar SKU ${row.id}`}
|
||||
>
|
||||
<Pencil className="h-3.5 w-3.5" />
|
||||
</Link>
|
||||
)}
|
||||
<Link
|
||||
to={viewMode === 'group' ? `/products/groups/${encodeProductGroupKey(row.baseName)}` : `/products/${row.id}`}
|
||||
className="inline-flex items-center whitespace-nowrap text-xs font-bold text-brand-primary hover:opacity-80 transition-opacity bg-brand-primary/10 px-3 py-1.5 rounded-lg cursor-pointer"
|
||||
className="inline-flex h-8 w-8 items-center justify-center rounded-lg bg-brand-primary/10 text-brand-primary transition-opacity hover:opacity-80 cursor-pointer"
|
||||
title={viewMode === 'group' ? `Ver grupo ${row.baseName}` : `Ver SKU ${row.id}`}
|
||||
aria-label={viewMode === 'group' ? `Ver grupo ${row.baseName}` : `Ver SKU ${row.id}`}
|
||||
>
|
||||
{viewMode === 'group' ? 'Ver grupo' : 'Ver produto'}
|
||||
<Eye className="h-3.5 w-3.5" />
|
||||
</Link>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
|
||||
@@ -21,7 +21,7 @@ import {
|
||||
Trash2,
|
||||
Warehouse,
|
||||
} from 'lucide-react';
|
||||
import { approveSupplyReceipt, createSupplyFabricPlan, createSupplyReceipt, deleteSupplyFabricPlan, deleteSupplyReceipt, fetchSupplySummary } from '../dataService';
|
||||
import { adjustSupplyLotInventory, approveSupplyReceipt, consumeSupplyLotForProduction, createSupplyFabricPlan, createSupplyReceipt, deleteSupplyFabricPlan, deleteSupplyReceipt, fetchSupplySummary } from '../dataService';
|
||||
import type { SupplyFabricPlan, SupplyLot, SupplyMovement, SupplyPurchaseNeed, SupplyReceipt, SupplySummary } from '../types';
|
||||
|
||||
type InventoryTab = 'dashboard' | 'balance' | 'receipts' | 'inventory' | 'movements';
|
||||
@@ -547,13 +547,33 @@ const ReceiptsTab = ({
|
||||
);
|
||||
};
|
||||
|
||||
const InventoryCountTab = ({ lots, onRefresh }: { lots: SupplyLot[]; onRefresh: () => void }) => {
|
||||
const InventoryCountTab = ({
|
||||
lots,
|
||||
onAdjust,
|
||||
onRefresh,
|
||||
}: {
|
||||
lots: SupplyLot[];
|
||||
onAdjust: (lotId: number, countedQuantity: number, reason: string) => Promise<void>;
|
||||
onRefresh: () => void;
|
||||
}) => {
|
||||
const [search, setSearch] = useState('');
|
||||
const [adjustments, setAdjustments] = useState<Record<number, { countedQuantity: string; reason: string }>>({});
|
||||
const normalizedSearch = normalizeSearch(search);
|
||||
const visibleLots = lots.filter(lot => (
|
||||
!normalizedSearch || normalizeSearch(`${lot.product} ${lot.category} ${lot.supplier} ${lot.invoice} ${lot.id}`).includes(normalizedSearch)
|
||||
));
|
||||
|
||||
const updateAdjustment = (lotId: number, patch: Partial<{ countedQuantity: string; reason: string }>) => {
|
||||
setAdjustments(current => ({
|
||||
...current,
|
||||
[lotId]: {
|
||||
countedQuantity: current[lotId]?.countedQuantity ?? '',
|
||||
reason: current[lotId]?.reason ?? '',
|
||||
...patch,
|
||||
},
|
||||
}));
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<button
|
||||
@@ -587,6 +607,42 @@ const InventoryCountTab = ({ lots, onRefresh }: { lots: SupplyLot[]; onRefresh:
|
||||
<p className="mt-1 text-xs font-semibold text-dark-muted">{lot.category} · lote #{lot.id}</p>
|
||||
<p className="mt-3 text-xl font-bold text-dark-text">{formatNumber(lot.quantity)} {lot.unit}</p>
|
||||
<p className="mt-1 text-xs font-semibold text-dark-muted">{lot.supplier || 'Sem fornecedor'}</p>
|
||||
<div className="mt-4 grid grid-cols-1 gap-2">
|
||||
<label className="text-xs font-bold text-dark-muted">
|
||||
Quantidade contada
|
||||
<input
|
||||
inputMode="decimal"
|
||||
value={adjustments[lot.id]?.countedQuantity ?? ''}
|
||||
onChange={(event) => updateAdjustment(lot.id, { countedQuantity: event.target.value })}
|
||||
className={`${inputClassName} mt-1`}
|
||||
placeholder={`${formatNumber(lot.quantity)} ${lot.unit}`}
|
||||
/>
|
||||
</label>
|
||||
<label className="text-xs font-bold text-dark-muted">
|
||||
Justificativa
|
||||
<input
|
||||
value={adjustments[lot.id]?.reason ?? ''}
|
||||
onChange={(event) => updateAdjustment(lot.id, { reason: event.target.value })}
|
||||
className={`${inputClassName} mt-1`}
|
||||
placeholder="ex: contagem física"
|
||||
/>
|
||||
</label>
|
||||
<button
|
||||
type="button"
|
||||
onClick={async () => {
|
||||
const rawQuantity = adjustments[lot.id]?.countedQuantity ?? '';
|
||||
if (!rawQuantity.trim()) return;
|
||||
const nextQuantity = parseDecimal(rawQuantity);
|
||||
const reason = adjustments[lot.id]?.reason.trim() ?? '';
|
||||
await onAdjust(lot.id, nextQuantity, reason);
|
||||
setAdjustments(current => ({ ...current, [lot.id]: { countedQuantity: '', reason: '' } }));
|
||||
}}
|
||||
className={buttonClassName}
|
||||
>
|
||||
<ClipboardCheck className="h-4 w-4" />
|
||||
Ajustar lote
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
@@ -604,10 +660,91 @@ const InventoryCountTab = ({ lots, onRefresh }: { lots: SupplyLot[]; onRefresh:
|
||||
const movementLabels: Record<string, string> = {
|
||||
receipt: 'Entrada por recebimento',
|
||||
inventory_adjustment: 'Ajuste de inventário',
|
||||
production_exit: 'Saída para produção',
|
||||
reversal: 'Estorno',
|
||||
};
|
||||
|
||||
const MovementsTab = ({ movements }: { movements: SupplyMovement[] }) => {
|
||||
const ProductionExitPanel = ({
|
||||
lots,
|
||||
onConsume,
|
||||
}: {
|
||||
lots: SupplyLot[];
|
||||
onConsume: (lotId: number, quantity: number, productionOrderNumber: string, reason: string) => Promise<void>;
|
||||
}) => {
|
||||
const [form, setForm] = useState({
|
||||
lotId: '',
|
||||
quantity: '',
|
||||
productionOrderNumber: '',
|
||||
reason: '',
|
||||
});
|
||||
|
||||
const selectedLot = lots.find(lot => `${lot.id}` === form.lotId);
|
||||
|
||||
const handleSubmit = async (event: FormEvent<HTMLFormElement>) => {
|
||||
event.preventDefault();
|
||||
const lotId = Number(form.lotId);
|
||||
const quantity = parseDecimal(form.quantity);
|
||||
if (!lotId || quantity <= 0) return;
|
||||
|
||||
await onConsume(lotId, quantity, form.productionOrderNumber.trim(), form.reason.trim());
|
||||
setForm({ lotId: '', quantity: '', productionOrderNumber: '', reason: '' });
|
||||
};
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit} className={`${panelClassName} p-5`}>
|
||||
<div className="flex flex-col gap-2 md:flex-row md:items-start md:justify-between">
|
||||
<div>
|
||||
<h2 className="text-base font-bold text-dark-text">Saída para produção</h2>
|
||||
<p className="mt-1 text-sm font-semibold text-dark-muted">Baixa material de um lote e registra movimentação ligada à OP.</p>
|
||||
</div>
|
||||
{selectedLot && (
|
||||
<span className="w-fit rounded-full border border-dark-border bg-dark-input px-2.5 py-1 text-xs font-bold text-dark-muted">
|
||||
Saldo: {formatNumber(selectedLot.quantity)} {selectedLot.unit}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="mt-4 grid grid-cols-1 gap-3 lg:grid-cols-[1.4fr_120px_160px_1fr_auto]">
|
||||
<label className="text-xs font-bold text-dark-muted">
|
||||
Lote
|
||||
<select value={form.lotId} onChange={(event) => setForm(current => ({ ...current, lotId: event.target.value }))} className={`${inputClassName} mt-1`}>
|
||||
<option value="">Selecione...</option>
|
||||
{lots.map(lot => (
|
||||
<option key={lot.id} value={lot.id}>
|
||||
#{lot.id} · {lot.product} · {formatNumber(lot.quantity)} {lot.unit}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
<label className="text-xs font-bold text-dark-muted">
|
||||
Quantidade
|
||||
<input inputMode="decimal" value={form.quantity} onChange={(event) => setForm(current => ({ ...current, quantity: event.target.value }))} className={`${inputClassName} mt-1`} placeholder="kg" />
|
||||
</label>
|
||||
<label className="text-xs font-bold text-dark-muted">
|
||||
OP
|
||||
<input value={form.productionOrderNumber} onChange={(event) => setForm(current => ({ ...current, productionOrderNumber: event.target.value }))} className={`${inputClassName} mt-1`} placeholder="ex: OP-123" />
|
||||
</label>
|
||||
<label className="text-xs font-bold text-dark-muted">
|
||||
Motivo
|
||||
<input value={form.reason} onChange={(event) => setForm(current => ({ ...current, reason: event.target.value }))} className={`${inputClassName} mt-1`} placeholder="ex: corte BLCS" />
|
||||
</label>
|
||||
<button type="submit" className={`${buttonClassName} mt-5`}>
|
||||
<Repeat2 className="h-4 w-4" />
|
||||
Baixar
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
};
|
||||
|
||||
const MovementsTab = ({
|
||||
lots,
|
||||
movements,
|
||||
onConsume,
|
||||
}: {
|
||||
lots: SupplyLot[];
|
||||
movements: SupplyMovement[];
|
||||
onConsume: (lotId: number, quantity: number, productionOrderNumber: string, reason: string) => Promise<void>;
|
||||
}) => {
|
||||
const [search, setSearch] = useState('');
|
||||
const [type, setType] = useState('all');
|
||||
const movementTypes = Array.from(new Set(movements.map(movement => movement.type))).sort();
|
||||
@@ -620,6 +757,7 @@ const MovementsTab = ({ movements }: { movements: SupplyMovement[] }) => {
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<ProductionExitPanel lots={lots} onConsume={onConsume} />
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => exportCsv('movimentacoes-suprimentos.csv', visibleMovements.map(movement => ({
|
||||
@@ -771,8 +909,24 @@ const InventoryScreen = () => {
|
||||
})}
|
||||
/>
|
||||
)}
|
||||
{activeTab === 'inventory' && <InventoryCountTab lots={summary.lots} onRefresh={loadSummary} />}
|
||||
{activeTab === 'movements' && <MovementsTab movements={summary.movements} />}
|
||||
{activeTab === 'inventory' && (
|
||||
<InventoryCountTab
|
||||
lots={summary.lots}
|
||||
onRefresh={loadSummary}
|
||||
onAdjust={(lotId, countedQuantity, reason) => runSupplyAction(async () => {
|
||||
await adjustSupplyLotInventory(lotId, { countedQuantity, reason });
|
||||
})}
|
||||
/>
|
||||
)}
|
||||
{activeTab === 'movements' && (
|
||||
<MovementsTab
|
||||
lots={summary.lots}
|
||||
movements={summary.movements}
|
||||
onConsume={(lotId, quantity, productionOrderNumber, reason) => runSupplyAction(async () => {
|
||||
await consumeSupplyLotForProduction(lotId, { quantity, productionOrderNumber, reason });
|
||||
})}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
@@ -970,6 +1124,24 @@ const purchaseStatusLabels: Record<SupplyPurchaseNeed['status'], string> = {
|
||||
ok: 'Coberto',
|
||||
};
|
||||
|
||||
const getNeedUnit = (need: SupplyPurchaseNeed) => need.unit || 'kg';
|
||||
|
||||
const formatNeedQuantity = (need: SupplyPurchaseNeed, value: number) => (
|
||||
`${formatNumber(value)} ${getNeedUnit(need)}`
|
||||
);
|
||||
|
||||
const summarizePurchaseNeeds = (needs: SupplyPurchaseNeed[]) => {
|
||||
const totalsByUnit = needs.reduce<Record<string, number>>((totals, need) => {
|
||||
if (need.purchaseKg <= 0) return totals;
|
||||
const unit = getNeedUnit(need);
|
||||
totals[unit] = (totals[unit] || 0) + need.purchaseKg;
|
||||
return totals;
|
||||
}, {});
|
||||
|
||||
const summaries = Object.entries(totalsByUnit).map(([unit, total]) => `${formatNumber(total)} ${unit}`);
|
||||
return summaries.length ? summaries.join(' + ') : '0 kg';
|
||||
};
|
||||
|
||||
const PurchaseNeedsScreen = () => {
|
||||
const [summary, setSummary] = useState<SupplySummary>(emptySupplySummary);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
@@ -1005,17 +1177,19 @@ const PurchaseNeedsScreen = () => {
|
||||
!normalizedSearch || normalizeSearch(`${need.material} ${need.suppliers.join(' ')} ${need.colors.join(' ')}`).includes(normalizedSearch)
|
||||
));
|
||||
const purchaseItemCount = summary.purchaseNeeds.filter(need => need.purchaseKg > 0).length;
|
||||
const suggestedPurchaseKg = summary.purchaseNeeds.reduce((total, need) => total + need.purchaseKg, 0);
|
||||
const suggestedPurchaseSummary = summarizePurchaseNeeds(summary.purchaseNeeds);
|
||||
const pendingSupplierCount = new Set(summary.purchaseNeeds.flatMap(need => need.suppliers)).size;
|
||||
const missingReferenceCount = summary.purchaseNeeds.filter(need => need.missingReference).length;
|
||||
|
||||
return (
|
||||
<div className={pageClassName}>
|
||||
<Header title="Necessidade de Compra" subtitle="Materiais abaixo do mínimo e necessidade projetada para compra." backTo="/supplies" />
|
||||
<div className="grid grid-cols-1 gap-4 md:grid-cols-3">
|
||||
<div className="grid grid-cols-1 gap-4 md:grid-cols-4">
|
||||
{[
|
||||
{ label: 'Itens a comprar', value: `${purchaseItemCount}` },
|
||||
{ label: 'Compra sugerida', value: `${formatNumber(suggestedPurchaseKg)} kg` },
|
||||
{ label: 'Fornecedores envolvidos', value: `${pendingSupplierCount}` },
|
||||
{ label: 'Compra sugerida', value: suggestedPurchaseSummary },
|
||||
{ label: 'Fornecedores', value: `${pendingSupplierCount}` },
|
||||
{ label: 'Sem referência', value: `${missingReferenceCount}` },
|
||||
].map(stat => (
|
||||
<div key={stat.label} className="rounded-2xl border border-dark-border bg-dark-card p-5 shadow-sm">
|
||||
<p className="text-xs font-bold uppercase tracking-widest text-dark-muted">{stat.label}</p>
|
||||
@@ -1039,10 +1213,12 @@ const PurchaseNeedsScreen = () => {
|
||||
estoque_kg: need.stockKg,
|
||||
pendente_kg: need.pendingKg,
|
||||
comprar_kg: need.purchaseKg,
|
||||
unidade: getNeedUnit(need),
|
||||
prioridade: need.priority,
|
||||
cobertura: purchaseStatusLabels[need.status],
|
||||
cobertura: need.missingReference ? 'Sem referência de consumo' : purchaseStatusLabels[need.status],
|
||||
fornecedores: need.suppliers.join(' | '),
|
||||
cores: need.colors.join(' | '),
|
||||
produtos: (need.products || []).map(product => `${product.productId} ${product.name}`).join(' | '),
|
||||
})))}
|
||||
className={buttonClassName}
|
||||
>
|
||||
@@ -1075,21 +1251,31 @@ const PurchaseNeedsScreen = () => {
|
||||
<div>
|
||||
<p className="text-sm font-bold text-dark-text">{need.material}</p>
|
||||
<p className="mt-1 text-xs font-semibold text-dark-muted">
|
||||
{(need.colors.length ? need.colors.join(', ') : 'Todas as cores')} · {(need.suppliers.length ? need.suppliers.join(', ') : 'Sem fornecedor')}
|
||||
{need.missingReference
|
||||
? 'Cadastre produto/material em Cadastros > Referência de Consumo'
|
||||
: `${(need.colors.length ? need.colors.join(', ') : 'Todas as cores')} · ${(need.suppliers.length ? need.suppliers.join(', ') : 'Sem fornecedor')}`}
|
||||
</p>
|
||||
{need.products?.length ? (
|
||||
<p className="mt-1 text-xs font-semibold text-dark-muted">
|
||||
{need.products.slice(0, 2).map(product => product.productId).join(', ')}
|
||||
{need.products.length > 2 ? ` +${need.products.length - 2}` : ''}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
<p className="text-sm font-bold text-dark-text">{formatNumber(need.plannedKg)} kg</p>
|
||||
<p className="text-sm font-bold text-dark-text">{formatNumber(need.stockKg)} kg</p>
|
||||
<p className="text-sm font-bold text-dark-text">{formatNumber(need.pendingKg)} kg</p>
|
||||
<p className={`text-sm font-bold ${need.purchaseKg > 0 ? 'text-red-300' : 'text-emerald-300'}`}>{formatNumber(need.purchaseKg)} kg</p>
|
||||
<p className="text-sm font-bold text-dark-text">{formatNeedQuantity(need, need.plannedKg)}</p>
|
||||
<p className="text-sm font-bold text-dark-text">{formatNeedQuantity(need, need.stockKg)}</p>
|
||||
<p className="text-sm font-bold text-dark-text">{formatNeedQuantity(need, need.pendingKg)}</p>
|
||||
<p className={`text-sm font-bold ${need.purchaseKg > 0 ? 'text-red-300' : 'text-emerald-300'}`}>{formatNeedQuantity(need, need.purchaseKg)}</p>
|
||||
<span className={`w-fit rounded-full border px-2.5 py-1 text-xs font-bold ${
|
||||
need.status === 'critical'
|
||||
need.missingReference
|
||||
? 'border-red-400/30 bg-red-400/10 text-red-300'
|
||||
: need.status === 'critical'
|
||||
? 'border-red-400/30 bg-red-400/10 text-red-300'
|
||||
: need.status === 'attention'
|
||||
? 'border-amber-400/30 bg-amber-400/10 text-amber-300'
|
||||
: 'border-emerald-400/30 bg-emerald-400/10 text-emerald-300'
|
||||
}`}>
|
||||
{purchaseStatusLabels[need.status]}
|
||||
{need.missingReference ? 'Sem referência' : purchaseStatusLabels[need.status]}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
|
||||
22
src/types.ts
22
src/types.ts
@@ -232,6 +232,17 @@ export interface SupplyPurchaseNeed {
|
||||
status: 'critical' | 'attention' | 'ok';
|
||||
suppliers: string[];
|
||||
colors: string[];
|
||||
unit?: string;
|
||||
source?: string;
|
||||
missingReference?: boolean;
|
||||
products?: Array<{
|
||||
productId: string;
|
||||
name: string;
|
||||
suggestedQuantity: number;
|
||||
quantitySold: number;
|
||||
stockQuantity: number;
|
||||
yieldPerKg?: number;
|
||||
}>;
|
||||
}
|
||||
|
||||
export interface SupplyStats {
|
||||
@@ -270,6 +281,17 @@ export type SupplyFabricPlanPayload = {
|
||||
priority?: string;
|
||||
};
|
||||
|
||||
export type SupplyInventoryAdjustmentPayload = {
|
||||
countedQuantity: number | string;
|
||||
reason: string;
|
||||
};
|
||||
|
||||
export type SupplyProductionExitPayload = {
|
||||
quantity: number | string;
|
||||
productionOrderNumber?: string;
|
||||
reason?: string;
|
||||
};
|
||||
|
||||
export interface DateRange {
|
||||
start: Date;
|
||||
end: Date;
|
||||
|
||||
Reference in New Issue
Block a user