Add catalog registrations workflow
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 1m12s
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 1m12s
This commit is contained in:
@@ -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() {
|
||||
<Route path="clients/:clientToken" element={<ClientDetails />} />
|
||||
<Route path="rfm" element={<Rfm />} />
|
||||
<Route path="campaigns" element={<Campaigns />} />
|
||||
<Route path="registrations" element={<Registrations />} />
|
||||
<Route path="admin/users" element={<SuperAdminRoute><AdminUsers /></SuperAdminRoute>} />
|
||||
</Route>
|
||||
</Routes>
|
||||
|
||||
@@ -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 = () => {
|
||||
</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) => (
|
||||
<div key={section.label} className="space-y-2">
|
||||
<div key={section.label} className="space-y-1.5">
|
||||
{!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}
|
||||
</div>
|
||||
)}
|
||||
@@ -117,17 +130,17 @@ const Layout = () => {
|
||||
<Link
|
||||
key={item.name}
|
||||
to={item.href}
|
||||
className={`flex items-center rounded-xl px-4 py-3 transition-all ${
|
||||
isSidebarCollapsed ? 'justify-center' : 'space-x-3'
|
||||
className={`group flex min-h-11 items-center rounded-lg px-3 py-2.5 text-sm transition-all ${
|
||||
isSidebarCollapsed ? 'justify-center' : 'gap-3'
|
||||
} ${
|
||||
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'
|
||||
}`}
|
||||
title={isSidebarCollapsed ? item.name : undefined}
|
||||
>
|
||||
<item.icon className="h-5 w-5 shrink-0" />
|
||||
{!isSidebarCollapsed && <span className="font-medium">{item.name}</span>}
|
||||
<item.icon className="h-4.5 w-4.5 shrink-0" />
|
||||
{!isSidebarCollapsed && <span className="truncate font-medium">{item.name}</span>}
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
|
||||
@@ -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<Cu
|
||||
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> => {
|
||||
const path = `/analytics/dashboard?${buildDateRangeParams(dateRange).toString()}`;
|
||||
return getCachedAnalytics(path, async () => {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
700
src/pages/Registrations.tsx
Normal file
700
src/pages/Registrations.tsx
Normal 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 pç/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)} pç/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 pç/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 pç/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² / pç/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;
|
||||
88
src/types.ts
88
src/types.ts
@@ -78,6 +78,94 @@ export interface CuttingSettings {
|
||||
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 {
|
||||
start: Date;
|
||||
end: Date;
|
||||
|
||||
Reference in New Issue
Block a user