+
{section.label}
)}
@@ -117,17 +130,17 @@ const Layout = () => {
-
- {!isSidebarCollapsed &&
{item.name}}
+
+ {!isSidebarCollapsed &&
{item.name}}
);
})}
diff --git a/src/dataService.ts b/src/dataService.ts
index 176f4ae..218d3df 100644
--- a/src/dataService.ts
+++ b/src/dataService.ts
@@ -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';
const API_URL = import.meta.env.VITE_API_URL || '/api';
@@ -209,6 +209,86 @@ export const saveCuttingSettings = async (settings: CuttingSettings): Promise
=> {
+ 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 => {
+ 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 => {
+ 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 => {
+ 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 => {
+ 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 => {
+ 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 => {
+ 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 => {
const path = `/analytics/dashboard?${buildDateRangeParams(dateRange).toString()}`;
return getCachedAnalytics(path, async () => {
diff --git a/src/index.css b/src/index.css
index c13f6c0..a7df43f 100644
--- a/src/index.css
+++ b/src/index.css
@@ -164,6 +164,7 @@
color: #9a6700 !important;
}
+ html[data-theme='offwhite'] .text-sky-200,
html[data-theme='offwhite'] .text-sky-300,
html[data-theme='offwhite'] .text-sky-400,
html[data-theme='offwhite'] .text-sky-500 {
@@ -231,6 +232,7 @@
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\/30,
html[data-theme='offwhite'] .border-sky-400\/35 {
diff --git a/src/pages/Registrations.tsx b/src/pages/Registrations.tsx
new file mode 100644
index 0000000..87b88ab
--- /dev/null
+++ b/src/pages/Registrations.tsx
@@ -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 = {
+ 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) => (
+ Object.entries(values).reduce>((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(emptyCatalog);
+ const [isLoading, setIsLoading] = useState(true);
+ const [activeTab, setActiveTab] = useState('products');
+ const [productFilter, setProductFilter] = useState<'all' | CatalogProductType>('all');
+ const [status, setStatus] = useState('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>(defaultSizeAreas);
+ const [sizeYields, setSizeYields] = useState>({});
+
+ 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, 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>((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 (
+
+
+
+
Cadastros
+
+ Base de produtos, categorias e referencias de consumo usadas pelo corte.
+
+
+
+
+
+
+ {tabItems.map(item => {
+ const Icon = item.icon;
+ const isActive = activeTab === item.key;
+ return (
+
+ );
+ })}
+
+
+ {feedback && (
+
+ {feedback}
+
+ )}
+
+ {isLoading ? (
+
+
+
+ ) : (
+ <>
+ {activeTab === 'products' && (
+
+
+
+
+
Produtos cadastrados
+
Produtos acabados e materias-primas do corte.
+
+
+
+
+ {filteredProducts.map(product => (
+
+
+
+ {product.sku}
+
+ {product.type === 'finished_product' ? 'Produto acabado' : 'Materia-prima'}
+
+
+
{product.name}
+
+ {[product.categoryName, product.composition, product.color ? formatColorLabel(product.color) : '']
+ .filter(Boolean)
+ .join(' · ') || 'Sem detalhes'}
+
+
+
+
+ ))}
+ {!filteredProducts.length && (
+
Nenhum produto cadastrado.
+ )}
+
+
+
+
+
Cadastrar produto / materia-prima
+
+
+
+
+
+
+
+
+
+
+
+ {productForm.type === 'raw_material' ? (
+ <>
+
+
+
+
+
+
+
+
+
+ >
+ ) : (
+
+
Tamanhos disponiveis
+
+ {sizeOptions.map(size => (
+
+ ))}
+
+
+ )}
+
+
+
+
+
+
+ )}
+
+ {activeTab === 'categories' && (
+
+
+
+
+
Categorias cadastradas
+
Classificacao usada no cadastro de produtos.
+
+
+
+ {catalog.categories.map(category => (
+
+
+
{category.name}
+
{category.description || 'Sem descricao'}
+
+
+
+ ))}
+ {!catalog.categories.length &&
Nenhuma categoria cadastrada.
}
+
+
+
+
+
+ )}
+
+ {activeTab === 'references' && (
+
+
+
+
+
Referencias cadastradas
+
Rendimento pç/kg por produto, malha, cor e tamanho.
+
+
+ {catalog.consumptionReferences.length} referencias
+
+
+
+ {catalog.consumptionReferences.map(reference => (
+
+
+
+ {reference.productSku}
+
+ {Object.keys(reference.sizeYields || {}).length ? 'Por tamanho' : 'Geral'}
+
+
+
{reference.productName}
+
+ {[reference.materialName, reference.color ? formatColorLabel(reference.color) : 'todas as cores'].filter(Boolean).join(' · ')}
+
+ {!!Object.keys(reference.sizeYields || {}).length && (
+
+ {Object.entries(reference.sizeYields).map(([size, value]) => (
+
+ {size}: {formatNumber(value)}
+
+ ))}
+
+ )}
+
+
+
+
{formatNumber(getAverageYield(reference), 3)} pç/kg
+
+ {Object.keys(reference.sizeYields || {}).length ? 'media' : 'geral'}
+
+
+
+
+
+ ))}
+ {!catalog.consumptionReferences.length && (
+
+ Nenhuma referencia cadastrada.
+
+ )}
+
+
+
+
+
+
Nova referencia de consumo
+
+ Defina pç/kg por produto, malha e cor para o calculo do corte.
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
Rendimento por tamanho
+
area m² / pç/kg
+
+
+
+ Tam.
+ Area
+ Rendimento
+
+ {referenceSizes.map(size => (
+
+ ))}
+
+
+
+
+
+
+
+
+ )}
+ >
+ )}
+
+
+ );
+};
+
+export default Registrations;
diff --git a/src/types.ts b/src/types.ts
index 5804723..a2737c6 100644
--- a/src/types.ts
+++ b/src/types.ts
@@ -78,6 +78,94 @@ export interface CuttingSettings {
productOverrides: Record;
}
+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;
+ sizeAreas: Record;
+ 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;
+ sizeAreas?: Record;
+ gramature?: number | string | null;
+ efficiencyPercent?: number | string | null;
+ ribGPerPiece?: number | string | null;
+ materialCostPerKg?: number | string | null;
+};
+
export interface DateRange {
start: Date;
end: Date;