From 5ed91fa36f01485206c7323e658c78a3153412d3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Cau=C3=AA=20Faleiros?= Date: Mon, 10 Aug 2026 10:15:01 -0300 Subject: [PATCH] Add editable supply material details --- backend/db.js | 11 +++ backend/routes/supplyRoutes.js | 9 ++ backend/services/supplyService.js | 94 ++++++++++++++++++-- src/catalogLinks.ts | 4 +- src/dataService.ts | 20 ++++- src/pages/Registrations.tsx | 2 + src/pages/Supplies.tsx | 138 ++++++++++++++++++++++++++---- src/types.ts | 9 ++ 8 files changed, 261 insertions(+), 26 deletions(-) diff --git a/backend/db.js b/backend/db.js index 39ebeb8..21b161d 100644 --- a/backend/db.js +++ b/backend/db.js @@ -266,6 +266,17 @@ const initDB = async () => { ); `); + await pool.query(` + CREATE TABLE IF NOT EXISTS supply_material_details ( + material_key VARCHAR(255) PRIMARY KEY, + display_name TEXT, + preferred_supplier VARCHAR(255), + notes TEXT, + created_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP + ); + `); + await pool.query(` CREATE TABLE IF NOT EXISTS consumption_references ( id SERIAL PRIMARY KEY, diff --git a/backend/routes/supplyRoutes.js b/backend/routes/supplyRoutes.js index c97b278..4172b9c 100644 --- a/backend/routes/supplyRoutes.js +++ b/backend/routes/supplyRoutes.js @@ -10,6 +10,7 @@ const { deleteReceipt, getSupplySummary, listFabricPlans, + saveMaterialDetails, listLots, listMovements, listPurchaseNeeds, @@ -124,4 +125,12 @@ router.get('/supply/purchase-needs', verifyToken, async (req, res, next) => { } }); +router.put('/supply/materials/:materialKey', verifyToken, async (req, res, next) => { + try { + res.json(await saveMaterialDetails(req.params.materialKey, req.body || {})); + } catch (error) { + next(error); + } +}); + module.exports = router; diff --git a/backend/services/supplyService.js b/backend/services/supplyService.js index 564c2c8..8200886 100644 --- a/backend/services/supplyService.js +++ b/backend/services/supplyService.js @@ -90,6 +90,15 @@ const mapFabricPlan = (row) => ({ updatedAt: row.updated_at }); +const mapMaterialDetails = (row) => ({ + materialKey: row.material_key, + displayName: row.display_name || '', + preferredSupplier: row.preferred_supplier || '', + notes: row.notes || '', + createdAt: row.created_at, + updatedAt: row.updated_at +}); + const createValidationError = (message) => { const error = new Error(message); error.statusCode = 400; @@ -141,6 +150,39 @@ const listFabricPlans = async () => { return result.rows.map(mapFabricPlan); }; +const listMaterialDetails = async () => { + const result = await pool.query(` + SELECT material_key, display_name, preferred_supplier, notes, created_at, updated_at + FROM supply_material_details + `); + return result.rows.map(mapMaterialDetails); +}; + +const saveMaterialDetails = async (materialKey, payload) => { + const normalizedKey = normalizeKey(materialKey); + if (!normalizedKey) throw createValidationError('Material inválido para edição.'); + + const result = await pool.query(` + INSERT INTO supply_material_details ( + material_key, display_name, preferred_supplier, notes, updated_at + ) + VALUES ($1, $2, $3, $4, CURRENT_TIMESTAMP) + ON CONFLICT (material_key) DO UPDATE + SET display_name = EXCLUDED.display_name, + preferred_supplier = EXCLUDED.preferred_supplier, + notes = EXCLUDED.notes, + updated_at = CURRENT_TIMESTAMP + RETURNING material_key, display_name, preferred_supplier, notes, created_at, updated_at + `, [ + normalizedKey, + normalizeText(payload.displayName) || null, + normalizeText(payload.preferredSupplier) || null, + normalizeText(payload.notes) || null + ]); + + return mapMaterialDetails(result.rows[0]); +}; + const buildPurchaseNeeds = (plans, lots, receipts) => { const needsByMaterial = new Map(); @@ -150,6 +192,7 @@ const buildPurchaseNeeds = (plans, lots, receipts) => { const current = needsByMaterial.get(key) || { material: plan.material, + materialKey: normalizeKey(plan.material), plannedKg: 0, stockKg: 0, pendingKg: 0, @@ -189,6 +232,7 @@ const buildPurchaseNeeds = (plans, lots, receipts) => { return { material: need.material, + materialKey: need.materialKey, plannedKg: need.plannedKg, stockKg, pendingKg: need.pendingKg, @@ -309,6 +353,7 @@ const listTinyStockRows = async () => { const mergeNeedLine = (needsByMaterial, key, patch) => { const current = needsByMaterial.get(key) || { material: patch.material, + materialKey: patch.materialKey || normalizeKey(patch.material), plannedKg: 0, stockKg: 0, pendingKg: 0, @@ -397,6 +442,7 @@ const buildProjectPurchaseNeeds = async (lots, receipts) => { mergeNeedLine(needsByMaterial, key, { material, + materialKey: normalizeKey(material), plannedKg: suggestedQuantity * quantityPerUnit, priority: 'Atenção', unit, @@ -557,6 +603,7 @@ const buildProjectPurchaseNeeds = async (lots, receipts) => { return { material: need.material, + materialKey: need.materialKey, plannedKg: need.plannedKg, stockKg, pendingKg: need.pendingKg, @@ -633,6 +680,25 @@ const mergePurchaseNeeds = (manualNeeds, projectNeeds) => { }); }; +const applyMaterialDetails = (purchaseNeeds, materialDetails) => { + const detailsByKey = new Map(materialDetails.map(detail => [detail.materialKey, detail])); + + return purchaseNeeds.map(need => { + const detail = detailsByKey.get(normalizeKey(need.materialKey || need.material)); + if (!detail) return { ...need, preferredSupplier: '', notes: '' }; + + return { + ...need, + material: detail.displayName || need.material, + suppliers: detail.preferredSupplier + ? [detail.preferredSupplier, ...need.suppliers.filter(supplier => supplier !== detail.preferredSupplier)] + : need.suppliers, + preferredSupplier: detail.preferredSupplier, + notes: detail.notes + }; + }); +}; + const buildStats = (receipts, lots, purchaseNeeds) => { const totalQuantityKg = lots.reduce((total, lot) => ( lot.unit === 'kg' ? total + lot.quantity : total @@ -649,15 +715,19 @@ const buildStats = (receipts, lots, purchaseNeeds) => { }; const getSupplySummary = async () => { - const [receipts, lots, movements, fabricPlans] = await Promise.all([ + const [receipts, lots, movements, fabricPlans, materialDetails] = await Promise.all([ listReceipts(), listLots(), listMovements(), - listFabricPlans() + listFabricPlans(), + listMaterialDetails() ]); const manualPurchaseNeeds = buildPurchaseNeeds(fabricPlans, lots, receipts); const projectPurchaseNeeds = await buildProjectPurchaseNeeds(lots, receipts); - const purchaseNeeds = mergePurchaseNeeds(manualPurchaseNeeds, projectPurchaseNeeds); + const purchaseNeeds = applyMaterialDetails( + mergePurchaseNeeds(manualPurchaseNeeds, projectPurchaseNeeds), + materialDetails + ); return { receipts, @@ -670,15 +740,19 @@ const getSupplySummary = async () => { }; const listPurchaseNeeds = async () => { - const [plans, lots, receipts] = await Promise.all([ + const [plans, lots, receipts, materialDetails] = await Promise.all([ listFabricPlans(), listLots(), - listReceipts() + listReceipts(), + listMaterialDetails() ]); - return mergePurchaseNeeds( - buildPurchaseNeeds(plans, lots, receipts), - await buildProjectPurchaseNeeds(lots, receipts) + return applyMaterialDetails( + mergePurchaseNeeds( + buildPurchaseNeeds(plans, lots, receipts), + await buildProjectPurchaseNeeds(lots, receipts) + ), + materialDetails ); }; @@ -967,8 +1041,10 @@ module.exports = { buildProjectPurchaseNeeds, buildPurchaseNeeds, listFabricPlans, + listMaterialDetails, listLots, listMovements, listPurchaseNeeds, - listReceipts + listReceipts, + saveMaterialDetails }; diff --git a/src/catalogLinks.ts b/src/catalogLinks.ts index 2c516d1..de302e2 100644 --- a/src/catalogLinks.ts +++ b/src/catalogLinks.ts @@ -3,13 +3,15 @@ type SkuEditParams = { name?: string; color?: string; size?: string; + type?: 'finished_product' | 'raw_material'; }; -export const buildSkuEditPath = ({ sku, name = '', color = '', size = '' }: SkuEditParams) => { +export const buildSkuEditPath = ({ sku, name = '', color = '', size = '', type }: 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); + if (type) params.set('type', type); return `/registrations?${params.toString()}`; }; diff --git a/src/dataService.ts b/src/dataService.ts index 40edded..fbf4fc7 100644 --- a/src/dataService.ts +++ b/src/dataService.ts @@ -1,4 +1,4 @@ -import type { AuthUser, CampaignPreview, CampaignProcessSummary, CampaignQueueSummary, CatalogCategory, CatalogCategoryPayload, CatalogProduct, CatalogProductPayload, CatalogSummary, ClientAnalyticsItem, ClientDetailsAnalytics, ClientFilterOptions, ClientMetadataFilters, ClientPurchasePatternAnalytics, ConsumptionReference, ConsumptionReferencePayload, CreateProductionOrdersResult, CreateUserResult, CuttingSettings, DashboardAnalytics, DateRange, ManagedUser, OrderData, ProductAnalyticsItem, ProductComposition, ProductCompositionImportSummary, ProductDetailsAnalytics, ProductionOrderItem, ProductionOrderPayload, ProductionOrderStatus, ProductionOrderSummary, RfmAnalytics, SellerAnalyticsItem, SellerDetailsAnalytics, StockData, SupplyFabricPlan, SupplyFabricPlanPayload, SupplyInventoryAdjustmentPayload, SupplyLot, SupplyProductionExitPayload, SupplyPurchaseNeed, SupplyReceipt, SupplyReceiptPayload, SupplySummary } from './types'; +import type { AuthUser, CampaignPreview, CampaignProcessSummary, CampaignQueueSummary, CatalogCategory, CatalogCategoryPayload, CatalogProduct, CatalogProductPayload, CatalogSummary, ClientAnalyticsItem, ClientDetailsAnalytics, ClientFilterOptions, ClientMetadataFilters, ClientPurchasePatternAnalytics, ConsumptionReference, ConsumptionReferencePayload, CreateProductionOrdersResult, CreateUserResult, CuttingSettings, DashboardAnalytics, DateRange, ManagedUser, OrderData, ProductAnalyticsItem, ProductComposition, ProductCompositionImportSummary, ProductDetailsAnalytics, ProductionOrderItem, ProductionOrderPayload, ProductionOrderStatus, ProductionOrderSummary, RfmAnalytics, SellerAnalyticsItem, SellerDetailsAnalytics, StockData, SupplyFabricPlan, SupplyFabricPlanPayload, SupplyInventoryAdjustmentPayload, SupplyLot, SupplyMaterialDetailsPayload, SupplyProductionExitPayload, SupplyPurchaseNeed, SupplyReceipt, SupplyReceiptPayload, SupplySummary } from './types'; import { formatDateParam } from './dateRanges'; const API_URL = import.meta.env.VITE_API_URL || '/api'; @@ -388,6 +388,24 @@ export const fetchSupplySummary = async (): Promise => { } }; +export const saveSupplyMaterialDetails = async ( + materialKey: string, + payload: SupplyMaterialDetailsPayload +): Promise => { + const response = await authFetch(`/supply/materials/${encodeURIComponent(materialKey)}`, { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(payload) + }); + + const data = await response.json().catch(() => null); + if (!response.ok) { + throw new Error(data?.error || 'Não foi possível salvar os dados do material.'); + } + + analyticsCache.clear(); +}; + export const createSupplyReceipt = async (payload: SupplyReceiptPayload): Promise => { const response = await authFetch('/supply/receipts', { method: 'POST', diff --git a/src/pages/Registrations.tsx b/src/pages/Registrations.tsx index 5eda5e7..242bfe0 100644 --- a/src/pages/Registrations.tsx +++ b/src/pages/Registrations.tsx @@ -158,6 +158,7 @@ const Registrations = () => { useEffect(() => { const requestedTab = searchParams.get('tab'); const sku = (searchParams.get('sku') || '').trim(); + const requestedType: CatalogProductType = searchParams.get('type') === 'raw_material' ? 'raw_material' : 'finished_product'; if (!sku && !(requestedTab === 'products' || requestedTab === 'categories' || requestedTab === 'references')) return; const prefillKey = `${requestedTab || ''}|${sku}|${searchParams.get('name') || ''}|${searchParams.get('color') || ''}|${catalog.products.length}`; @@ -210,6 +211,7 @@ const Registrations = () => { const requestedSize = (searchParams.get('size') || '').trim().toUpperCase(); setProductForm({ ...defaultProductForm, + type: requestedType, sku, name: searchParams.get('name') || '', color: searchParams.get('color') || '', diff --git a/src/pages/Supplies.tsx b/src/pages/Supplies.tsx index dc31323..7927e8f 100644 --- a/src/pages/Supplies.tsx +++ b/src/pages/Supplies.tsx @@ -23,13 +23,14 @@ import { Truck, Trash2, Warehouse, + X, } from 'lucide-react'; -import { buildConsumptionReferencePath } from '../catalogLinks'; +import { buildSkuEditPath } from '../catalogLinks'; import DateRangePicker from '../components/DateRangePicker'; import PaginationControls from '../components/PaginationControls'; import ProductTypeBadge from '../components/ProductTypeBadge'; import { classifyCutFamily } from '../analytics/cutting'; -import { adjustSupplyLotInventory, approveSupplyReceipt, consumeSupplyLotForProduction, createSupplyFabricPlan, createSupplyReceipt, deleteSupplyFabricPlan, deleteSupplyReceipt, downloadDatabaseDiagnostic, fetchCuttingSettings, fetchProductAnalytics, fetchProductionOrders, fetchStock, fetchSupplySummary, isSuperAdmin } from '../dataService'; +import { adjustSupplyLotInventory, approveSupplyReceipt, consumeSupplyLotForProduction, createSupplyFabricPlan, createSupplyReceipt, deleteSupplyFabricPlan, deleteSupplyReceipt, downloadDatabaseDiagnostic, fetchCuttingSettings, fetchProductAnalytics, fetchProductionOrders, fetchStock, fetchSupplySummary, isSuperAdmin, saveSupplyMaterialDetails } from '../dataService'; import { parseProductName } from '../productParsing'; import { resolveProductType, type ProductTypeKey } from '../productClassification'; import { getPlanningStock } from '../planningStock'; @@ -2117,14 +2118,6 @@ const AffectedProducts = ({ products }: { products: SupplyPurchaseNeed['products > #{product.productId} · {product.name} - - - ( +const PurchaseMaterialQueue = ({ needs, onEditMaterial }: { needs: SupplyPurchaseNeed[]; onEditMaterial: (need: SupplyPurchaseNeed) => void }) => ( <>
@@ -2182,7 +2175,7 @@ const PurchaseMaterialQueue = ({ needs }: { needs: SupplyPurchaseNeed[] }) => (

{need.colors.length ? need.colors.join(', ') : 'Todas as cores'} • - {need.suppliers.length ? need.suppliers.join(', ') : 'Fornecedor não informado'} + {need.preferredSupplier || (need.suppliers.length ? need.suppliers.join(', ') : 'Fornecedor não informado')}

@@ -2209,6 +2202,15 @@ const PurchaseMaterialQueue = ({ needs }: { needs: SupplyPurchaseNeed[] }) => (

Impacto

{products.length} {products.length === 1 ? 'SKU' : 'SKUs'} dependem

{formatNumber(sumNeedProducts(need, 'suggestedQuantity'))} un. planejadas

+
@@ -2220,7 +2222,7 @@ const PurchaseMaterialQueue = ({ needs }: { needs: SupplyPurchaseNeed[] }) => ( ); -const PurchaseServiceQueue = ({ needs }: { needs: SupplyPurchaseNeed[] }) => ( +const PurchaseServiceQueue = ({ needs, onEditMaterial }: { needs: SupplyPurchaseNeed[]; onEditMaterial: (need: SupplyPurchaseNeed) => void }) => ( <>
@@ -2247,7 +2249,7 @@ const PurchaseServiceQueue = ({ needs }: { needs: SupplyPurchaseNeed[] }) => (

{need.colors.length ? need.colors.join(', ') : 'Todas as cores'} • - {need.suppliers.length ? need.suppliers.join(', ') : 'Fornecedor não informado'} + {need.preferredSupplier || (need.suppliers.length ? need.suppliers.join(', ') : 'Fornecedor não informado')}

@@ -2266,6 +2268,15 @@ const PurchaseServiceQueue = ({ needs }: { needs: SupplyPurchaseNeed[] }) => (

Impacto

{products.length} {products.length === 1 ? 'SKU' : 'SKUs'} dependem

{formatNumber(sumNeedProducts(need, 'suggestedQuantity'))} un. planejadas

+
@@ -2277,11 +2288,95 @@ const PurchaseServiceQueue = ({ needs }: { needs: SupplyPurchaseNeed[] }) => ( ); +const getMaterialRegistrationSku = (material: string) => { + const [candidate] = material.trim().split(/\s+/, 1); + return /^[A-Z0-9_-]{2,}$/i.test(candidate) ? candidate : ''; +}; + +const MaterialDetailsModal = ({ + need, + onClose, + onSave, +}: { + need: SupplyPurchaseNeed; + onClose: () => void; + onSave: (payload: { displayName: string; preferredSupplier: string; notes: string }) => Promise; +}) => { + const [form, setForm] = useState({ + displayName: need.material, + preferredSupplier: need.preferredSupplier || need.suppliers[0] || '', + notes: need.notes || '', + }); + const [isSaving, setIsSaving] = useState(false); + const [error, setError] = useState(''); + const registrationPath = buildSkuEditPath({ + sku: getMaterialRegistrationSku(need.material), + name: need.material, + type: 'raw_material', + }); + + const submit = async (event: FormEvent) => { + event.preventDefault(); + setIsSaving(true); + setError(''); + try { + await onSave(form); + onClose(); + } catch (saveError) { + setError(saveError instanceof Error ? saveError.message : 'Não foi possível salvar os dados do material.'); + } finally { + setIsSaving(false); + } + }; + + return ( +
+
void submit(event)} className="w-full max-w-lg overflow-hidden rounded-2xl border border-dark-border bg-dark-card shadow-2xl"> +
+
+

Dados do material

+

{need.material}

+
+ +
+
+ + +