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