diff --git a/backend/db.js b/backend/db.js index d77bf03..342f97a 100644 --- a/backend/db.js +++ b/backend/db.js @@ -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(` ALTER TABLE production_orders 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; `).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(` CREATE TABLE IF NOT EXISTS app_users ( 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_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_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_normalized_cliente_nome diff --git a/backend/routes/catalogRoutes.js b/backend/routes/catalogRoutes.js new file mode 100644 index 0000000..d1195a9 --- /dev/null +++ b/backend/routes/catalogRoutes.js @@ -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; diff --git a/backend/server.js b/backend/server.js index bec0174..e6ab9f1 100644 --- a/backend/server.js +++ b/backend/server.js @@ -10,6 +10,7 @@ const analyticsRoutes = require('./routes/analyticsRoutes'); const userRoutes = require('./routes/userRoutes'); const productionOrderRoutes = require('./routes/productionOrderRoutes'); const cuttingSettingsRoutes = require('./routes/cuttingSettingsRoutes'); +const catalogRoutes = require('./routes/catalogRoutes'); const createApp = () => { const app = express(); @@ -23,6 +24,7 @@ const createApp = () => { app.use('/api', campaignRoutes); app.use('/api', productionOrderRoutes); app.use('/api', cuttingSettingsRoutes); + app.use('/api', catalogRoutes); app.use('/api', analyticsRoutes); app.use('/api', userRoutes); app.use('/api/internal', internalRoutes); diff --git a/backend/services/catalogService.js b/backend/services/catalogService.js new file mode 100644 index 0000000..13eda16 --- /dev/null +++ b/backend/services/catalogService.js @@ -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 +}; diff --git a/src/App.tsx b/src/App.tsx index ff81bee..f5e4eec 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -15,6 +15,7 @@ const Clients = React.lazy(() => import('./pages/Clients')); const ClientDetails = React.lazy(() => import('./pages/ClientDetails')); const Campaigns = React.lazy(() => import('./pages/Campaigns')); const Rfm = React.lazy(() => import('./pages/Rfm')); +const Registrations = React.lazy(() => import('./pages/Registrations')); const Login = React.lazy(() => import('./pages/Login')); const AdminUsers = React.lazy(() => import('./pages/AdminUsers')); @@ -59,6 +60,7 @@ function App() { } /> } /> } /> + } /> } /> diff --git a/src/components/Layout.tsx b/src/components/Layout.tsx index 58497c7..16dff66 100644 --- a/src/components/Layout.tsx +++ b/src/components/Layout.tsx @@ -1,6 +1,6 @@ import { useState, useEffect } from 'react'; 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 { isSuperAdmin, logout } from '../dataService'; import { rangeForLastDays } from '../dateRanges'; @@ -59,21 +59,34 @@ const Layout = () => { 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() ? [{ name: 'Usuários', href: '/admin/users', icon: Shield }] : []; 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 }] : []), ]; @@ -103,11 +116,11 @@ const Layout = () => { )} -