Compare commits

...

2 Commits

Author SHA1 Message Date
Cauê Faleiros
5f7f9d94d4 Add focused cut item editor
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 49s
2026-08-11 10:40:41 -03:00
Cauê Faleiros
2b618a015e Add editable registrations 2026-08-11 10:28:29 -03:00
5 changed files with 347 additions and 52 deletions

View File

@@ -11,7 +11,9 @@ const {
listCategories, listCategories,
listConsumptionReferences, listConsumptionReferences,
listProducts, listProducts,
updateCategory updateCategory,
updateConsumptionReference,
updateProduct
} = require('../services/catalogService'); } = require('../services/catalogService');
const { importTinyProductCompositionExport } = require('../services/productionOrderService'); 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) => { router.delete('/catalog/products/:id', verifyToken, async (req, res, next) => {
try { try {
await deleteProduct(req.params.id); 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) => { router.post('/catalog/product-compositions/import', verifyToken, async (req, res, next) => {
try { try {
res.status(201).json(await importTinyProductCompositionExport(req.body || {})); res.status(201).json(await importTinyProductCompositionExport(req.body || {}));

View File

@@ -205,6 +205,56 @@ const createProduct = async (payload) => {
return products.find(product => product.id === result.rows[0].id); 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) => { const deleteProduct = async (id) => {
await pool.query('DELETE FROM catalog_products WHERE id = $1', [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); 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) => { const deleteConsumptionReference = async (id) => {
await pool.query('DELETE FROM consumption_references WHERE id = $1', [id]); await pool.query('DELETE FROM consumption_references WHERE id = $1', [id]);
}; };
@@ -300,5 +410,7 @@ module.exports = {
listCategories, listCategories,
listConsumptionReferences, listConsumptionReferences,
listProducts, listProducts,
updateCategory updateCategory,
updateConsumptionReference,
updateProduct
}; };

View File

@@ -367,6 +367,17 @@ export const saveCatalogProduct = async (payload: CatalogProductPayload): Promis
return data as CatalogProduct; return data as CatalogProduct;
}; };
export const updateCatalogProduct = async (id: number, payload: CatalogProductPayload): Promise<CatalogProduct> => {
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<void> => { export const deleteCatalogProduct = async (id: number): Promise<void> => {
const response = await authFetch(`/catalog/products/${id}`, { method: 'DELETE' }); const response = await authFetch(`/catalog/products/${id}`, { method: 'DELETE' });
if (!response.ok) { if (!response.ok) {
@@ -390,6 +401,17 @@ export const saveConsumptionReference = async (payload: ConsumptionReferencePayl
return data as ConsumptionReference; return data as ConsumptionReference;
}; };
export const updateConsumptionReference = async (id: number, payload: ConsumptionReferencePayload): Promise<ConsumptionReference> => {
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<void> => { export const deleteConsumptionReference = async (id: number): Promise<void> => {
const response = await authFetch(`/catalog/consumption-references/${id}`, { method: 'DELETE' }); const response = await authFetch(`/catalog/consumption-references/${id}`, { method: 'DELETE' });
if (!response.ok) { if (!response.ok) {

View File

@@ -5,7 +5,6 @@ import DateRangePicker from '../components/DateRangePicker';
import PaginationControls from '../components/PaginationControls'; import PaginationControls from '../components/PaginationControls';
import ProductColorBadge from '../components/ProductColorBadge'; import ProductColorBadge from '../components/ProductColorBadge';
import RefreshStatus from '../components/RefreshStatus'; import RefreshStatus from '../components/RefreshStatus';
import { buildCuttingSkuConfigPath } from '../catalogLinks';
import { CUT_FAMILY_RULES, buildCutPlan, buildOpenProductionByProductId, type CutFamilyKey, type CutIssue, type CutPlanSkuRow, type CutProductOverride } from '../analytics/cutting'; import { CUT_FAMILY_RULES, buildCutPlan, buildOpenProductionByProductId, type CutFamilyKey, type CutIssue, type CutPlanSkuRow, type CutProductOverride } from '../analytics/cutting';
import { createProductionOrders, exportToCSV, fetchCuttingSettings, fetchProductAnalytics, fetchProductCompositions, fetchProductionOrders, fetchStock, saveCuttingSettings } from '../dataService'; import { createProductionOrders, exportToCSV, fetchCuttingSettings, fetchProductAnalytics, fetchProductCompositions, fetchProductionOrders, fetchStock, saveCuttingSettings } from '../dataService';
import type { CuttingSettings, DateRange, ProductAnalyticsItem, ProductComposition, ProductionOrderItem, StockData } from '../types'; import type { CuttingSettings, DateRange, ProductAnalyticsItem, ProductComposition, ProductionOrderItem, StockData } from '../types';
@@ -160,6 +159,9 @@ const Cutting = () => {
const [itemsPerPage, setItemsPerPage] = useState(10); const [itemsPerPage, setItemsPerPage] = useState(10);
const [isGeneratingOrders, setIsGeneratingOrders] = useState(false); const [isGeneratingOrders, setIsGeneratingOrders] = useState(false);
const [isOrderPreviewOpen, setIsOrderPreviewOpen] = useState(false); const [isOrderPreviewOpen, setIsOrderPreviewOpen] = useState(false);
const [editingCutRow, setEditingCutRow] = useState<CutPlanSkuRow | null>(null);
const [quickCutEdit, setQuickCutEdit] = useState<CutProductOverride>({});
const [isQuickCutEditSaving, setIsQuickCutEditSaving] = useState(false);
const [activeCuttingView, setActiveCuttingView] = useState<CuttingView>('plan'); const [activeCuttingView, setActiveCuttingView] = useState<CuttingView>('plan');
const [generationMessage, setGenerationMessage] = useState(''); const [generationMessage, setGenerationMessage] = useState('');
const cutConfigSection = searchParams.get('config'); const cutConfigSection = searchParams.get('config');
@@ -410,12 +412,14 @@ const Cutting = () => {
}, [correctionItemsPerPage, correctionRows, cutConfigSection, targetCorrectionSku]); }, [correctionItemsPerPage, correctionRows, cutConfigSection, targetCorrectionSku]);
useEffect(() => { useEffect(() => {
if (!isSettingsOpen && !isOrderPreviewOpen) return undefined; if (!isSettingsOpen && !isOrderPreviewOpen && !editingCutRow) return undefined;
const closeOnEscape = (event: KeyboardEvent) => { const closeOnEscape = (event: KeyboardEvent) => {
if (event.key === 'Escape') { if (event.key === 'Escape') {
if (isOrderPreviewOpen) { if (isOrderPreviewOpen) {
setIsOrderPreviewOpen(false); setIsOrderPreviewOpen(false);
} else if (editingCutRow) {
setEditingCutRow(null);
} else { } else {
setIsSettingsOpen(false); setIsSettingsOpen(false);
} }
@@ -429,7 +433,7 @@ const Cutting = () => {
document.body.style.overflow = previousOverflow; document.body.style.overflow = previousOverflow;
document.removeEventListener('keydown', closeOnEscape); document.removeEventListener('keydown', closeOnEscape);
}; };
}, [isOrderPreviewOpen, isSettingsOpen]); }, [editingCutRow, isOrderPreviewOpen, isSettingsOpen]);
const updateFamilyYield = (familyKey: CutFamilyKey, value: string) => { const updateFamilyYield = (familyKey: CutFamilyKey, value: string) => {
const parsedValue = Number(value); const parsedValue = Number(value);
@@ -503,6 +507,55 @@ const Cutting = () => {
setIsSettingsOpen(true); setIsSettingsOpen(true);
}; };
const openQuickCutEdit = (row: CutPlanSkuRow) => {
const override = cuttingSettings.productOverrides[row.id] || {};
setEditingCutRow(row);
setQuickCutEdit({
familyKey: override.familyKey || '',
color: override.color || '',
size: override.size || ''
});
};
const saveQuickCutEdit = async () => {
if (!editingCutRow) return;
const normalizedOverride: CutProductOverride = {
familyKey: quickCutEdit.familyKey || '',
color: quickCutEdit.color || '',
size: quickCutEdit.size || ''
};
const productOverrides = { ...cuttingSettings.productOverrides };
if (!normalizedOverride.familyKey && !normalizedOverride.color && !normalizedOverride.size) {
delete productOverrides[editingCutRow.id];
} else {
productOverrides[editingCutRow.id] = normalizedOverride;
}
const nextSettings = { ...cuttingSettings, productOverrides };
setIsQuickCutEditSaving(true);
try {
const savedSettings = await saveCuttingSettings(nextSettings);
setCuttingSettings(savedSettings);
localStorage.setItem(SETTINGS_STORAGE_KEY, JSON.stringify(savedSettings));
setHasUnsavedSettings(false);
setSaveStatus('saved');
setEditingCutRow(null);
} catch (error) {
console.error('Save quick cut edit failed', error);
setSaveStatus('error');
} finally {
setIsQuickCutEditSaving(false);
}
};
const openBulkCorrectionForRow = (row: CutPlanSkuRow) => {
const correctionIndex = correctionRows.findIndex(item => item.id === row.id);
setEditingCutRow(null);
setSettingsSection('corrections');
setCorrectionIssueFilter('all');
if (correctionIndex >= 0) setCorrectionPage(Math.floor(correctionIndex / correctionItemsPerPage) + 1);
setIsSettingsOpen(true);
};
const exportRows = () => { const exportRows = () => {
exportToCSV(filteredRows.map(row => ({ exportToCSV(filteredRows.map(row => ({
'ID Produto': row.id, 'ID Produto': row.id,
@@ -813,6 +866,39 @@ const Cutting = () => {
</div> </div>
)} )}
{editingCutRow && (
<div className="fixed inset-0 z-[60] flex items-center justify-center bg-black/70 p-4 backdrop-blur-sm" role="dialog" aria-modal="true" aria-label="Editar dados de corte" onClick={() => !isQuickCutEditSaving && setEditingCutRow(null)}>
<div className="w-full max-w-md rounded-2xl border border-dark-border bg-dark-card shadow-2xl" onClick={(event) => event.stopPropagation()}>
<div className="flex items-start justify-between gap-4 border-b border-dark-border p-5">
<div className="min-w-0">
<p className="text-xs font-bold uppercase tracking-widest text-dark-muted">Editar dados de corte</p>
<h2 className="mt-1 truncate text-base font-bold text-dark-text" title={editingCutRow.name}>{editingCutRow.name}</h2>
<p className="mt-1 font-mono text-xs font-semibold text-brand-primary">#{editingCutRow.id}</p>
</div>
<button type="button" onClick={() => setEditingCutRow(null)} disabled={isQuickCutEditSaving} title="Fechar" aria-label="Fechar" className="inline-flex h-9 w-9 shrink-0 items-center justify-center rounded-xl border border-dark-border bg-dark-input text-dark-muted transition-colors hover:border-brand-primary hover:text-dark-text disabled:cursor-not-allowed disabled:opacity-50 cursor-pointer"><X className="h-4 w-4" /></button>
</div>
<div className="space-y-4 p-5">
<div className="flex flex-wrap gap-2 text-xs font-bold text-dark-muted">
<span className={`rounded-full border px-2.5 py-1 ${familyStyles[editingCutRow.family.key]}`}>{editingCutRow.family.materialLabel}</span>
<ProductColorBadge label={editingCutRow.color} className="px-2.5 py-1 text-xs" />
<span className="rounded-full border border-emerald-400/25 bg-emerald-400/10 px-2.5 py-1 text-emerald-300">{editingCutRow.size || 'Sem tamanho'}</span>
</div>
<p className="text-xs font-semibold leading-relaxed text-dark-muted">Altere apenas quando a leitura automática do SKU estiver incorreta. Salvar atualiza este SKU imediatamente.</p>
<label className="block text-xs font-bold text-dark-muted">Família<select value={quickCutEdit.familyKey || ''} onChange={(event) => setQuickCutEdit(current => ({ ...current, familyKey: event.target.value as CutFamilyKey | '' }))} className="mt-1 h-10 w-full rounded-lg border border-dark-border bg-dark-input px-3 text-sm font-bold text-dark-text outline-none focus:border-brand-primary cursor-pointer"><option value="">Automática ({editingCutRow.family.materialLabel})</option>{familyOptions.filter(option => option.value !== 'all').map(option => <option key={option.value} value={option.value}>{option.label}</option>)}</select></label>
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
<label className="text-xs font-bold text-dark-muted">Cor<input value={quickCutEdit.color || ''} onChange={(event) => setQuickCutEdit(current => ({ ...current, color: event.target.value }))} placeholder={editingCutRow.color || 'Informar cor'} className="mt-1 h-10 w-full rounded-lg border border-dark-border bg-dark-input px-3 text-sm font-bold text-dark-text outline-none focus:border-brand-primary" /></label>
<label className="text-xs font-bold text-dark-muted">Tamanho<input value={quickCutEdit.size || ''} onChange={(event) => setQuickCutEdit(current => ({ ...current, size: event.target.value }))} placeholder={editingCutRow.size || 'Informar tamanho'} className="mt-1 h-10 w-full rounded-lg border border-dark-border bg-dark-input px-3 text-sm font-bold text-dark-text outline-none focus:border-brand-primary" /></label>
</div>
<button type="button" onClick={() => setQuickCutEdit({})} className="inline-flex items-center gap-2 text-xs font-bold text-dark-muted transition-colors hover:text-dark-text cursor-pointer"><RotateCcw className="h-3.5 w-3.5" /> Usar leitura automática</button>
</div>
<div className="flex flex-col gap-2 border-t border-dark-border p-4 sm:flex-row sm:items-center sm:justify-between">
<button type="button" onClick={() => openBulkCorrectionForRow(editingCutRow)} disabled={isQuickCutEditSaving} className="text-left text-xs font-bold text-brand-primary transition-colors hover:text-brand-primary/80 disabled:opacity-50 cursor-pointer">Abrir correções em lote</button>
<div className="flex gap-2 sm:justify-end"><button type="button" onClick={() => setEditingCutRow(null)} disabled={isQuickCutEditSaving} className="h-10 rounded-xl border border-dark-border bg-dark-input px-3 text-xs font-bold text-dark-text transition-colors hover:border-brand-primary disabled:opacity-50 cursor-pointer">Cancelar</button><button type="button" onClick={() => void saveQuickCutEdit()} disabled={isQuickCutEditSaving} className="inline-flex h-10 items-center gap-2 rounded-xl bg-brand-primary px-3 text-xs font-bold text-brand-contrast transition-colors hover:bg-brand-primary/90 disabled:cursor-not-allowed disabled:opacity-50 cursor-pointer"><SaveIcon className="h-4 w-4" />{isQuickCutEditSaving ? 'Salvando' : 'Salvar'}</button></div>
</div>
</div>
</div>
)}
{isSettingsOpen && ( {isSettingsOpen && (
<div <div
className="fixed inset-0 z-50 flex h-dvh justify-end overflow-hidden bg-zinc-950/80 backdrop-blur-md dark:bg-black/75" className="fixed inset-0 z-50 flex h-dvh justify-end overflow-hidden bg-zinc-950/80 backdrop-blur-md dark:bg-black/75"
@@ -1316,14 +1402,15 @@ const Cutting = () => {
<td className="px-4 py-2.5 align-middle">{renderCutStatus(row)}</td> <td className="px-4 py-2.5 align-middle">{renderCutStatus(row)}</td>
<td className="px-4 py-2.5 text-right"> <td className="px-4 py-2.5 text-right">
<div className="flex justify-end gap-2"> <div className="flex justify-end gap-2">
<Link <button
to={buildCuttingSkuConfigPath({ sku: row.id })} type="button"
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" onClick={() => openQuickCutEdit(row)}
title={`Configurar corte do SKU ${row.id}`} 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"
aria-label={`Configurar corte do SKU ${row.id}`} title={`Editar dados de corte do SKU ${row.id}`}
aria-label={`Editar dados de corte do SKU ${row.id}`}
> >
<Pencil className="h-3.5 w-3.5" /> <Pencil className="h-3.5 w-3.5" />
</Link> </button>
<Link <Link
to={`/products/${row.id}`} to={`/products/${row.id}`}
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" 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"

View File

@@ -13,7 +13,9 @@ import {
saveCatalogProduct, saveCatalogProduct,
saveConsumptionReference, saveConsumptionReference,
startOlistCompositionSync, startOlistCompositionSync,
updateCatalogCategory updateCatalogCategory,
updateCatalogProduct,
updateConsumptionReference
} from '../dataService'; } from '../dataService';
import PaginationControls from '../components/PaginationControls'; import PaginationControls from '../components/PaginationControls';
import type { CatalogCategory, CatalogProduct, CatalogProductType, CatalogSummary, ConsumptionReference, OlistStatus } from '../types'; import type { CatalogCategory, CatalogProduct, CatalogProductType, CatalogSummary, ConsumptionReference, OlistStatus } from '../types';
@@ -131,7 +133,9 @@ const Registrations = () => {
const [categoryForm, setCategoryForm] = useState({ name: '', description: '' }); const [categoryForm, setCategoryForm] = useState({ name: '', description: '' });
const [editingCategory, setEditingCategory] = useState<CatalogCategory | null>(null); const [editingCategory, setEditingCategory] = useState<CatalogCategory | null>(null);
const [editingProduct, setEditingProduct] = useState<CatalogProduct | null>(null);
const [productForm, setProductForm] = useState(defaultProductForm); const [productForm, setProductForm] = useState(defaultProductForm);
const [editingReference, setEditingReference] = useState<ConsumptionReference | null>(null);
const [referenceForm, setReferenceForm] = useState({ const [referenceForm, setReferenceForm] = useState({
productId: '', productId: '',
materialProductId: '', materialProductId: '',
@@ -329,23 +333,16 @@ const Registrations = () => {
}, editingCategory ? 'Categoria atualizada.' : 'Categoria salva.'); }, editingCategory ? 'Categoria atualizada.' : 'Categoria salva.');
const saveProduct = () => runAction(async () => { const saveProduct = () => runAction(async () => {
await saveCatalogProduct({ const payload = {
...productForm, ...productForm,
categoryId: productForm.categoryId ? Number(productForm.categoryId) : null, categoryId: productForm.categoryId ? Number(productForm.categoryId) : null,
sizes: productForm.type === 'finished_product' ? productForm.sizes : [] sizes: productForm.type === 'finished_product' ? productForm.sizes : []
}); };
setProductForm(current => ({ if (editingProduct) await updateCatalogProduct(editingProduct.id, payload);
...current, else await saveCatalogProduct(payload);
sku: '', setProductForm(defaultProductForm);
name: '', setEditingProduct(null);
composition: '', }, editingProduct ? 'Produto atualizado.' : 'Produto salvo.');
notes: '',
gramature: '',
materialYield: '',
widthCm: '',
color: ''
}));
}, 'Produto salvo.');
const calculateSizeYields = () => { const calculateSizeYields = () => {
const gramature = Number(referenceForm.gramature.replace(',', '.')); const gramature = Number(referenceForm.gramature.replace(',', '.'));
@@ -374,22 +371,20 @@ const Registrations = () => {
}; };
const saveReference = () => runAction(async () => { const saveReference = () => runAction(async () => {
await saveConsumptionReference({ const payload = {
...referenceForm, ...referenceForm,
productId: Number(referenceForm.productId), productId: Number(referenceForm.productId),
materialProductId: referenceForm.materialProductId ? Number(referenceForm.materialProductId) : null, materialProductId: referenceForm.materialProductId ? Number(referenceForm.materialProductId) : null,
sizeAreas: numericMapFromStrings(sizeAreas), sizeAreas: numericMapFromStrings(sizeAreas),
sizeYields: numericMapFromStrings(sizeYields) sizeYields: numericMapFromStrings(sizeYields)
}); };
setReferenceForm(current => ({ if (editingReference) await updateConsumptionReference(editingReference.id, payload);
...current, else await saveConsumptionReference(payload);
productId: '', setReferenceForm({ productId: '', materialProductId: '', color: '', generalYield: '', gramature: '', efficiencyPercent: '85', ribGPerPiece: '', materialCostPerKg: '' });
materialProductId: '', setSizeAreas(defaultSizeAreas);
color: '',
generalYield: ''
}));
setSizeYields({}); setSizeYields({});
}, 'Referencia salva.'); setEditingReference(null);
}, editingReference ? 'Referencia atualizada.' : 'Referencia salva.');
const importCompositionFile = async (file: File | null) => { const importCompositionFile = async (file: File | null) => {
if (!file) return; if (!file) return;
@@ -451,11 +446,61 @@ const Registrations = () => {
`Produto ${product.sku} excluido.` `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( const removeReference = (reference: ConsumptionReference) => runAction(
() => deleteConsumptionReference(reference.id), () => deleteConsumptionReference(reference.id),
`Referencia ${reference.productSku} excluida.` `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) => { const toggleProductSize = (size: string) => {
setProductForm(current => ({ setProductForm(current => ({
...current, ...current,
@@ -621,15 +666,15 @@ const Registrations = () => {
.join(' · ') || 'Sem detalhes'} .join(' · ') || 'Sem detalhes'}
</p> </p>
</div> </div>
<button <div className="flex shrink-0 items-center gap-2">
type="button" <button type="button" onClick={() => editProduct(product)} aria-label={`Editar produto ${product.sku}`} title="Editar produto" className="inline-flex h-9 w-9 items-center justify-center rounded-lg border border-dark-border bg-dark-input text-dark-muted transition-colors hover:border-brand-primary/50 hover:text-brand-primary cursor-pointer">
onClick={() => void removeProduct(product)} <Pencil className="h-4 w-4" />
className="inline-flex h-9 w-9 shrink-0 items-center justify-center rounded-lg border border-dark-border bg-dark-input text-dark-muted transition-colors hover:border-red-400/40 hover:text-red-300 cursor-pointer" </button>
title="Excluir produto" <button type="button" onClick={() => void removeProduct(product)} className="inline-flex h-9 w-9 items-center justify-center rounded-lg border border-dark-border bg-dark-input text-dark-muted transition-colors hover:border-red-400/40 hover:text-red-300 cursor-pointer" title="Excluir produto">
>
<Trash2 className="h-4 w-4" /> <Trash2 className="h-4 w-4" />
</button> </button>
</div> </div>
</div>
))} ))}
{!filteredProducts.length && ( {!filteredProducts.length && (
<div className={emptyStateClassName}>Nenhum produto cadastrado.</div> <div className={emptyStateClassName}>Nenhum produto cadastrado.</div>
@@ -651,7 +696,10 @@ const Registrations = () => {
</div> </div>
<div className={formPanelClassName}> <div className={formPanelClassName}>
<h2 className="text-sm font-bold text-dark-text">Cadastrar produto / materia-prima</h2> <div className="flex items-center justify-between gap-3">
<h2 className="text-sm font-bold text-dark-text">{editingProduct ? 'Editar produto / materia-prima' : 'Cadastrar produto / materia-prima'}</h2>
{editingProduct && <button type="button" onClick={cancelProductEdit} className="text-xs font-bold text-dark-muted transition-colors hover:text-dark-text cursor-pointer">Cancelar</button>}
</div>
<div className="mt-4 space-y-3"> <div className="mt-4 space-y-3">
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2"> <div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
<label className={labelClassName}> <label className={labelClassName}>
@@ -781,7 +829,7 @@ const Registrations = () => {
className="inline-flex h-11 w-full items-center justify-center gap-2 rounded-xl bg-brand-primary px-4 text-sm font-bold text-brand-contrast transition-colors hover:bg-brand-primary/90 disabled:cursor-not-allowed disabled:opacity-50 cursor-pointer" className="inline-flex h-11 w-full items-center justify-center gap-2 rounded-xl bg-brand-primary px-4 text-sm font-bold text-brand-contrast transition-colors hover:bg-brand-primary/90 disabled:cursor-not-allowed disabled:opacity-50 cursor-pointer"
> >
<Save className="h-4 w-4" /> <Save className="h-4 w-4" />
Salvar produto {editingProduct ? 'Salvar alterações' : 'Salvar produto'}
</button> </button>
</div> </div>
</div> </div>
@@ -917,11 +965,16 @@ const Registrations = () => {
</div> </div>
) : null} ) : null}
</div> </div>
<button type="button" onClick={() => void removeReference(reference)} className="inline-flex h-9 w-9 items-center justify-center rounded-lg border border-dark-border bg-dark-input text-dark-muted transition-colors hover:border-red-400/40 hover:text-red-300 cursor-pointer"> <div className="flex items-center gap-2">
<button type="button" onClick={() => editReference(reference)} aria-label={`Editar referência ${reference.productSku}`} title="Editar referência" className="inline-flex h-9 w-9 items-center justify-center rounded-lg border border-dark-border bg-dark-input text-dark-muted transition-colors hover:border-brand-primary/50 hover:text-brand-primary cursor-pointer">
<Pencil className="h-4 w-4" />
</button>
<button type="button" onClick={() => void removeReference(reference)} title="Excluir referência" className="inline-flex h-9 w-9 items-center justify-center rounded-lg border border-dark-border bg-dark-input text-dark-muted transition-colors hover:border-red-400/40 hover:text-red-300 cursor-pointer">
<Trash2 className="h-4 w-4" /> <Trash2 className="h-4 w-4" />
</button> </button>
</div> </div>
</div> </div>
</div>
))} ))}
{!catalog.consumptionReferences.length && ( {!catalog.consumptionReferences.length && (
<div className={emptyStateClassName}> <div className={emptyStateClassName}>
@@ -945,12 +998,15 @@ const Registrations = () => {
</div> </div>
<div className={formPanelClassName}> <div className={formPanelClassName}>
<div className="flex items-start justify-between gap-3">
<div> <div>
<h2 className="text-sm font-bold text-dark-text">Nova referencia de consumo</h2> <h2 className="text-sm font-bold text-dark-text">{editingReference ? 'Editar referencia de consumo' : 'Nova referencia de consumo'}</h2>
<p className="mt-1 text-xs font-semibold text-dark-muted"> <p className="mt-1 text-xs font-semibold text-dark-muted">
Defina pç/kg por produto, malha e cor para o calculo do corte. Defina pç/kg por produto, malha e cor para o calculo do corte.
</p> </p>
</div> </div>
{editingReference && <button type="button" onClick={cancelReferenceEdit} className="text-xs font-bold text-dark-muted transition-colors hover:text-dark-text cursor-pointer">Cancelar</button>}
</div>
<div className="mt-4 space-y-4"> <div className="mt-4 space-y-4">
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2"> <div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
<label className={labelClassName}> <label className={labelClassName}>
@@ -1021,7 +1077,7 @@ const Registrations = () => {
<button type="button" onClick={() => void saveReference()} disabled={status === 'saving'} className="inline-flex h-11 w-full items-center justify-center gap-2 rounded-xl bg-brand-primary px-4 text-sm font-bold text-brand-contrast transition-colors hover:bg-brand-primary/90 disabled:cursor-not-allowed disabled:opacity-50 cursor-pointer"> <button type="button" onClick={() => void saveReference()} disabled={status === 'saving'} className="inline-flex h-11 w-full items-center justify-center gap-2 rounded-xl bg-brand-primary px-4 text-sm font-bold text-brand-contrast transition-colors hover:bg-brand-primary/90 disabled:cursor-not-allowed disabled:opacity-50 cursor-pointer">
<Save className="h-4 w-4" /> <Save className="h-4 w-4" />
Salvar referencia {editingReference ? 'Salvar alterações' : 'Salvar referencia'}
</button> </button>
</div> </div>
</div> </div>