Compare commits

...

2 Commits

Author SHA1 Message Date
Cauê Faleiros
c7808702fa Add catalog registrations workflow
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 1m12s
2026-07-09 15:12:27 -03:00
Cauê Faleiros
d3d8886a90 Format product color labels 2026-07-09 13:58:48 -03:00
17 changed files with 1410 additions and 54 deletions

View File

@@ -140,6 +140,54 @@ const initDB = async () => {
); );
`); `);
await pool.query(`
CREATE TABLE IF NOT EXISTS catalog_categories (
id SERIAL PRIMARY KEY,
name VARCHAR(255) NOT NULL UNIQUE,
description TEXT,
created_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP
);
`);
await pool.query(`
CREATE TABLE IF NOT EXISTS catalog_products (
id SERIAL PRIMARY KEY,
type VARCHAR(40) NOT NULL DEFAULT 'finished_product',
sku VARCHAR(100) NOT NULL UNIQUE,
name TEXT NOT NULL,
category_id INTEGER REFERENCES catalog_categories(id) ON DELETE SET NULL,
composition TEXT,
notes TEXT,
gramature NUMERIC(14, 4),
material_yield NUMERIC(14, 4),
width_cm NUMERIC(14, 4),
color VARCHAR(100),
subcategory VARCHAR(80),
sizes TEXT[] DEFAULT '{}',
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,
product_id INTEGER NOT NULL REFERENCES catalog_products(id) ON DELETE CASCADE,
material_product_id INTEGER REFERENCES catalog_products(id) ON DELETE SET NULL,
color VARCHAR(100),
general_yield NUMERIC(14, 4),
size_yields JSONB DEFAULT '{}'::jsonb,
size_areas JSONB DEFAULT '{}'::jsonb,
gramature NUMERIC(14, 4),
efficiency_percent NUMERIC(7, 3),
rib_g_per_piece NUMERIC(14, 4),
material_cost_per_kg NUMERIC(14, 4),
created_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP
);
`);
await pool.query(` await pool.query(`
ALTER TABLE production_orders ALTER TABLE production_orders
ALTER COLUMN created_at TYPE TIMESTAMPTZ USING created_at AT TIME ZONE 'America/Sao_Paulo', ALTER COLUMN created_at TYPE TIMESTAMPTZ USING created_at AT TIME ZONE 'America/Sao_Paulo',
@@ -160,6 +208,30 @@ const initDB = async () => {
ALTER COLUMN updated_at SET DEFAULT CURRENT_TIMESTAMP; ALTER COLUMN updated_at SET DEFAULT CURRENT_TIMESTAMP;
`).catch(() => {}); `).catch(() => {});
await pool.query(`
ALTER TABLE catalog_categories
ALTER COLUMN created_at TYPE TIMESTAMPTZ USING created_at AT TIME ZONE 'America/Sao_Paulo',
ALTER COLUMN created_at SET DEFAULT CURRENT_TIMESTAMP,
ALTER COLUMN updated_at TYPE TIMESTAMPTZ USING updated_at AT TIME ZONE 'America/Sao_Paulo',
ALTER COLUMN updated_at SET DEFAULT CURRENT_TIMESTAMP;
`).catch(() => {});
await pool.query(`
ALTER TABLE catalog_products
ALTER COLUMN created_at TYPE TIMESTAMPTZ USING created_at AT TIME ZONE 'America/Sao_Paulo',
ALTER COLUMN created_at SET DEFAULT CURRENT_TIMESTAMP,
ALTER COLUMN updated_at TYPE TIMESTAMPTZ USING updated_at AT TIME ZONE 'America/Sao_Paulo',
ALTER COLUMN updated_at SET DEFAULT CURRENT_TIMESTAMP;
`).catch(() => {});
await pool.query(`
ALTER TABLE consumption_references
ALTER COLUMN created_at TYPE TIMESTAMPTZ USING created_at AT TIME ZONE 'America/Sao_Paulo',
ALTER COLUMN created_at SET DEFAULT CURRENT_TIMESTAMP,
ALTER COLUMN updated_at TYPE TIMESTAMPTZ USING updated_at AT TIME ZONE 'America/Sao_Paulo',
ALTER COLUMN updated_at SET DEFAULT CURRENT_TIMESTAMP;
`).catch(() => {});
await pool.query(` await pool.query(`
CREATE TABLE IF NOT EXISTS app_users ( CREATE TABLE IF NOT EXISTS app_users (
id SERIAL PRIMARY KEY, id SERIAL PRIMARY KEY,
@@ -228,6 +300,10 @@ const initDB = async () => {
await pool.query(`CREATE INDEX IF NOT EXISTS idx_production_orders_expected_date ON production_orders (expected_date DESC);`); await pool.query(`CREATE INDEX IF NOT EXISTS idx_production_orders_expected_date ON production_orders (expected_date DESC);`);
await pool.query(`CREATE INDEX IF NOT EXISTS idx_production_order_markers_order_id ON production_order_markers (production_order_id);`); await pool.query(`CREATE INDEX IF NOT EXISTS idx_production_order_markers_order_id ON production_order_markers (production_order_id);`);
await pool.query(`CREATE INDEX IF NOT EXISTS idx_cutting_product_overrides_family_key ON cutting_product_overrides (family_key);`); await pool.query(`CREATE INDEX IF NOT EXISTS idx_cutting_product_overrides_family_key ON cutting_product_overrides (family_key);`);
await pool.query(`CREATE INDEX IF NOT EXISTS idx_catalog_products_type ON catalog_products (type);`);
await pool.query(`CREATE INDEX IF NOT EXISTS idx_catalog_products_category_id ON catalog_products (category_id);`);
await pool.query(`CREATE INDEX IF NOT EXISTS idx_consumption_references_product_id ON consumption_references (product_id);`);
await pool.query(`CREATE INDEX IF NOT EXISTS idx_consumption_references_material_product_id ON consumption_references (material_product_id);`);
await pool.query(`CREATE INDEX IF NOT EXISTS idx_orders_cliente_fone ON orders (cliente_fone);`); await pool.query(`CREATE INDEX IF NOT EXISTS idx_orders_cliente_fone ON orders (cliente_fone);`);
await pool.query(` await pool.query(`
CREATE INDEX IF NOT EXISTS idx_orders_normalized_cliente_nome CREATE INDEX IF NOT EXISTS idx_orders_normalized_cliente_nome

View File

@@ -0,0 +1,101 @@
const express = require('express');
const { verifyToken } = require('../auth');
const {
createCategory,
createConsumptionReference,
createProduct,
deleteCategory,
deleteConsumptionReference,
deleteProduct,
getCatalogSummary,
listCategories,
listConsumptionReferences,
listProducts
} = require('../services/catalogService');
const router = express.Router();
router.get('/catalog', verifyToken, async (req, res, next) => {
try {
res.json(await getCatalogSummary());
} catch (error) {
next(error);
}
});
router.get('/catalog/categories', verifyToken, async (req, res, next) => {
try {
res.json(await listCategories());
} catch (error) {
next(error);
}
});
router.post('/catalog/categories', verifyToken, async (req, res, next) => {
try {
res.status(201).json(await createCategory(req.body || {}));
} catch (error) {
next(error);
}
});
router.delete('/catalog/categories/:id', verifyToken, async (req, res, next) => {
try {
await deleteCategory(req.params.id);
res.status(204).end();
} catch (error) {
next(error);
}
});
router.get('/catalog/products', verifyToken, async (req, res, next) => {
try {
res.json(await listProducts());
} catch (error) {
next(error);
}
});
router.post('/catalog/products', verifyToken, async (req, res, next) => {
try {
res.status(201).json(await createProduct(req.body || {}));
} catch (error) {
next(error);
}
});
router.delete('/catalog/products/:id', verifyToken, async (req, res, next) => {
try {
await deleteProduct(req.params.id);
res.status(204).end();
} catch (error) {
next(error);
}
});
router.get('/catalog/consumption-references', verifyToken, async (req, res, next) => {
try {
res.json(await listConsumptionReferences());
} catch (error) {
next(error);
}
});
router.post('/catalog/consumption-references', verifyToken, async (req, res, next) => {
try {
res.status(201).json(await createConsumptionReference(req.body || {}));
} catch (error) {
next(error);
}
});
router.delete('/catalog/consumption-references/:id', verifyToken, async (req, res, next) => {
try {
await deleteConsumptionReference(req.params.id);
res.status(204).end();
} catch (error) {
next(error);
}
});
module.exports = router;

View File

@@ -10,6 +10,7 @@ const analyticsRoutes = require('./routes/analyticsRoutes');
const userRoutes = require('./routes/userRoutes'); const userRoutes = require('./routes/userRoutes');
const productionOrderRoutes = require('./routes/productionOrderRoutes'); const productionOrderRoutes = require('./routes/productionOrderRoutes');
const cuttingSettingsRoutes = require('./routes/cuttingSettingsRoutes'); const cuttingSettingsRoutes = require('./routes/cuttingSettingsRoutes');
const catalogRoutes = require('./routes/catalogRoutes');
const createApp = () => { const createApp = () => {
const app = express(); const app = express();
@@ -23,6 +24,7 @@ const createApp = () => {
app.use('/api', campaignRoutes); app.use('/api', campaignRoutes);
app.use('/api', productionOrderRoutes); app.use('/api', productionOrderRoutes);
app.use('/api', cuttingSettingsRoutes); app.use('/api', cuttingSettingsRoutes);
app.use('/api', catalogRoutes);
app.use('/api', analyticsRoutes); app.use('/api', analyticsRoutes);
app.use('/api', userRoutes); app.use('/api', userRoutes);
app.use('/api/internal', internalRoutes); app.use('/api/internal', internalRoutes);

View File

@@ -0,0 +1,268 @@
const { pool } = require('../db');
const PRODUCT_TYPES = new Set(['finished_product', 'raw_material']);
const normalizeText = (value) => String(value || '').replace(/\s+/g, ' ').trim();
const normalizeNumber = (value) => {
if (value === '' || value === null || value === undefined) return null;
const number = Number(String(value).replace(',', '.'));
return Number.isFinite(number) && number > 0 ? number : null;
};
const normalizeProductType = (value) => {
const normalized = normalizeText(value);
return PRODUCT_TYPES.has(normalized) ? normalized : 'finished_product';
};
const normalizeStringArray = (value) => {
if (!Array.isArray(value)) return [];
return value.map(item => normalizeText(item)).filter(Boolean);
};
const normalizeNumericMap = (value) => {
if (!value || typeof value !== 'object' || Array.isArray(value)) return {};
return Object.entries(value).reduce((normalized, [key, rawValue]) => {
const normalizedKey = normalizeText(key).toUpperCase();
const number = normalizeNumber(rawValue);
if (normalizedKey && number) normalized[normalizedKey] = number;
return normalized;
}, {});
};
const mapCategory = (row) => ({
id: row.id,
name: row.name,
description: row.description || '',
createdAt: row.created_at,
updatedAt: row.updated_at
});
const mapProduct = (row) => ({
id: row.id,
type: row.type,
sku: row.sku,
name: row.name,
categoryId: row.category_id,
categoryName: row.category_name || '',
composition: row.composition || '',
notes: row.notes || '',
gramature: row.gramature === null ? null : Number(row.gramature),
materialYield: row.material_yield === null ? null : Number(row.material_yield),
widthCm: row.width_cm === null ? null : Number(row.width_cm),
color: row.color || '',
subcategory: row.subcategory || '',
sizes: row.sizes || [],
createdAt: row.created_at,
updatedAt: row.updated_at
});
const mapConsumptionReference = (row) => ({
id: row.id,
productId: row.product_id,
productSku: row.product_sku,
productName: row.product_name,
materialProductId: row.material_product_id,
materialSku: row.material_sku || '',
materialName: row.material_name || '',
color: row.color || '',
generalYield: row.general_yield === null ? null : Number(row.general_yield),
sizeYields: row.size_yields || {},
sizeAreas: row.size_areas || {},
gramature: row.gramature === null ? null : Number(row.gramature),
efficiencyPercent: row.efficiency_percent === null ? null : Number(row.efficiency_percent),
ribGPerPiece: row.rib_g_per_piece === null ? null : Number(row.rib_g_per_piece),
materialCostPerKg: row.material_cost_per_kg === null ? null : Number(row.material_cost_per_kg),
createdAt: row.created_at,
updatedAt: row.updated_at
});
const listCategories = async () => {
const result = await pool.query(`
SELECT id, name, description, created_at, updated_at
FROM catalog_categories
ORDER BY name
`);
return result.rows.map(mapCategory);
};
const createCategory = async ({ name, description }) => {
const normalizedName = normalizeText(name);
if (!normalizedName) {
const error = new Error('Nome da categoria é obrigatório.');
error.statusCode = 400;
throw error;
}
const result = await pool.query(`
INSERT INTO catalog_categories (name, description, updated_at)
VALUES ($1, $2, CURRENT_TIMESTAMP)
ON CONFLICT (name) DO UPDATE
SET description = EXCLUDED.description,
updated_at = CURRENT_TIMESTAMP
RETURNING id, name, description, created_at, updated_at
`, [normalizedName, normalizeText(description) || null]);
return mapCategory(result.rows[0]);
};
const deleteCategory = async (id) => {
await pool.query('DELETE FROM catalog_categories WHERE id = $1', [id]);
};
const listProducts = async () => {
const result = await pool.query(`
SELECT
p.id, p.type, p.sku, p.name, p.category_id, c.name AS category_name,
p.composition, p.notes, p.gramature, p.material_yield, p.width_cm,
p.color, p.subcategory, p.sizes, p.created_at, p.updated_at
FROM catalog_products p
LEFT JOIN catalog_categories c ON c.id = p.category_id
ORDER BY p.type, p.name, p.sku
`);
return result.rows.map(mapProduct);
};
const createProduct = async (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 categoryId = Number(payload.categoryId) || null;
const result = await pool.query(`
INSERT INTO catalog_products (
type, sku, name, category_id, composition, notes, gramature,
material_yield, width_cm, color, subcategory, sizes, updated_at
)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, CURRENT_TIMESTAMP)
ON CONFLICT (sku) DO UPDATE
SET type = EXCLUDED.type,
name = EXCLUDED.name,
category_id = EXCLUDED.category_id,
composition = EXCLUDED.composition,
notes = EXCLUDED.notes,
gramature = EXCLUDED.gramature,
material_yield = EXCLUDED.material_yield,
width_cm = EXCLUDED.width_cm,
color = EXCLUDED.color,
subcategory = EXCLUDED.subcategory,
sizes = EXCLUDED.sizes,
updated_at = CURRENT_TIMESTAMP
RETURNING id
`, [
type,
sku,
name,
categoryId,
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)
]);
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]);
};
const listConsumptionReferences = async () => {
const result = await pool.query(`
SELECT
r.id, 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, r.size_areas,
r.gramature, r.efficiency_percent, r.rib_g_per_piece,
r.material_cost_per_kg, r.created_at, r.updated_at
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.name, m.name NULLS FIRST, r.color NULLS FIRST
`);
return result.rows.map(mapConsumptionReference);
};
const createConsumptionReference = async (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;
if (!generalYield && !calculatedYield) {
const error = new Error('Informe o rendimento geral ou por tamanho.');
error.statusCode = 400;
throw error;
}
const result = await pool.query(`
INSERT INTO consumption_references (
product_id, material_product_id, color, general_yield, size_yields,
size_areas, gramature, efficiency_percent, rib_g_per_piece,
material_cost_per_kg, updated_at
)
VALUES ($1, $2, $3, $4, $5::jsonb, $6::jsonb, $7, $8, $9, $10, CURRENT_TIMESTAMP)
RETURNING 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)
]);
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]);
};
const getCatalogSummary = async () => {
const [categories, products, consumptionReferences] = await Promise.all([
listCategories(),
listProducts(),
listConsumptionReferences()
]);
return { categories, products, consumptionReferences };
};
module.exports = {
createCategory,
createConsumptionReference,
createProduct,
deleteCategory,
deleteConsumptionReference,
deleteProduct,
getCatalogSummary,
listCategories,
listConsumptionReferences,
listProducts
};

View File

@@ -15,6 +15,7 @@ const Clients = React.lazy(() => import('./pages/Clients'));
const ClientDetails = React.lazy(() => import('./pages/ClientDetails')); const ClientDetails = React.lazy(() => import('./pages/ClientDetails'));
const Campaigns = React.lazy(() => import('./pages/Campaigns')); const Campaigns = React.lazy(() => import('./pages/Campaigns'));
const Rfm = React.lazy(() => import('./pages/Rfm')); const Rfm = React.lazy(() => import('./pages/Rfm'));
const Registrations = React.lazy(() => import('./pages/Registrations'));
const Login = React.lazy(() => import('./pages/Login')); const Login = React.lazy(() => import('./pages/Login'));
const AdminUsers = React.lazy(() => import('./pages/AdminUsers')); const AdminUsers = React.lazy(() => import('./pages/AdminUsers'));
@@ -59,6 +60,7 @@ function App() {
<Route path="clients/:clientToken" element={<ClientDetails />} /> <Route path="clients/:clientToken" element={<ClientDetails />} />
<Route path="rfm" element={<Rfm />} /> <Route path="rfm" element={<Rfm />} />
<Route path="campaigns" element={<Campaigns />} /> <Route path="campaigns" element={<Campaigns />} />
<Route path="registrations" element={<Registrations />} />
<Route path="admin/users" element={<SuperAdminRoute><AdminUsers /></SuperAdminRoute>} /> <Route path="admin/users" element={<SuperAdminRoute><AdminUsers /></SuperAdminRoute>} />
</Route> </Route>
</Routes> </Routes>

View File

@@ -1,6 +1,6 @@
import { useState, useEffect } from 'react'; import { useState, useEffect } from 'react';
import { Outlet, Link, useLocation } from 'react-router-dom'; import { Outlet, Link, useLocation } from 'react-router-dom';
import { LayoutDashboard, Users, BarChart3, ChevronLeft, ChevronRight, Package, LogOut, Megaphone, Grid3X3, Shield, Moon, Sun, ClipboardList, ShoppingCart, Scissors } from 'lucide-react'; import { LayoutDashboard, Users, BarChart3, ChevronLeft, ChevronRight, Package, LogOut, Megaphone, Grid3X3, Shield, Moon, Sun, ClipboardList, ShoppingCart, Scissors, Tags } from 'lucide-react';
import type { DateRange, OrderData } from '../types'; import type { DateRange, OrderData } from '../types';
import { isSuperAdmin, logout } from '../dataService'; import { isSuperAdmin, logout } from '../dataService';
import { rangeForLastDays } from '../dateRanges'; import { rangeForLastDays } from '../dateRanges';
@@ -59,21 +59,34 @@ const Layout = () => {
setThemeMode(current => current === 'dark' ? 'offwhite' : 'dark'); setThemeMode(current => current === 'dark' ? 'offwhite' : 'dark');
}; };
const appNavigation = [
{ name: 'Dashboard', href: '/graph', icon: LayoutDashboard },
{ name: 'Produtos', href: '/products', icon: Package },
{ name: 'Reposição', href: '/replenishment', icon: ShoppingCart },
{ name: 'Corte', href: '/cutting', icon: Scissors },
{ name: 'Ordens de Produção', href: '/production-orders', icon: ClipboardList },
{ name: 'Clientes', href: '/clients', icon: Users },
{ name: 'RFV', href: '/rfm', icon: Grid3X3 },
{ name: 'Campanhas', href: '/campaigns', icon: Megaphone },
];
const adminNavigation = isSuperAdmin() const adminNavigation = isSuperAdmin()
? [{ name: 'Usuários', href: '/admin/users', icon: Shield }] ? [{ name: 'Usuários', href: '/admin/users', icon: Shield }]
: []; : [];
const navigationSections = [ const navigationSections = [
{ label: 'Painel', items: appNavigation }, {
label: 'Painel',
items: [
{ name: 'Dashboard', href: '/graph', icon: LayoutDashboard },
],
},
{
label: 'Operação',
items: [
{ name: 'Produtos', href: '/products', icon: Package },
{ name: 'Cadastros', href: '/registrations', icon: Tags },
{ name: 'Reposição', href: '/replenishment', icon: ShoppingCart },
{ name: 'Corte', href: '/cutting', icon: Scissors },
{ name: 'Ordens de Produção', href: '/production-orders', icon: ClipboardList },
],
},
{
label: 'Comercial',
items: [
{ name: 'Clientes', href: '/clients', icon: Users },
{ name: 'RFV', href: '/rfm', icon: Grid3X3 },
{ name: 'Campanhas', href: '/campaigns', icon: Megaphone },
],
},
...(adminNavigation.length ? [{ label: 'Super admin', items: adminNavigation }] : []), ...(adminNavigation.length ? [{ label: 'Super admin', items: adminNavigation }] : []),
]; ];
@@ -103,11 +116,11 @@ const Layout = () => {
</div> </div>
)} )}
<nav className="flex-1 space-y-6 overflow-y-auto p-4"> <nav className="flex-1 space-y-5 overflow-y-auto px-3 py-4">
{navigationSections.map((section) => ( {navigationSections.map((section) => (
<div key={section.label} className="space-y-2"> <div key={section.label} className="space-y-1.5">
{!isSidebarCollapsed && ( {!isSidebarCollapsed && (
<div className="px-3 text-xs font-bold uppercase tracking-widest text-dark-muted"> <div className="px-3 pb-1 text-[11px] font-bold uppercase tracking-widest text-dark-muted">
{section.label} {section.label}
</div> </div>
)} )}
@@ -117,17 +130,17 @@ const Layout = () => {
<Link <Link
key={item.name} key={item.name}
to={item.href} to={item.href}
className={`flex items-center rounded-xl px-4 py-3 transition-all ${ className={`group flex min-h-11 items-center rounded-lg px-3 py-2.5 text-sm transition-all ${
isSidebarCollapsed ? 'justify-center' : 'space-x-3' isSidebarCollapsed ? 'justify-center' : 'gap-3'
} ${ } ${
isActive isActive
? 'bg-brand-primary/12 text-brand-primary font-semibold shadow-md shadow-brand-primary/10 ring-1 ring-brand-primary/15' ? 'bg-brand-primary/12 text-brand-primary font-semibold shadow-sm shadow-brand-primary/10 ring-1 ring-brand-primary/15'
: 'text-dark-muted hover:bg-dark-input/70 hover:text-dark-text' : 'text-dark-muted hover:bg-dark-input/70 hover:text-dark-text'
}`} }`}
title={isSidebarCollapsed ? item.name : undefined} title={isSidebarCollapsed ? item.name : undefined}
> >
<item.icon className="h-5 w-5 shrink-0" /> <item.icon className="h-4.5 w-4.5 shrink-0" />
{!isSidebarCollapsed && <span className="font-medium">{item.name}</span>} {!isSidebarCollapsed && <span className="truncate font-medium">{item.name}</span>}
</Link> </Link>
); );
})} })}

View File

@@ -1,29 +1,5 @@
const COLOR_SWATCHES: Array<{ pattern: string; color: string }> = [ import { formatColorLabel } from '../displayFormatters';
{ pattern: 'preto', color: '#171717' }, import { getProductColor } from '../productColors';
{ pattern: 'branco', color: '#f8fafc' },
{ pattern: 'bege', color: '#d7bf9a' },
{ pattern: 'cafe', color: '#79553d' },
{ pattern: 'perola', color: '#e7dfcf' },
{ pattern: 'marinho', color: '#172554' },
{ pattern: 'bordo', color: '#6b1226' },
{ pattern: 'verde', color: '#166534' },
{ pattern: 'rosa', color: '#f0a6bf' },
{ pattern: 'cinza', color: '#8f8f8f' },
{ pattern: 'vermelho', color: '#b91c1c' },
{ pattern: 'grafite', color: '#3f3f46' },
{ pattern: 'azul', color: '#2563eb' },
{ pattern: 'marron', color: '#6b4f3b' },
{ pattern: 'marrom', color: '#6b4f3b' }
];
const normalizeColorLabel = (label: string) => (
label.normalize('NFD').replace(/\p{Diacritic}/gu, '').toLowerCase()
);
export const getProductColor = (label: string) => {
const normalizedLabel = normalizeColorLabel(label);
return COLOR_SWATCHES.find(item => normalizedLabel.includes(item.pattern))?.color || '#64748b';
};
export const ProductColorSwatch = ({ export const ProductColorSwatch = ({
label, label,
@@ -50,7 +26,8 @@ const ProductColorBadge = ({
emptyLabel?: string; emptyLabel?: string;
className?: string; className?: string;
}) => { }) => {
const displayLabel = label?.trim() || emptyLabel; const normalizedLabel = label?.trim();
const displayLabel = normalizedLabel ? formatColorLabel(normalizedLabel) : emptyLabel;
return ( return (
<span className={`inline-flex max-w-full items-center gap-2 rounded-full border border-dark-border bg-dark-input px-2.5 py-1 text-xs font-bold text-dark-text ${className}`}> <span className={`inline-flex max-w-full items-center gap-2 rounded-full border border-dark-border bg-dark-input px-2.5 py-1 text-xs font-bold text-dark-text ${className}`}>

View File

@@ -1,4 +1,4 @@
import type { AuthUser, CampaignPreview, CampaignProcessSummary, CampaignQueueSummary, ClientAnalyticsItem, ClientDetailsAnalytics, ClientFilterOptions, ClientMetadataFilters, ClientPurchasePatternAnalytics, CreateUserResult, CuttingSettings, DashboardAnalytics, DateRange, ManagedUser, OrderData, ProductAnalyticsItem, ProductDetailsAnalytics, ProductionOrderSummary, RfmAnalytics, StockData } 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 } from './types';
import { formatDateParam } from './dateRanges'; import { formatDateParam } from './dateRanges';
const API_URL = import.meta.env.VITE_API_URL || '/api'; const API_URL = import.meta.env.VITE_API_URL || '/api';
@@ -209,6 +209,86 @@ export const saveCuttingSettings = async (settings: CuttingSettings): Promise<Cu
return data as CuttingSettings; return data as CuttingSettings;
}; };
export const fetchCatalogSummary = async (): Promise<CatalogSummary> => {
try {
const response = await authFetch('/catalog');
if (!response.ok) return { categories: [], products: [], consumptionReferences: [] };
return await response.json();
} catch (error) {
console.error('Fetch catalog summary failed', error);
return { categories: [], products: [], consumptionReferences: [] };
}
};
export const saveCatalogCategory = async (payload: CatalogCategoryPayload): Promise<CatalogCategory> => {
const response = await authFetch('/catalog/categories', {
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 salvar a categoria.');
}
return data as CatalogCategory;
};
export const deleteCatalogCategory = async (id: number): Promise<void> => {
const response = await authFetch(`/catalog/categories/${id}`, { method: 'DELETE' });
if (!response.ok) {
const data = await response.json().catch(() => null);
throw new Error(data?.error || 'Não foi possível excluir a categoria.');
}
};
export const saveCatalogProduct = async (payload: CatalogProductPayload): Promise<CatalogProduct> => {
const response = await authFetch('/catalog/products', {
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 salvar o produto.');
}
return data as CatalogProduct;
};
export const deleteCatalogProduct = async (id: number): Promise<void> => {
const response = await authFetch(`/catalog/products/${id}`, { method: 'DELETE' });
if (!response.ok) {
const data = await response.json().catch(() => null);
throw new Error(data?.error || 'Não foi possível excluir o produto.');
}
};
export const saveConsumptionReference = async (payload: ConsumptionReferencePayload): Promise<ConsumptionReference> => {
const response = await authFetch('/catalog/consumption-references', {
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 salvar a referência de consumo.');
}
return data as ConsumptionReference;
};
export const deleteConsumptionReference = async (id: number): Promise<void> => {
const response = await authFetch(`/catalog/consumption-references/${id}`, { method: 'DELETE' });
if (!response.ok) {
const data = await response.json().catch(() => null);
throw new Error(data?.error || 'Não foi possível excluir a referência.');
}
};
export const fetchDashboardAnalytics = async (dateRange: DateRange, options?: CacheOptions): Promise<DashboardAnalytics | null> => { export const fetchDashboardAnalytics = async (dateRange: DateRange, options?: CacheOptions): Promise<DashboardAnalytics | null> => {
const path = `/analytics/dashboard?${buildDateRangeParams(dateRange).toString()}`; const path = `/analytics/dashboard?${buildDateRangeParams(dateRange).toString()}`;
return getCachedAnalytics(path, async () => { return getCachedAnalytics(path, async () => {

View File

@@ -24,3 +24,18 @@ export const formatDisplayName = (value: string) => {
}) })
.join(' '); .join(' ');
}; };
export const formatColorLabel = (value: string) => {
const normalized = String(value || '').replace(/\s+/g, ' ').trim();
if (!normalized) return '';
if (normalized.toLocaleLowerCase('pt-BR') === 'sem cor') return 'Sem cor';
return normalized
.toLocaleLowerCase('pt-BR')
.split(' ')
.map((word, index) => {
if (index > 0 && SMALL_WORDS.has(word)) return word;
return word.charAt(0).toLocaleUpperCase('pt-BR') + word.slice(1);
})
.join(' ');
};

View File

@@ -164,6 +164,7 @@
color: #9a6700 !important; color: #9a6700 !important;
} }
html[data-theme='offwhite'] .text-sky-200,
html[data-theme='offwhite'] .text-sky-300, html[data-theme='offwhite'] .text-sky-300,
html[data-theme='offwhite'] .text-sky-400, html[data-theme='offwhite'] .text-sky-400,
html[data-theme='offwhite'] .text-sky-500 { html[data-theme='offwhite'] .text-sky-500 {
@@ -231,6 +232,7 @@
border-color: rgba(154, 103, 0, 0.30) !important; border-color: rgba(154, 103, 0, 0.30) !important;
} }
html[data-theme='offwhite'] .border-sky-400\/20,
html[data-theme='offwhite'] .border-sky-400\/25, html[data-theme='offwhite'] .border-sky-400\/25,
html[data-theme='offwhite'] .border-sky-400\/30, html[data-theme='offwhite'] .border-sky-400\/30,
html[data-theme='offwhite'] .border-sky-400\/35 { html[data-theme='offwhite'] .border-sky-400\/35 {

View File

@@ -8,6 +8,7 @@ import RefreshStatus from '../components/RefreshStatus';
import type { DateRange, ProductDetailsAnalytics } from '../types'; import type { DateRange, ProductDetailsAnalytics } from '../types';
import { fetchProductDetailsAnalytics } from '../dataService'; import { fetchProductDetailsAnalytics } from '../dataService';
import { parseProductName } from '../productParsing'; import { parseProductName } from '../productParsing';
import { formatColorLabel } from '../displayFormatters';
const CHART_GRID_COLOR = 'var(--chart-grid)'; const CHART_GRID_COLOR = 'var(--chart-grid)';
const CHART_AXIS_COLOR = 'var(--chart-axis)'; const CHART_AXIS_COLOR = 'var(--chart-axis)';
@@ -361,7 +362,7 @@ const ProductDetails = () => {
<span>#{variant.id}</span> <span>#{variant.id}</span>
{metadata.color && ( {metadata.color && (
<span className="rounded-full border border-sky-400/25 bg-sky-400/10 px-2 py-0.5 font-bold text-sky-300"> <span className="rounded-full border border-sky-400/25 bg-sky-400/10 px-2 py-0.5 font-bold text-sky-300">
{metadata.color} {formatColorLabel(metadata.color)}
</span> </span>
)} )}
{metadata.size && ( {metadata.size && (

View File

@@ -4,11 +4,13 @@ import { DollarSign, Package, Palette, Ruler, TrendingDown, TrendingUp, Warehous
import BackButton from '../components/BackButton'; import BackButton from '../components/BackButton';
import DateRangePicker from '../components/DateRangePicker'; import DateRangePicker from '../components/DateRangePicker';
import PaginationControls from '../components/PaginationControls'; import PaginationControls from '../components/PaginationControls';
import ProductColorBadge, { ProductColorSwatch, getProductColor } from '../components/ProductColorBadge'; import ProductColorBadge, { ProductColorSwatch } from '../components/ProductColorBadge';
import RefreshStatus from '../components/RefreshStatus'; import RefreshStatus from '../components/RefreshStatus';
import { buildOpenProductionByProductId } from '../analytics/cutting'; import { buildOpenProductionByProductId } from '../analytics/cutting';
import { fetchProductAnalytics, fetchProductionOrders } from '../dataService'; import { fetchProductAnalytics, fetchProductionOrders } from '../dataService';
import { decodeProductGroupKey, normalizeProductText, parseProductName } from '../productParsing'; import { decodeProductGroupKey, normalizeProductText, parseProductName } from '../productParsing';
import { formatColorLabel } from '../displayFormatters';
import { getProductColor } from '../productColors';
import type { DateRange, ProductAnalyticsItem, ProductionOrderItem } from '../types'; import type { DateRange, ProductAnalyticsItem, ProductionOrderItem } from '../types';
type VariantRow = ProductAnalyticsItem & { type VariantRow = ProductAnalyticsItem & {
@@ -121,13 +123,14 @@ const BreakdownPanel = ({
{visibleRows.map(row => { {visibleRows.map(row => {
const width = maxSold ? Math.max(4, (row.quantitySold / maxSold) * 100) : 0; const width = maxSold ? Math.max(4, (row.quantitySold / maxSold) * 100) : 0;
const barColor = type === 'color' ? getBarColor(row.label) : '#25c2ff'; const barColor = type === 'color' ? getBarColor(row.label) : '#25c2ff';
const displayLabel = type === 'color' ? formatColorLabel(row.label) : row.label;
return ( return (
<div key={row.label} className="grid grid-cols-[minmax(7.5rem,9rem)_1fr_6rem] items-center gap-3"> <div key={row.label} className="grid grid-cols-[minmax(7.5rem,9rem)_1fr_6rem] items-center gap-3">
<div className="flex min-w-0 items-center gap-2"> <div className="flex min-w-0 items-center gap-2">
{type === 'color' ? <ProductColorSwatch label={row.label} /> : <span className="h-2.5 w-2.5 shrink-0 rounded-sm bg-sky-400" />} {type === 'color' ? <ProductColorSwatch label={row.label} /> : <span className="h-2.5 w-2.5 shrink-0 rounded-sm bg-sky-400" />}
<span className="truncate text-xs font-bold text-dark-text" title={row.label}> <span className="truncate text-xs font-bold text-dark-text" title={displayLabel}>
{type === 'size' && row.label !== 'Sem tamanho' ? `Tam. ${row.label}` : row.label} {type === 'size' && row.label !== 'Sem tamanho' ? `Tam. ${row.label}` : displayLabel}
</span> </span>
</div> </div>
<div className="h-3 overflow-hidden rounded-full border border-dark-border bg-dark-input"> <div className="h-3 overflow-hidden rounded-full border border-dark-border bg-dark-input">

View File

@@ -7,6 +7,7 @@ import RefreshStatus from '../components/RefreshStatus';
import type { DateRange, ProductAnalyticsItem } from '../types'; import type { DateRange, ProductAnalyticsItem } from '../types';
import { exportToCSV, fetchProductAnalytics } from '../dataService'; import { exportToCSV, fetchProductAnalytics } from '../dataService';
import { encodeProductGroupKey, parseProductName, sortProductSizes } from '../productParsing'; import { encodeProductGroupKey, parseProductName, sortProductSizes } from '../productParsing';
import { formatColorLabel } from '../displayFormatters';
type StockRisk = 'rupture' | 'critical' | 'attention' | 'monitor' | 'healthy' | 'no_sales'; type StockRisk = 'rupture' | 'critical' | 'attention' | 'monitor' | 'healthy' | 'no_sales';
type StockStatusFilter = 'all' | StockRisk; type StockStatusFilter = 'all' | StockRisk;
@@ -657,7 +658,7 @@ const Products = () => {
<div className="truncate font-semibold text-zinc-900 dark:text-dark-text" title={product.name}>{product.name}</div> <div className="truncate font-semibold text-zinc-900 dark:text-dark-text" title={product.name}>{product.name}</div>
<div className="truncate text-[10px] text-zinc-400 dark:text-dark-muted font-medium"> <div className="truncate text-[10px] text-zinc-400 dark:text-dark-muted font-medium">
{viewMode === 'group' {viewMode === 'group'
? `Cor principal: ${product.topColor} · Tam. principal: ${product.topSize} · ${product.colors.length} cores · ${product.sizes.length} tamanhos` ? `Cor principal: ${formatColorLabel(product.topColor)} · Tam. principal: ${product.topSize} · ${product.colors.length} cores · ${product.sizes.length} tamanhos`
: `Preço Atual: ${formatCurrency(product.lastPrice)}`} : `Preço Atual: ${formatCurrency(product.lastPrice)}`}
</div> </div>
</td> </td>

700
src/pages/Registrations.tsx Normal file
View File

@@ -0,0 +1,700 @@
import { useEffect, useMemo, useState } from 'react';
import { Loader2, Package, RefreshCw, Ruler, Save, Tags, Trash2 } from 'lucide-react';
import {
deleteCatalogCategory,
deleteCatalogProduct,
deleteConsumptionReference,
fetchCatalogSummary,
saveCatalogCategory,
saveCatalogProduct,
saveConsumptionReference
} from '../dataService';
import type { CatalogCategory, CatalogProduct, CatalogProductType, CatalogSummary, ConsumptionReference } from '../types';
import { formatColorLabel } from '../displayFormatters';
type RegistrationTab = 'products' | 'categories' | 'references';
type SaveStatus = 'idle' | 'saving' | 'saved' | 'error';
const emptyCatalog: CatalogSummary = {
categories: [],
products: [],
consumptionReferences: []
};
const sizeOptions = ['2', '4', '6', '8', '10', '12', '14', '16', 'PP', 'P', 'M', 'G', 'GG', 'XG', 'G1', 'G2', 'G3', 'G4', 'G5'];
const defaultSizeAreas: Record<string, string> = {
P: '0.78',
M: '0.85',
G: '0.92',
GG: '1.00',
XG: '1.08',
G1: '1.08',
G2: '1.16',
G3: '1.24',
G4: '1.32',
G5: '1.40'
};
const rawMaterialSubcategories = [
{ value: 'fio', label: 'Fio' },
{ value: 'malha', label: 'Malha' },
{ value: 'ribana', label: 'Ribana' },
{ value: 'malha_fria', label: 'Malha Fria' },
{ value: 'meia_malha', label: 'Meia Malha 100% Algodao' },
{ value: 'moletom', label: 'Moletom' },
{ value: 'dry_fit', label: 'Dry Fit' },
{ value: 'piquet', label: 'Piquet / Polo' },
{ value: 'pima', label: 'Pima' },
{ value: 'poliamida', label: 'Poliamida' },
{ value: 'outra', label: 'Outra' }
];
const listPanelClassName = 'rounded-2xl border border-dark-border bg-dark-card shadow-sm';
const listHeaderClassName = 'flex min-h-[73px] flex-col gap-3 border-b border-dark-border p-4 md:flex-row md:items-center md:justify-between';
const formPanelClassName = 'rounded-2xl border border-dark-border bg-dark-card p-4 shadow-sm';
const emptyStateClassName = 'flex min-h-[280px] items-center justify-center px-4 py-10 text-center text-sm font-semibold text-dark-muted';
const labelClassName = 'text-xs font-bold text-dark-muted';
const inputClassName = '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';
const formatNumber = (value: number | null | undefined, maximumFractionDigits = 2) => (
value === null || value === undefined
? '-'
: new Intl.NumberFormat('pt-BR', { maximumFractionDigits }).format(value)
);
const numericMapFromStrings = (values: Record<string, string>) => (
Object.entries(values).reduce<Record<string, number>>((normalized, [key, value]) => {
const number = Number(value.replace(',', '.'));
if (Number.isFinite(number) && number > 0) normalized[key] = number;
return normalized;
}, {})
);
const getAverageYield = (reference: ConsumptionReference) => {
const yields = Object.values(reference.sizeYields || {});
if (yields.length) return yields.reduce((total, value) => total + value, 0) / yields.length;
return reference.generalYield;
};
const Registrations = () => {
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 [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 [referenceForm, setReferenceForm] = useState({
productId: '',
materialProductId: '',
color: '',
generalYield: '',
gramature: '',
efficiencyPercent: '85',
ribGPerPiece: '',
materialCostPerKg: ''
});
const [sizeAreas, setSizeAreas] = useState<Record<string, string>>(defaultSizeAreas);
const [sizeYields, setSizeYields] = useState<Record<string, string>>({});
const loadCatalog = async () => {
setIsLoading(true);
const summary = await fetchCatalogSummary();
setCatalog(summary);
setIsLoading(false);
};
useEffect(() => {
let isMounted = true;
fetchCatalogSummary().then(summary => {
if (!isMounted) return;
setCatalog(summary);
setIsLoading(false);
}).catch(error => {
console.error('Initial catalog load failed', error);
if (!isMounted) return;
setIsLoading(false);
});
return () => {
isMounted = false;
};
}, []);
const finishedProducts = useMemo(
() => catalog.products.filter(product => product.type === 'finished_product'),
[catalog.products]
);
const rawMaterials = useMemo(
() => catalog.products.filter(product => product.type === 'raw_material'),
[catalog.products]
);
const selectedReferenceProduct = useMemo(
() => catalog.products.find(product => String(product.id) === referenceForm.productId),
[catalog.products, referenceForm.productId]
);
const referenceSizes = selectedReferenceProduct?.sizes?.length ? selectedReferenceProduct.sizes : ['P', 'M', 'G', 'GG'];
const filteredProducts = useMemo(() => {
if (productFilter === 'all') return catalog.products;
return catalog.products.filter(product => product.type === productFilter);
}, [catalog.products, productFilter]);
const runAction = async (action: () => Promise<void>, successMessage: string) => {
setStatus('saving');
setFeedback('');
try {
await action();
await loadCatalog();
setStatus('saved');
setFeedback(successMessage);
} catch (error) {
setStatus('error');
setFeedback(error instanceof Error ? error.message : 'Nao foi possivel salvar.');
}
};
const saveCategory = () => runAction(async () => {
await saveCatalogCategory(categoryForm);
setCategoryForm({ name: '', description: '' });
}, 'Categoria salva.');
const saveProduct = () => runAction(async () => {
await saveCatalogProduct({
...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.');
const calculateSizeYields = () => {
const gramature = Number(referenceForm.gramature.replace(',', '.'));
const efficiency = Number(referenceForm.efficiencyPercent.replace(',', '.')) || 85;
const rib = Number(referenceForm.ribGPerPiece.replace(',', '.')) || 0;
if (!Number.isFinite(gramature) || gramature <= 0) {
setStatus('error');
setFeedback('Informe a gramatura para calcular o rendimento.');
return;
}
const nextYields = referenceSizes.reduce<Record<string, string>>((values, size) => {
const area = Number((sizeAreas[size] || '').replace(',', '.'));
if (!Number.isFinite(area) || area <= 0) return values;
const fabricGrams = gramature * area / (efficiency / 100);
const pieceGrams = fabricGrams + rib;
const yieldValue = pieceGrams > 0 ? 1000 / pieceGrams : 0;
values[size] = yieldValue.toFixed(2);
return values;
}, {});
setSizeYields(nextYields);
setStatus('idle');
setFeedback(`${Object.keys(nextYields).length} tamanhos calculados.`);
};
const saveReference = () => runAction(async () => {
await saveConsumptionReference({
...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: ''
}));
setSizeYields({});
}, 'Referencia salva.');
const removeCategory = (category: CatalogCategory) => runAction(
() => deleteCatalogCategory(category.id),
`Categoria ${category.name} excluida.`
);
const removeProduct = (product: CatalogProduct) => runAction(
() => deleteCatalogProduct(product.id),
`Produto ${product.sku} excluido.`
);
const removeReference = (reference: ConsumptionReference) => runAction(
() => deleteConsumptionReference(reference.id),
`Referencia ${reference.productSku} excluida.`
);
const toggleProductSize = (size: string) => {
setProductForm(current => ({
...current,
sizes: current.sizes.includes(size)
? current.sizes.filter(item => item !== size)
: [...current.sizes, size]
}));
};
const tabItems: Array<{ key: RegistrationTab; label: string; icon: typeof Package; count: number }> = [
{ key: 'products', label: 'Produtos', icon: Package, count: catalog.products.length },
{ key: 'categories', label: 'Categorias', icon: Tags, count: catalog.categories.length },
{ key: 'references', label: 'Referencia de Consumo', icon: Ruler, count: catalog.consumptionReferences.length }
];
return (
<div className="space-y-6">
<div className="flex flex-col gap-4 2xl:flex-row 2xl:items-start 2xl:justify-between">
<div>
<h1 className="mb-2 text-2xl font-bold text-zinc-900 dark:text-dark-text">Cadastros</h1>
<p className="font-medium text-zinc-500 dark:text-dark-muted">
Base de produtos, categorias e referencias de consumo usadas pelo corte.
</p>
</div>
<button
type="button"
onClick={() => void loadCatalog()}
className="inline-flex items-center justify-center gap-2 rounded-xl border border-dark-border bg-dark-card px-4 py-2.5 text-sm font-bold text-dark-text transition-colors hover:border-brand-primary cursor-pointer"
>
<RefreshCw className="h-4 w-4 text-brand-primary" />
Atualizar
</button>
</div>
<div className="grid grid-cols-1 gap-3 md:grid-cols-3">
{tabItems.map(item => {
const Icon = item.icon;
const isActive = activeTab === item.key;
return (
<button
key={item.key}
type="button"
onClick={() => setActiveTab(item.key)}
className={`flex items-center justify-between rounded-2xl border p-4 text-left transition-colors cursor-pointer ${isActive ? 'border-brand-primary/40 bg-brand-primary/10 text-brand-primary' : 'border-dark-border bg-dark-card text-dark-text hover:border-brand-primary/40'}`}
>
<span className="flex items-center gap-3">
<span className="rounded-xl border border-dark-border bg-dark-input p-2">
<Icon className="h-5 w-5" />
</span>
<span className="font-bold">{item.label}</span>
</span>
<span className="rounded-full border border-dark-border bg-dark-input px-2.5 py-1 text-xs font-bold text-dark-muted">
{item.count}
</span>
</button>
);
})}
</div>
{feedback && (
<div className={`rounded-2xl border px-4 py-3 text-sm font-bold ${status === 'error' ? 'border-red-400/30 bg-red-400/10 text-red-300' : 'border-emerald-400/30 bg-emerald-400/10 text-emerald-300'}`}>
{feedback}
</div>
)}
{isLoading ? (
<div className="flex h-48 items-center justify-center rounded-2xl border border-dark-border bg-dark-card text-brand-primary">
<Loader2 className="h-7 w-7 animate-spin" />
</div>
) : (
<>
{activeTab === 'products' && (
<div className="grid grid-cols-1 gap-6 xl:grid-cols-[1fr_420px]">
<div className={listPanelClassName}>
<div className={listHeaderClassName}>
<div>
<h2 className="text-sm font-bold text-dark-text">Produtos cadastrados</h2>
<p className="mt-1 text-xs font-semibold text-dark-muted">Produtos acabados e materias-primas do corte.</p>
</div>
<select
value={productFilter}
onChange={(event) => setProductFilter(event.target.value as 'all' | CatalogProductType)}
className="h-10 rounded-xl 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="all">Todos</option>
<option value="finished_product">Produtos acabados</option>
<option value="raw_material">Materias-primas</option>
</select>
</div>
<div className="divide-y divide-dark-border">
{filteredProducts.map(product => (
<div key={product.id} className="flex flex-col gap-3 p-4 md:flex-row md:items-center md:justify-between">
<div className="min-w-0">
<div className="flex flex-wrap items-center gap-2">
<span className="font-mono text-xs font-bold text-brand-primary">{product.sku}</span>
<span className="rounded-full border border-dark-border bg-dark-input px-2 py-0.5 text-[10px] font-bold text-dark-muted">
{product.type === 'finished_product' ? 'Produto acabado' : 'Materia-prima'}
</span>
</div>
<h3 className="mt-1 truncate text-sm font-bold text-dark-text">{product.name}</h3>
<p className="mt-1 text-xs font-semibold text-dark-muted">
{[product.categoryName, product.composition, product.color ? formatColorLabel(product.color) : '']
.filter(Boolean)
.join(' · ') || 'Sem detalhes'}
</p>
</div>
<button
type="button"
onClick={() => void removeProduct(product)}
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"
title="Excluir produto"
>
<Trash2 className="h-4 w-4" />
</button>
</div>
))}
{!filteredProducts.length && (
<div className={emptyStateClassName}>Nenhum produto cadastrado.</div>
)}
</div>
</div>
<div className={formPanelClassName}>
<h2 className="text-sm font-bold text-dark-text">Cadastrar produto / materia-prima</h2>
<div className="mt-4 space-y-3">
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
<label className={labelClassName}>
Tipo
<select
value={productForm.type}
onChange={(event) => setProductForm(current => ({ ...current, type: event.target.value as CatalogProductType }))}
className={inputClassName}
>
<option value="finished_product">Produto acabado</option>
<option value="raw_material">Materia-prima</option>
</select>
</label>
<label className={labelClassName}>
SKU
<input
value={productForm.sku}
onChange={(event) => setProductForm(current => ({ ...current, sku: event.target.value }))}
placeholder="ex: BLCS"
className={`${inputClassName} font-mono`}
/>
</label>
</div>
<label className={`block ${labelClassName}`}>
Nome
<input
value={productForm.name}
onChange={(event) => setProductForm(current => ({ ...current, name: event.target.value }))}
placeholder="Nome do produto"
className={inputClassName}
/>
</label>
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
<label className={labelClassName}>
Categoria
<select
value={productForm.categoryId}
onChange={(event) => setProductForm(current => ({ ...current, categoryId: event.target.value }))}
className={inputClassName}
>
<option value="">Sem categoria</option>
{catalog.categories.map(category => <option key={category.id} value={category.id}>{category.name}</option>)}
</select>
</label>
<label className={labelClassName}>
Composicao
<input
value={productForm.composition}
onChange={(event) => setProductForm(current => ({ ...current, composition: event.target.value }))}
placeholder="ex: 100% Algodao"
className={inputClassName}
/>
</label>
</div>
{productForm.type === 'raw_material' ? (
<>
<div className="grid grid-cols-1 gap-3 sm:grid-cols-3">
<label className={labelClassName}>
Gramatura
<input inputMode="decimal" value={productForm.gramature} onChange={(event) => setProductForm(current => ({ ...current, gramature: event.target.value }))} className={inputClassName} />
</label>
<label className={labelClassName}>
Rendimento m/kg
<input inputMode="decimal" value={productForm.materialYield} onChange={(event) => setProductForm(current => ({ ...current, materialYield: event.target.value }))} className={inputClassName} />
</label>
<label className={labelClassName}>
Largura cm
<input inputMode="decimal" value={productForm.widthCm} onChange={(event) => setProductForm(current => ({ ...current, widthCm: event.target.value }))} className={inputClassName} />
</label>
</div>
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
<label className={labelClassName}>
Cor
<input value={productForm.color} onChange={(event) => setProductForm(current => ({ ...current, color: event.target.value }))} placeholder="ex: Preto" className={inputClassName} />
</label>
<label className={labelClassName}>
Subcategoria
<select value={productForm.subcategory} onChange={(event) => setProductForm(current => ({ ...current, subcategory: event.target.value }))} className={inputClassName}>
{rawMaterialSubcategories.map(option => <option key={option.value} value={option.value}>{option.label}</option>)}
</select>
</label>
</div>
</>
) : (
<div>
<p className="mb-2 text-xs font-bold text-dark-muted">Tamanhos disponiveis</p>
<div className="flex flex-wrap gap-2">
{sizeOptions.map(size => (
<button
key={size}
type="button"
onClick={() => toggleProductSize(size)}
className={`rounded-full border px-3 py-1.5 text-xs font-bold transition-colors cursor-pointer ${productForm.sizes.includes(size) ? 'border-brand-primary bg-brand-primary/15 text-brand-primary' : 'border-dark-border bg-dark-input text-dark-muted hover:text-dark-text'}`}
>
{size}
</button>
))}
</div>
</div>
)}
<label className={`block ${labelClassName}`}>
Observacoes
<input
value={productForm.notes}
onChange={(event) => setProductForm(current => ({ ...current, notes: event.target.value }))}
placeholder="Opcional"
className={inputClassName}
/>
</label>
<button
type="button"
onClick={() => void saveProduct()}
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" />
Salvar produto
</button>
</div>
</div>
</div>
)}
{activeTab === 'categories' && (
<div className="grid grid-cols-1 gap-6 xl:grid-cols-[1fr_420px]">
<div className={listPanelClassName}>
<div className={listHeaderClassName}>
<div>
<h2 className="text-sm font-bold text-dark-text">Categorias cadastradas</h2>
<p className="mt-1 text-xs font-semibold text-dark-muted">Classificacao usada no cadastro de produtos.</p>
</div>
</div>
<div className="divide-y divide-dark-border">
{catalog.categories.map(category => (
<div key={category.id} className="flex items-center justify-between gap-3 p-4">
<div>
<h3 className="text-sm font-bold text-dark-text">{category.name}</h3>
<p className="mt-1 text-xs font-semibold text-dark-muted">{category.description || 'Sem descricao'}</p>
</div>
<button type="button" onClick={() => void removeCategory(category)} 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" />
</button>
</div>
))}
{!catalog.categories.length && <div className={emptyStateClassName}>Nenhuma categoria cadastrada.</div>}
</div>
</div>
<div className={formPanelClassName}>
<h2 className="text-sm font-bold text-dark-text">Nova categoria</h2>
<div className="mt-4 space-y-3">
<label className={`block ${labelClassName}`}>
Nome
<input value={categoryForm.name} onChange={(event) => setCategoryForm(current => ({ ...current, name: event.target.value }))} placeholder="ex: Camiseta Regular" className={inputClassName} />
</label>
<label className={`block ${labelClassName}`}>
Descricao
<input value={categoryForm.description} onChange={(event) => setCategoryForm(current => ({ ...current, description: event.target.value }))} placeholder="Opcional" className={inputClassName} />
</label>
<button type="button" onClick={() => void saveCategory()} 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" />
Salvar categoria
</button>
</div>
</div>
</div>
)}
{activeTab === 'references' && (
<div className="grid grid-cols-1 gap-6 xl:grid-cols-[1fr_420px]">
<div className={listPanelClassName}>
<div className={listHeaderClassName}>
<div>
<h2 className="text-sm font-bold text-dark-text">Referencias cadastradas</h2>
<p className="mt-1 text-xs font-semibold text-dark-muted">Rendimento /kg por produto, malha, cor e tamanho.</p>
</div>
<span className="rounded-full border border-dark-border bg-dark-input px-3 py-1 text-xs font-bold text-dark-muted">
{catalog.consumptionReferences.length} referencias
</span>
</div>
<div className="divide-y divide-dark-border">
{catalog.consumptionReferences.map(reference => (
<div key={reference.id} className="flex flex-col gap-3 p-4 md:flex-row md:items-start md:justify-between">
<div className="min-w-0">
<div className="flex flex-wrap items-center gap-2">
<span className="font-mono text-xs font-bold text-brand-primary">{reference.productSku}</span>
<span className="rounded-full border border-dark-border bg-dark-input px-2 py-0.5 text-[10px] font-bold text-dark-muted">
{Object.keys(reference.sizeYields || {}).length ? 'Por tamanho' : 'Geral'}
</span>
</div>
<h3 className="mt-1 truncate text-sm font-bold text-dark-text">{reference.productName}</h3>
<p className="mt-1 text-xs font-semibold text-dark-muted">
{[reference.materialName, reference.color ? formatColorLabel(reference.color) : 'todas as cores'].filter(Boolean).join(' · ')}
</p>
{!!Object.keys(reference.sizeYields || {}).length && (
<div className="mt-2 flex flex-wrap gap-1.5">
{Object.entries(reference.sizeYields).map(([size, value]) => (
<span key={size} className="rounded-full border border-dark-border bg-dark-input px-2 py-0.5 text-[10px] font-bold text-dark-muted">
{size}: {formatNumber(value)}
</span>
))}
</div>
)}
</div>
<div className="flex shrink-0 items-start gap-3">
<div className="text-right">
<div className="text-lg font-bold text-emerald-300">{formatNumber(getAverageYield(reference), 3)} /kg</div>
<div className="text-[10px] font-bold uppercase tracking-widest text-dark-muted">
{Object.keys(reference.sizeYields || {}).length ? 'media' : 'geral'}
</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">
<Trash2 className="h-4 w-4" />
</button>
</div>
</div>
))}
{!catalog.consumptionReferences.length && (
<div className={emptyStateClassName}>
Nenhuma referencia cadastrada.
</div>
)}
</div>
</div>
<div className={formPanelClassName}>
<div>
<h2 className="text-sm font-bold text-dark-text">Nova referencia de consumo</h2>
<p className="mt-1 text-xs font-semibold text-dark-muted">
Defina /kg por produto, malha e cor para o calculo do corte.
</p>
</div>
<div className="mt-4 space-y-4">
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
<label className={labelClassName}>
Produto
<select value={referenceForm.productId} onChange={(event) => setReferenceForm(current => ({ ...current, productId: event.target.value }))} className={inputClassName}>
<option value="">Selecione...</option>
{finishedProducts.map(product => <option key={product.id} value={product.id}>{product.name} ({product.sku})</option>)}
</select>
</label>
<label className={labelClassName}>
Malha
<select value={referenceForm.materialProductId} onChange={(event) => setReferenceForm(current => ({ ...current, materialProductId: event.target.value }))} className={inputClassName}>
<option value="">Qualquer</option>
{rawMaterials.map(product => <option key={product.id} value={product.id}>{product.name}</option>)}
</select>
</label>
</div>
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
<label className={labelClassName}>
Cor
<input value={referenceForm.color} onChange={(event) => setReferenceForm(current => ({ ...current, color: event.target.value }))} placeholder="Todas as cores" className={inputClassName} />
</label>
<label className={labelClassName}>
Rendimento geral
<input inputMode="decimal" value={referenceForm.generalYield} onChange={(event) => setReferenceForm(current => ({ ...current, generalYield: event.target.value }))} placeholder="ex: 5,36" className={inputClassName} />
</label>
</div>
<div className="rounded-xl border border-dark-border bg-dark-input/45 p-3">
<div className="mb-3 flex flex-col gap-1 sm:flex-row sm:items-center sm:justify-between">
<div>
<h3 className="text-xs font-bold uppercase tracking-widest text-dark-text">Calculo por gramatura</h3>
<p className="mt-1 text-[11px] font-semibold text-dark-muted">Opcional. Preenche o /kg dos tamanhos abaixo.</p>
</div>
<button type="button" onClick={calculateSizeYields} className="inline-flex h-9 items-center justify-center gap-2 rounded-lg border border-dark-border bg-dark-card px-3 text-xs font-bold text-dark-text transition-colors hover:border-brand-primary cursor-pointer">
<Ruler className="h-3.5 w-3.5 text-brand-primary" />
Calcular
</button>
</div>
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
<label className={labelClassName}>Gramatura<input inputMode="decimal" value={referenceForm.gramature} onChange={(event) => setReferenceForm(current => ({ ...current, gramature: event.target.value }))} placeholder="ex: 180" className={inputClassName} /></label>
<label className={labelClassName}>Aproveitamento %<input inputMode="decimal" value={referenceForm.efficiencyPercent} onChange={(event) => setReferenceForm(current => ({ ...current, efficiencyPercent: event.target.value }))} className={inputClassName} /></label>
<label className={labelClassName}>Ribana g/peça<input inputMode="decimal" value={referenceForm.ribGPerPiece} onChange={(event) => setReferenceForm(current => ({ ...current, ribGPerPiece: event.target.value }))} placeholder="ex: 18" className={inputClassName} /></label>
<label className={labelClassName}>Custo R$/kg<input inputMode="decimal" value={referenceForm.materialCostPerKg} onChange={(event) => setReferenceForm(current => ({ ...current, materialCostPerKg: event.target.value }))} placeholder="opcional" className={inputClassName} /></label>
</div>
</div>
<div>
<div className="mb-2 flex items-center justify-between gap-3">
<p className="text-xs font-bold text-dark-muted">Rendimento por tamanho</p>
<span className="text-[11px] font-semibold text-dark-muted">area m² / /kg</span>
</div>
<div className="overflow-hidden rounded-xl border border-dark-border">
<div className="grid grid-cols-[56px_1fr_1fr] gap-2 border-b border-dark-border bg-dark-input px-3 py-2 text-[10px] font-bold uppercase tracking-widest text-dark-muted">
<span>Tam.</span>
<span>Area</span>
<span>Rendimento</span>
</div>
{referenceSizes.map(size => (
<div key={size} className="grid grid-cols-[56px_1fr_1fr] items-center gap-2 border-b border-dark-border px-3 py-2 last:border-b-0">
<div className="text-xs font-bold text-dark-text">{size}</div>
<input value={sizeAreas[size] || ''} onChange={(event) => setSizeAreas(current => ({ ...current, [size]: event.target.value }))} placeholder="area" className="h-8 w-full rounded-lg border border-dark-border bg-dark-input px-2 text-xs font-bold text-dark-text outline-none focus:border-brand-primary" />
<input value={sizeYields[size] || ''} onChange={(event) => setSizeYields(current => ({ ...current, [size]: event.target.value }))} placeholder="pç/kg" className="h-8 w-full rounded-lg border border-dark-border bg-dark-input px-2 text-xs font-bold text-dark-text outline-none focus:border-brand-primary" />
</div>
))}
</div>
</div>
<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" />
Salvar referencia
</button>
</div>
</div>
</div>
)}
</>
)}
</div>
);
};
export default Registrations;

View File

@@ -8,6 +8,7 @@ import { buildOpenProductionByProductId } from '../analytics/cutting';
import type { DateRange, ProductAnalyticsItem, ProductionOrderItem } from '../types'; import type { DateRange, ProductAnalyticsItem, ProductionOrderItem } from '../types';
import { exportToCSV, fetchProductAnalytics, fetchProductionOrders } from '../dataService'; import { exportToCSV, fetchProductAnalytics, fetchProductionOrders } from '../dataService';
import { encodeProductGroupKey, parseProductName, sortProductSizes } from '../productParsing'; import { encodeProductGroupKey, parseProductName, sortProductSizes } from '../productParsing';
import { formatColorLabel } from '../displayFormatters';
type ReplenishmentStatus = 'need' | 'covered' | 'no_sales' | 'no_stock'; type ReplenishmentStatus = 'need' | 'covered' | 'no_sales' | 'no_stock';
type ReplenishmentFilter = 'all' | ReplenishmentStatus; type ReplenishmentFilter = 'all' | ReplenishmentStatus;
@@ -237,7 +238,7 @@ const Replenishment = () => {
: 'covered'; : 'covered';
const sizes = sortProductSizes(Array.from(new Set(group.flatMap(row => row.sizes)))); const sizes = sortProductSizes(Array.from(new Set(group.flatMap(row => row.sizes))));
const productIds = group.map(row => row.id); const productIds = group.map(row => row.id);
const name = first.color ? `${first.baseName} · ${first.color}` : first.baseName; const name = first.color ? `${first.baseName} · ${formatColorLabel(first.color)}` : first.baseName;
return { return {
...first, ...first,

26
src/productColors.ts Normal file
View File

@@ -0,0 +1,26 @@
const COLOR_SWATCHES: Array<{ pattern: string; color: string }> = [
{ pattern: 'preto', color: '#171717' },
{ pattern: 'branco', color: '#f8fafc' },
{ pattern: 'bege', color: '#d7bf9a' },
{ pattern: 'cafe', color: '#79553d' },
{ pattern: 'perola', color: '#e7dfcf' },
{ pattern: 'marinho', color: '#172554' },
{ pattern: 'bordo', color: '#6b1226' },
{ pattern: 'verde', color: '#166534' },
{ pattern: 'rosa', color: '#f0a6bf' },
{ pattern: 'cinza', color: '#8f8f8f' },
{ pattern: 'vermelho', color: '#b91c1c' },
{ pattern: 'grafite', color: '#3f3f46' },
{ pattern: 'azul', color: '#2563eb' },
{ pattern: 'marron', color: '#6b4f3b' },
{ pattern: 'marrom', color: '#6b4f3b' }
];
const normalizeColorLabel = (label: string) => (
label.normalize('NFD').replace(/\p{Diacritic}/gu, '').toLowerCase()
);
export const getProductColor = (label: string) => {
const normalizedLabel = normalizeColorLabel(label);
return COLOR_SWATCHES.find(item => normalizedLabel.includes(item.pattern))?.color || '#64748b';
};

View File

@@ -78,6 +78,94 @@ export interface CuttingSettings {
productOverrides: Record<string, CutProductOverride>; productOverrides: Record<string, CutProductOverride>;
} }
export type CatalogProductType = 'finished_product' | 'raw_material';
export interface CatalogCategory {
id: number;
name: string;
description: string;
createdAt: string;
updatedAt: string;
}
export interface CatalogProduct {
id: number;
type: CatalogProductType;
sku: string;
name: string;
categoryId: number | null;
categoryName: string;
composition: string;
notes: string;
gramature: number | null;
materialYield: number | null;
widthCm: number | null;
color: string;
subcategory: string;
sizes: string[];
createdAt: string;
updatedAt: string;
}
export interface ConsumptionReference {
id: number;
productId: number;
productSku: string;
productName: string;
materialProductId: number | null;
materialSku: string;
materialName: string;
color: string;
generalYield: number | null;
sizeYields: Record<string, number>;
sizeAreas: Record<string, number>;
gramature: number | null;
efficiencyPercent: number | null;
ribGPerPiece: number | null;
materialCostPerKg: number | null;
createdAt: string;
updatedAt: string;
}
export interface CatalogSummary {
categories: CatalogCategory[];
products: CatalogProduct[];
consumptionReferences: ConsumptionReference[];
}
export type CatalogCategoryPayload = {
name: string;
description?: string;
};
export type CatalogProductPayload = {
type: CatalogProductType;
sku: string;
name: string;
categoryId?: number | null;
composition?: string;
notes?: string;
gramature?: number | string | null;
materialYield?: number | string | null;
widthCm?: number | string | null;
color?: string;
subcategory?: string;
sizes?: string[];
};
export type ConsumptionReferencePayload = {
productId: number | string;
materialProductId?: number | string | null;
color?: string;
generalYield?: number | string | null;
sizeYields?: Record<string, number | string>;
sizeAreas?: Record<string, number | string>;
gramature?: number | string | null;
efficiencyPercent?: number | string | null;
ribGPerPiece?: number | string | null;
materialCostPerKg?: number | string | null;
};
export interface DateRange { export interface DateRange {
start: Date; start: Date;
end: Date; end: Date;