Compare commits

..

3 Commits

Author SHA1 Message Date
Cauê Faleiros
bc05fb4504 Add SKU edit actions
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 2m18s
2026-07-14 10:18:04 -03:00
Cauê Faleiros
060b4da907 Connect supplies to project demand 2026-07-13 15:17:33 -03:00
Cauê Faleiros
8f3f2f3e93 Connect production orders to stock movements 2026-07-13 13:22:11 -03:00
14 changed files with 1005 additions and 88 deletions

View File

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

View File

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

View File

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

View File

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

View File

@@ -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 () => {

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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