diff --git a/backend/routes/catalogRoutes.js b/backend/routes/catalogRoutes.js index b06e7c2..3d622fd 100644 --- a/backend/routes/catalogRoutes.js +++ b/backend/routes/catalogRoutes.js @@ -11,7 +11,9 @@ const { listCategories, listConsumptionReferences, listProducts, - updateCategory + updateCategory, + updateConsumptionReference, + updateProduct } = require('../services/catalogService'); const { importTinyProductCompositionExport } = require('../services/productionOrderService'); @@ -74,6 +76,14 @@ router.post('/catalog/products', verifyToken, async (req, res, next) => { } }); +router.put('/catalog/products/:id', verifyToken, async (req, res, next) => { + try { + res.json(await updateProduct(req.params.id, req.body || {})); + } catch (error) { + next(error); + } +}); + router.delete('/catalog/products/:id', verifyToken, async (req, res, next) => { try { await deleteProduct(req.params.id); @@ -99,6 +109,14 @@ router.post('/catalog/consumption-references', verifyToken, async (req, res, nex } }); +router.put('/catalog/consumption-references/:id', verifyToken, async (req, res, next) => { + try { + res.json(await updateConsumptionReference(req.params.id, req.body || {})); + } catch (error) { + next(error); + } +}); + router.post('/catalog/product-compositions/import', verifyToken, async (req, res, next) => { try { res.status(201).json(await importTinyProductCompositionExport(req.body || {})); diff --git a/backend/services/catalogService.js b/backend/services/catalogService.js index d206475..741248a 100644 --- a/backend/services/catalogService.js +++ b/backend/services/catalogService.js @@ -205,6 +205,56 @@ const createProduct = async (payload) => { return products.find(product => product.id === result.rows[0].id); }; +const updateProduct = async (id, payload) => { + const type = normalizeProductType(payload.type); + const sku = normalizeText(payload.sku).toUpperCase(); + const name = normalizeText(payload.name); + if (!sku || !name) { + const error = new Error('SKU e nome do produto são obrigatórios.'); + error.statusCode = 400; + throw error; + } + const result = await pool.query(` + UPDATE catalog_products + SET type = $2, + sku = $3, + name = $4, + category_id = $5, + composition = $6, + notes = $7, + gramature = $8, + material_yield = $9, + width_cm = $10, + color = $11, + subcategory = $12, + sizes = $13, + updated_at = CURRENT_TIMESTAMP + WHERE id = $1 + RETURNING id; + `, [ + id, + type, + sku, + name, + Number(payload.categoryId) || null, + normalizeText(payload.composition) || null, + normalizeText(payload.notes) || null, + normalizeNumber(payload.gramature), + normalizeNumber(payload.materialYield), + normalizeNumber(payload.widthCm), + normalizeText(payload.color) || null, + normalizeText(payload.subcategory) || null, + normalizeStringArray(payload.sizes) + ]); + if (!result.rows.length) { + const error = new Error('Produto não encontrado.'); + error.statusCode = 404; + throw error; + } + const products = await listProducts(); + return products.find(product => product.id === result.rows[0].id); +}; + const deleteProduct = async (id) => { await pool.query('DELETE FROM catalog_products WHERE id = $1', [id]); }; @@ -275,6 +325,66 @@ const createConsumptionReference = async (payload) => { return references.find(reference => reference.id === result.rows[0].id); }; +const updateConsumptionReference = async (id, payload) => { + const productId = Number(payload.productId); + if (!productId) { + const error = new Error('Produto é obrigatório.'); + error.statusCode = 400; + throw error; + } + const sizeYields = normalizeNumericMap(payload.sizeYields); + const sizeAreas = normalizeNumericMap(payload.sizeAreas); + const generalYield = normalizeNumber(payload.generalYield); + const calculatedYield = Object.values(sizeYields).length + ? Object.values(sizeYields).reduce((total, value) => total + value, 0) / Object.values(sizeYields).length + : null; + const consumptionQuantity = normalizeNumber(payload.consumptionQuantity); + if (!generalYield && !calculatedYield && !consumptionQuantity) { + const error = new Error('Informe o rendimento ou consumo por peça.'); + error.statusCode = 400; + throw error; + } + const result = await pool.query(` + UPDATE consumption_references + SET product_id = $2, + material_product_id = $3, + color = $4, + general_yield = $5, + size_yields = $6::jsonb, + size_areas = $7::jsonb, + gramature = $8, + efficiency_percent = $9, + rib_g_per_piece = $10, + material_cost_per_kg = $11, + consumption_quantity = $12, + consumption_unit = $13, + updated_at = CURRENT_TIMESTAMP + WHERE id = $1 + RETURNING id; + `, [ + id, + productId, + Number(payload.materialProductId) || null, + normalizeText(payload.color) || null, + generalYield || calculatedYield, + JSON.stringify(sizeYields), + JSON.stringify(sizeAreas), + normalizeNumber(payload.gramature), + normalizeNumber(payload.efficiencyPercent), + normalizeNumber(payload.ribGPerPiece), + normalizeNumber(payload.materialCostPerKg), + consumptionQuantity, + normalizeText(payload.consumptionUnit) || null + ]); + if (!result.rows.length) { + const error = new Error('Referência de consumo não encontrada.'); + error.statusCode = 404; + throw error; + } + const references = await listConsumptionReferences(); + return references.find(reference => reference.id === result.rows[0].id); +}; + const deleteConsumptionReference = async (id) => { await pool.query('DELETE FROM consumption_references WHERE id = $1', [id]); }; @@ -300,5 +410,7 @@ module.exports = { listCategories, listConsumptionReferences, listProducts, - updateCategory + updateCategory, + updateConsumptionReference, + updateProduct }; diff --git a/src/dataService.ts b/src/dataService.ts index 92f37e4..9fa3b36 100644 --- a/src/dataService.ts +++ b/src/dataService.ts @@ -367,6 +367,17 @@ export const saveCatalogProduct = async (payload: CatalogProductPayload): Promis return data as CatalogProduct; }; +export const updateCatalogProduct = async (id: number, payload: CatalogProductPayload): Promise => { + const response = await authFetch(`/catalog/products/${id}`, { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(payload) + }); + const data = await response.json().catch(() => null); + if (!response.ok) throw new Error(data?.error || 'Não foi possível atualizar o produto.'); + return data as CatalogProduct; +}; + export const deleteCatalogProduct = async (id: number): Promise => { const response = await authFetch(`/catalog/products/${id}`, { method: 'DELETE' }); if (!response.ok) { @@ -390,6 +401,17 @@ export const saveConsumptionReference = async (payload: ConsumptionReferencePayl return data as ConsumptionReference; }; +export const updateConsumptionReference = async (id: number, payload: ConsumptionReferencePayload): Promise => { + const response = await authFetch(`/catalog/consumption-references/${id}`, { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(payload) + }); + const data = await response.json().catch(() => null); + if (!response.ok) throw new Error(data?.error || 'Não foi possível atualizar a referência de consumo.'); + return data as ConsumptionReference; +}; + export const deleteConsumptionReference = async (id: number): Promise => { const response = await authFetch(`/catalog/consumption-references/${id}`, { method: 'DELETE' }); if (!response.ok) { diff --git a/src/pages/Registrations.tsx b/src/pages/Registrations.tsx index cfdd51a..063d795 100644 --- a/src/pages/Registrations.tsx +++ b/src/pages/Registrations.tsx @@ -13,7 +13,9 @@ import { saveCatalogProduct, saveConsumptionReference, startOlistCompositionSync, - updateCatalogCategory + updateCatalogCategory, + updateCatalogProduct, + updateConsumptionReference } from '../dataService'; import PaginationControls from '../components/PaginationControls'; import type { CatalogCategory, CatalogProduct, CatalogProductType, CatalogSummary, ConsumptionReference, OlistStatus } from '../types'; @@ -131,7 +133,9 @@ const Registrations = () => { const [categoryForm, setCategoryForm] = useState({ name: '', description: '' }); const [editingCategory, setEditingCategory] = useState(null); + const [editingProduct, setEditingProduct] = useState(null); const [productForm, setProductForm] = useState(defaultProductForm); + const [editingReference, setEditingReference] = useState(null); const [referenceForm, setReferenceForm] = useState({ productId: '', materialProductId: '', @@ -329,23 +333,16 @@ const Registrations = () => { }, editingCategory ? 'Categoria atualizada.' : 'Categoria salva.'); const saveProduct = () => runAction(async () => { - await saveCatalogProduct({ + const payload = { ...productForm, categoryId: productForm.categoryId ? Number(productForm.categoryId) : null, sizes: productForm.type === 'finished_product' ? productForm.sizes : [] - }); - setProductForm(current => ({ - ...current, - sku: '', - name: '', - composition: '', - notes: '', - gramature: '', - materialYield: '', - widthCm: '', - color: '' - })); - }, 'Produto salvo.'); + }; + if (editingProduct) await updateCatalogProduct(editingProduct.id, payload); + else await saveCatalogProduct(payload); + setProductForm(defaultProductForm); + setEditingProduct(null); + }, editingProduct ? 'Produto atualizado.' : 'Produto salvo.'); const calculateSizeYields = () => { const gramature = Number(referenceForm.gramature.replace(',', '.')); @@ -374,22 +371,20 @@ const Registrations = () => { }; const saveReference = () => runAction(async () => { - await saveConsumptionReference({ + const payload = { ...referenceForm, productId: Number(referenceForm.productId), materialProductId: referenceForm.materialProductId ? Number(referenceForm.materialProductId) : null, sizeAreas: numericMapFromStrings(sizeAreas), sizeYields: numericMapFromStrings(sizeYields) - }); - setReferenceForm(current => ({ - ...current, - productId: '', - materialProductId: '', - color: '', - generalYield: '' - })); + }; + if (editingReference) await updateConsumptionReference(editingReference.id, payload); + else await saveConsumptionReference(payload); + setReferenceForm({ productId: '', materialProductId: '', color: '', generalYield: '', gramature: '', efficiencyPercent: '85', ribGPerPiece: '', materialCostPerKg: '' }); + setSizeAreas(defaultSizeAreas); setSizeYields({}); - }, 'Referencia salva.'); + setEditingReference(null); + }, editingReference ? 'Referencia atualizada.' : 'Referencia salva.'); const importCompositionFile = async (file: File | null) => { if (!file) return; @@ -451,11 +446,61 @@ const Registrations = () => { `Produto ${product.sku} excluido.` ); + const editProduct = (product: CatalogProduct) => { + setEditingProduct(product); + setProductForm({ + type: product.type, + sku: product.sku, + name: product.name, + categoryId: product.categoryId ? String(product.categoryId) : '', + composition: product.composition || '', + notes: product.notes || '', + gramature: product.gramature?.toString() || '', + materialYield: product.materialYield?.toString() || '', + widthCm: product.widthCm?.toString() || '', + color: product.color || '', + subcategory: product.subcategory || 'malha', + sizes: product.sizes || [] + }); + setStatus('idle'); + setFeedback(''); + }; + + const cancelProductEdit = () => { + setEditingProduct(null); + setProductForm(defaultProductForm); + }; + const removeReference = (reference: ConsumptionReference) => runAction( () => deleteConsumptionReference(reference.id), `Referencia ${reference.productSku} excluida.` ); + const editReference = (reference: ConsumptionReference) => { + setEditingReference(reference); + setReferenceForm({ + productId: String(reference.productId), + materialProductId: reference.materialProductId ? String(reference.materialProductId) : '', + color: reference.color || '', + generalYield: reference.generalYield?.toString() || '', + gramature: reference.gramature?.toString() || '', + efficiencyPercent: reference.efficiencyPercent?.toString() || '85', + ribGPerPiece: reference.ribGPerPiece?.toString() || '', + materialCostPerKg: reference.materialCostPerKg?.toString() || '' + }); + setSizeAreas(Object.fromEntries(Object.entries(reference.sizeAreas || {}).map(([size, value]) => [size, String(value)]))); + setSizeYields(Object.fromEntries(Object.entries(reference.sizeYields || {}).map(([size, value]) => [size, String(value)]))); + setStatus('idle'); + setFeedback(''); + }; + + const cancelReferenceEdit = () => { + setEditingReference(null); + setReferenceForm({ productId: '', materialProductId: '', color: '', generalYield: '', gramature: '', efficiencyPercent: '85', ribGPerPiece: '', materialCostPerKg: '' }); + setSizeAreas(defaultSizeAreas); + setSizeYields({}); + }; + const toggleProductSize = (size: string) => { setProductForm(current => ({ ...current, @@ -621,14 +666,14 @@ const Registrations = () => { .join(' · ') || 'Sem detalhes'}

- +
+ + +
))} {!filteredProducts.length && ( @@ -651,7 +696,10 @@ const Registrations = () => {
-

Cadastrar produto / materia-prima

+
+

{editingProduct ? 'Editar produto / materia-prima' : 'Cadastrar produto / materia-prima'}

+ {editingProduct && } +
@@ -917,9 +965,14 @@ const Registrations = () => {
) : null} - +
+ + +
))} @@ -945,11 +998,14 @@ const Registrations = () => {
-
-

Nova referencia de consumo

+
+
+

{editingReference ? 'Editar referencia de consumo' : 'Nova referencia de consumo'}

Defina pç/kg por produto, malha e cor para o calculo do corte.

+
+ {editingReference && }
@@ -1021,7 +1077,7 @@ const Registrations = () => {