Add supplier and imporve inventory frontend
This commit is contained in:
@@ -9,6 +9,7 @@ import { AppRouter } from './router/AppRouter';
|
||||
import { ThemeProvider } from './contexts/ThemeContext';
|
||||
import { AuthProvider } from './contexts/AuthContext';
|
||||
import { SSEProvider } from './contexts/SSEContext';
|
||||
import './i18n';
|
||||
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: {
|
||||
|
||||
@@ -2,6 +2,44 @@
|
||||
* Food Safety API Types - Mirror backend schemas
|
||||
*/
|
||||
|
||||
// Food Safety Enums
|
||||
export enum FoodSafetyStandard {
|
||||
HACCP = 'haccp',
|
||||
FDA = 'fda',
|
||||
USDA = 'usda',
|
||||
FSMA = 'fsma',
|
||||
SQF = 'sqf',
|
||||
BRC = 'brc',
|
||||
IFS = 'ifs',
|
||||
ISO22000 = 'iso22000',
|
||||
ORGANIC = 'organic',
|
||||
NON_GMO = 'non_gmo',
|
||||
ALLERGEN_FREE = 'allergen_free',
|
||||
KOSHER = 'kosher',
|
||||
HALAL = 'halal'
|
||||
}
|
||||
|
||||
export enum ComplianceStatus {
|
||||
COMPLIANT = 'compliant',
|
||||
NON_COMPLIANT = 'non_compliant',
|
||||
PENDING_REVIEW = 'pending_review',
|
||||
EXPIRED = 'expired',
|
||||
WARNING = 'warning'
|
||||
}
|
||||
|
||||
export enum FoodSafetyAlertType {
|
||||
TEMPERATURE_VIOLATION = 'temperature_violation',
|
||||
EXPIRATION_WARNING = 'expiration_warning',
|
||||
EXPIRED_PRODUCT = 'expired_product',
|
||||
CONTAMINATION_RISK = 'contamination_risk',
|
||||
ALLERGEN_CROSS_CONTAMINATION = 'allergen_cross_contamination',
|
||||
STORAGE_VIOLATION = 'storage_violation',
|
||||
QUALITY_DEGRADATION = 'quality_degradation',
|
||||
RECALL_NOTICE = 'recall_notice',
|
||||
CERTIFICATION_EXPIRY = 'certification_expiry',
|
||||
SUPPLIER_COMPLIANCE_ISSUE = 'supplier_compliance_issue'
|
||||
}
|
||||
|
||||
export interface FoodSafetyComplianceCreate {
|
||||
ingredient_id: string;
|
||||
compliance_type: 'temperature_check' | 'quality_inspection' | 'hygiene_audit' | 'batch_verification';
|
||||
@@ -223,4 +261,12 @@ export interface FoodSafetyDashboard {
|
||||
days_until_expiry: number;
|
||||
quantity: number;
|
||||
}>;
|
||||
}
|
||||
|
||||
// Select option interface for enum helpers
|
||||
export interface EnumOption {
|
||||
value: string | number;
|
||||
label: string;
|
||||
disabled?: boolean;
|
||||
description?: string;
|
||||
}
|
||||
@@ -56,6 +56,18 @@ export enum ProductCategory {
|
||||
OTHER_PRODUCTS = 'other_products'
|
||||
}
|
||||
|
||||
export enum StockMovementType {
|
||||
PURCHASE = 'PURCHASE',
|
||||
PRODUCTION_USE = 'PRODUCTION_USE',
|
||||
ADJUSTMENT = 'ADJUSTMENT',
|
||||
WASTE = 'WASTE',
|
||||
TRANSFER = 'TRANSFER',
|
||||
RETURN = 'RETURN',
|
||||
INITIAL_STOCK = 'INITIAL_STOCK',
|
||||
TRANSFORMATION = 'TRANSFORMATION'
|
||||
}
|
||||
|
||||
|
||||
// Base Inventory Types
|
||||
export interface IngredientCreate {
|
||||
name: string;
|
||||
@@ -67,7 +79,6 @@ export interface IngredientCreate {
|
||||
reorder_point: number;
|
||||
shelf_life_days?: number; // Default shelf life only
|
||||
is_seasonal?: boolean;
|
||||
supplier_id?: string;
|
||||
average_cost?: number;
|
||||
notes?: string;
|
||||
}
|
||||
@@ -135,6 +146,7 @@ export interface IngredientResponse {
|
||||
// Stock Management Types
|
||||
export interface StockCreate {
|
||||
ingredient_id: string;
|
||||
supplier_id?: string;
|
||||
batch_number?: string;
|
||||
lot_number?: string;
|
||||
supplier_batch_ref?: string;
|
||||
@@ -174,6 +186,7 @@ export interface StockCreate {
|
||||
}
|
||||
|
||||
export interface StockUpdate {
|
||||
supplier_id?: string;
|
||||
batch_number?: string;
|
||||
lot_number?: string;
|
||||
supplier_batch_ref?: string;
|
||||
@@ -409,4 +422,12 @@ export interface DeletionSummary {
|
||||
deleted_stock_movements: number;
|
||||
deleted_stock_alerts: number;
|
||||
success: boolean;
|
||||
}
|
||||
|
||||
// Select option interface for enum helpers
|
||||
export interface EnumOption {
|
||||
value: string | number;
|
||||
label: string;
|
||||
disabled?: boolean;
|
||||
description?: string;
|
||||
}
|
||||
@@ -22,12 +22,13 @@ export enum SupplierStatus {
|
||||
}
|
||||
|
||||
export enum PaymentTerms {
|
||||
CASH_ON_DELIVERY = 'cod',
|
||||
COD = 'cod',
|
||||
NET_15 = 'net_15',
|
||||
NET_30 = 'net_30',
|
||||
NET_45 = 'net_45',
|
||||
NET_60 = 'net_60',
|
||||
PREPAID = 'prepaid',
|
||||
CREDIT_TERMS = 'credit_terms',
|
||||
}
|
||||
|
||||
export enum PurchaseOrderStatus {
|
||||
@@ -39,6 +40,7 @@ export enum PurchaseOrderStatus {
|
||||
PARTIALLY_RECEIVED = 'partially_received',
|
||||
COMPLETED = 'completed',
|
||||
CANCELLED = 'cancelled',
|
||||
DISPUTED = 'disputed',
|
||||
}
|
||||
|
||||
export enum DeliveryStatus {
|
||||
@@ -48,6 +50,32 @@ export enum DeliveryStatus {
|
||||
DELIVERED = 'delivered',
|
||||
PARTIALLY_DELIVERED = 'partially_delivered',
|
||||
FAILED_DELIVERY = 'failed_delivery',
|
||||
RETURNED = 'returned',
|
||||
}
|
||||
|
||||
export enum QualityRating {
|
||||
EXCELLENT = 5,
|
||||
GOOD = 4,
|
||||
AVERAGE = 3,
|
||||
POOR = 2,
|
||||
VERY_POOR = 1,
|
||||
}
|
||||
|
||||
export enum DeliveryRating {
|
||||
EXCELLENT = 5,
|
||||
GOOD = 4,
|
||||
AVERAGE = 3,
|
||||
POOR = 2,
|
||||
VERY_POOR = 1,
|
||||
}
|
||||
|
||||
export enum InvoiceStatus {
|
||||
PENDING = 'pending',
|
||||
APPROVED = 'approved',
|
||||
PAID = 'paid',
|
||||
OVERDUE = 'overdue',
|
||||
DISPUTED = 'disputed',
|
||||
CANCELLED = 'cancelled',
|
||||
}
|
||||
|
||||
export enum OrderPriority {
|
||||
@@ -425,7 +453,10 @@ export interface ApiResponse<T> {
|
||||
errors?: string[];
|
||||
}
|
||||
|
||||
// Export all types
|
||||
export type {
|
||||
// Add any additional export aliases if needed
|
||||
};
|
||||
// Select option interface for enum helpers
|
||||
export interface EnumOption {
|
||||
value: string | number;
|
||||
label: string;
|
||||
disabled?: boolean;
|
||||
description?: string;
|
||||
}
|
||||
@@ -1,8 +1,11 @@
|
||||
import React, { useState } from 'react';
|
||||
import { Plus, Package, Euro, Calendar, FileText, Thermometer } from 'lucide-react';
|
||||
import { StatusModal } from '../../ui/StatusModal/StatusModal';
|
||||
import { IngredientResponse, StockCreate } from '../../../api/types/inventory';
|
||||
import { IngredientResponse, StockCreate, ProductionStage } from '../../../api/types/inventory';
|
||||
import { Button } from '../../ui/Button';
|
||||
import { useSuppliers } from '../../../api/hooks/suppliers';
|
||||
import { useCurrentTenant } from '../../../stores/tenant.store';
|
||||
import { useInventoryEnums } from '../../../utils/inventoryEnumHelpers';
|
||||
import { statusColors } from '../../../styles/colors';
|
||||
|
||||
interface AddStockModalProps {
|
||||
@@ -27,11 +30,14 @@ export const AddStockModal: React.FC<AddStockModalProps> = ({
|
||||
current_quantity: 0,
|
||||
unit_cost: Number(ingredient.average_cost) || 0,
|
||||
expiration_date: '',
|
||||
production_stage: ProductionStage.RAW_INGREDIENT,
|
||||
batch_number: '',
|
||||
supplier_id: '',
|
||||
quality_status: 'good',
|
||||
storage_location: '',
|
||||
requires_refrigeration: false,
|
||||
requires_freezing: false,
|
||||
warehouse_zone: '',
|
||||
requires_refrigeration: 'no',
|
||||
requires_freezing: 'no',
|
||||
storage_temperature_min: undefined,
|
||||
storage_temperature_max: undefined,
|
||||
storage_humidity_max: undefined,
|
||||
@@ -43,12 +49,82 @@ export const AddStockModal: React.FC<AddStockModalProps> = ({
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [mode, setMode] = useState<'overview' | 'edit'>('edit');
|
||||
|
||||
// Get current tenant and fetch suppliers
|
||||
const currentTenant = useCurrentTenant();
|
||||
const tenantId = currentTenant?.id || '';
|
||||
|
||||
const { data: suppliersData } = useSuppliers(tenantId, {
|
||||
limit: 100
|
||||
}, {
|
||||
enabled: !!tenantId
|
||||
});
|
||||
const suppliers = (suppliersData || []).filter(supplier => supplier.status === 'active');
|
||||
|
||||
// Get inventory enum helpers
|
||||
const inventoryEnums = useInventoryEnums();
|
||||
|
||||
// Create supplier options for select
|
||||
const supplierOptions = [
|
||||
{ value: '', label: 'Sin proveedor asignado' },
|
||||
...suppliers.map(supplier => ({
|
||||
value: supplier.id,
|
||||
label: `${supplier.name} (${supplier.supplier_code || 'Sin código'})`
|
||||
}))
|
||||
];
|
||||
|
||||
// Create quality status options
|
||||
const qualityStatusOptions = [
|
||||
{ value: 'good', label: 'Bueno' },
|
||||
{ value: 'damaged', label: 'Dañado' },
|
||||
{ value: 'expired', label: 'Vencido' },
|
||||
{ value: 'returned', label: 'Devuelto' }
|
||||
];
|
||||
|
||||
// Create storage location options (predefined common locations)
|
||||
const storageLocationOptions = [
|
||||
{ value: '', label: 'Sin ubicación específica' },
|
||||
{ value: 'estante-a1', label: 'Estante A-1' },
|
||||
{ value: 'estante-a2', label: 'Estante A-2' },
|
||||
{ value: 'estante-a3', label: 'Estante A-3' },
|
||||
{ value: 'estante-b1', label: 'Estante B-1' },
|
||||
{ value: 'estante-b2', label: 'Estante B-2' },
|
||||
{ value: 'frigorifico', label: 'Frigorífico' },
|
||||
{ value: 'congelador', label: 'Congelador' },
|
||||
{ value: 'almacen-principal', label: 'Almacén Principal' },
|
||||
{ value: 'zona-recepcion', label: 'Zona de Recepción' }
|
||||
];
|
||||
|
||||
// Create warehouse zone options
|
||||
const warehouseZoneOptions = [
|
||||
{ value: '', label: 'Sin zona específica' },
|
||||
{ value: 'zona-a', label: 'Zona A' },
|
||||
{ value: 'zona-b', label: 'Zona B' },
|
||||
{ value: 'zona-c', label: 'Zona C' },
|
||||
{ value: 'refrigerado', label: 'Refrigerado' },
|
||||
{ value: 'congelado', label: 'Congelado' },
|
||||
{ value: 'ambiente', label: 'Temperatura Ambiente' }
|
||||
];
|
||||
|
||||
// Create refrigeration requirement options
|
||||
const refrigerationOptions = [
|
||||
{ value: 'no', label: 'No requiere refrigeración' },
|
||||
{ value: 'yes', label: 'Requiere refrigeración' },
|
||||
{ value: 'recommended', label: 'Refrigeración recomendada' }
|
||||
];
|
||||
|
||||
// Create freezing requirement options
|
||||
const freezingOptions = [
|
||||
{ value: 'no', label: 'No requiere congelación' },
|
||||
{ value: 'yes', label: 'Requiere congelación' },
|
||||
{ value: 'recommended', label: 'Congelación recomendada' }
|
||||
];
|
||||
|
||||
const handleFieldChange = (sectionIndex: number, fieldIndex: number, value: string | number | boolean) => {
|
||||
const fieldMappings = [
|
||||
// Basic Stock Information section
|
||||
['current_quantity', 'unit_cost', 'expiration_date'],
|
||||
['current_quantity', 'unit_cost', 'expiration_date', 'production_stage'],
|
||||
// Additional Information section
|
||||
['batch_number', 'supplier_id', 'storage_location', 'notes'],
|
||||
['batch_number', 'supplier_id', 'quality_status', 'storage_location', 'warehouse_zone', 'notes'],
|
||||
// Storage Requirements section
|
||||
['requires_refrigeration', 'requires_freezing', 'storage_temperature_min', 'storage_temperature_max', 'storage_humidity_max', 'shelf_life_days', 'storage_instructions']
|
||||
];
|
||||
@@ -80,11 +156,14 @@ export const AddStockModal: React.FC<AddStockModalProps> = ({
|
||||
current_quantity: Number(formData.current_quantity),
|
||||
unit_cost: Number(formData.unit_cost),
|
||||
expiration_date: formData.expiration_date || undefined,
|
||||
production_stage: formData.production_stage || ProductionStage.RAW_INGREDIENT,
|
||||
batch_number: formData.batch_number || undefined,
|
||||
supplier_id: formData.supplier_id || undefined,
|
||||
quality_status: formData.quality_status || 'good',
|
||||
storage_location: formData.storage_location || undefined,
|
||||
requires_refrigeration: formData.requires_refrigeration || false,
|
||||
requires_freezing: formData.requires_freezing || false,
|
||||
warehouse_zone: formData.warehouse_zone || undefined,
|
||||
requires_refrigeration: formData.requires_refrigeration === 'yes',
|
||||
requires_freezing: formData.requires_freezing === 'yes',
|
||||
storage_temperature_min: formData.storage_temperature_min ? Number(formData.storage_temperature_min) : undefined,
|
||||
storage_temperature_max: formData.storage_temperature_max ? Number(formData.storage_temperature_max) : undefined,
|
||||
storage_humidity_max: formData.storage_humidity_max ? Number(formData.storage_humidity_max) : undefined,
|
||||
@@ -103,11 +182,14 @@ export const AddStockModal: React.FC<AddStockModalProps> = ({
|
||||
current_quantity: 0,
|
||||
unit_cost: Number(ingredient.average_cost) || 0,
|
||||
expiration_date: '',
|
||||
production_stage: ProductionStage.RAW_INGREDIENT,
|
||||
batch_number: '',
|
||||
supplier_id: '',
|
||||
quality_status: 'good',
|
||||
storage_location: '',
|
||||
requires_refrigeration: false,
|
||||
requires_freezing: false,
|
||||
warehouse_zone: '',
|
||||
requires_refrigeration: 'no',
|
||||
requires_freezing: 'no',
|
||||
storage_temperature_min: undefined,
|
||||
storage_temperature_max: undefined,
|
||||
storage_humidity_max: undefined,
|
||||
@@ -162,8 +244,14 @@ export const AddStockModal: React.FC<AddStockModalProps> = ({
|
||||
label: 'Fecha de Vencimiento',
|
||||
value: formData.expiration_date || '',
|
||||
type: 'date' as const,
|
||||
editable: true
|
||||
},
|
||||
{
|
||||
label: 'Etapa de Producción',
|
||||
value: formData.production_stage || ProductionStage.RAW_INGREDIENT,
|
||||
type: 'select' as const,
|
||||
editable: true,
|
||||
span: 2 as const
|
||||
options: inventoryEnums.getProductionStageOptions()
|
||||
}
|
||||
]
|
||||
},
|
||||
@@ -179,19 +267,35 @@ export const AddStockModal: React.FC<AddStockModalProps> = ({
|
||||
placeholder: 'Ej: LOTE2024001'
|
||||
},
|
||||
{
|
||||
label: 'ID Proveedor',
|
||||
label: 'Proveedor',
|
||||
value: formData.supplier_id || '',
|
||||
type: 'text' as const,
|
||||
type: 'select' as const,
|
||||
editable: true,
|
||||
placeholder: 'Ej: PROV001'
|
||||
placeholder: 'Seleccionar proveedor',
|
||||
options: supplierOptions
|
||||
},
|
||||
{
|
||||
label: 'Estado de Calidad',
|
||||
value: formData.quality_status || 'good',
|
||||
type: 'select' as const,
|
||||
editable: true,
|
||||
options: qualityStatusOptions
|
||||
},
|
||||
{
|
||||
label: 'Ubicación de Almacenamiento',
|
||||
value: formData.storage_location || '',
|
||||
type: 'text' as const,
|
||||
type: 'select' as const,
|
||||
editable: true,
|
||||
placeholder: 'Ej: Estante A-3',
|
||||
span: 2 as const
|
||||
placeholder: 'Seleccionar ubicación',
|
||||
options: storageLocationOptions
|
||||
},
|
||||
{
|
||||
label: 'Zona de Almacén',
|
||||
value: formData.warehouse_zone || '',
|
||||
type: 'select' as const,
|
||||
editable: true,
|
||||
placeholder: 'Seleccionar zona',
|
||||
options: warehouseZoneOptions
|
||||
},
|
||||
{
|
||||
label: 'Notas',
|
||||
@@ -209,15 +313,17 @@ export const AddStockModal: React.FC<AddStockModalProps> = ({
|
||||
fields: [
|
||||
{
|
||||
label: 'Requiere Refrigeración',
|
||||
value: formData.requires_refrigeration || false,
|
||||
type: 'boolean' as const,
|
||||
editable: true
|
||||
value: formData.requires_refrigeration || 'no',
|
||||
type: 'select' as const,
|
||||
editable: true,
|
||||
options: refrigerationOptions
|
||||
},
|
||||
{
|
||||
label: 'Requiere Congelación',
|
||||
value: formData.requires_freezing || false,
|
||||
type: 'boolean' as const,
|
||||
editable: true
|
||||
value: formData.requires_freezing || 'no',
|
||||
type: 'select' as const,
|
||||
editable: true,
|
||||
options: freezingOptions
|
||||
},
|
||||
{
|
||||
label: 'Temperatura Mínima (°C)',
|
||||
|
||||
@@ -2,6 +2,7 @@ import React, { useState } from 'react';
|
||||
import { Plus, Package, Calculator, Settings } from 'lucide-react';
|
||||
import { StatusModal } from '../../ui/StatusModal/StatusModal';
|
||||
import { IngredientCreate, UnitOfMeasure, IngredientCategory, ProductCategory } from '../../../api/types/inventory';
|
||||
import { useInventoryEnums } from '../../../utils/inventoryEnumHelpers';
|
||||
import { statusColors } from '../../../styles/colors';
|
||||
|
||||
interface CreateIngredientModalProps {
|
||||
@@ -28,7 +29,6 @@ export const CreateIngredientModal: React.FC<CreateIngredientModalProps> = ({
|
||||
reorder_point: 20,
|
||||
max_stock_level: 100,
|
||||
is_seasonal: false,
|
||||
supplier_id: '',
|
||||
average_cost: 0,
|
||||
notes: ''
|
||||
});
|
||||
@@ -36,44 +36,16 @@ export const CreateIngredientModal: React.FC<CreateIngredientModalProps> = ({
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [mode, setMode] = useState<'overview' | 'edit'>('edit');
|
||||
|
||||
// Category options combining ingredient and product categories
|
||||
// Get enum options using helpers
|
||||
const inventoryEnums = useInventoryEnums();
|
||||
|
||||
// Combine ingredient and product categories
|
||||
const categoryOptions = [
|
||||
// Ingredient categories
|
||||
{ label: 'Harinas', value: 'flour' },
|
||||
{ label: 'Levaduras', value: 'yeast' },
|
||||
{ label: 'Lácteos', value: 'dairy' },
|
||||
{ label: 'Huevos', value: 'eggs' },
|
||||
{ label: 'Azúcar', value: 'sugar' },
|
||||
{ label: 'Grasas', value: 'fats' },
|
||||
{ label: 'Sal', value: 'salt' },
|
||||
{ label: 'Especias', value: 'spices' },
|
||||
{ label: 'Aditivos', value: 'additives' },
|
||||
{ label: 'Envases', value: 'packaging' },
|
||||
{ label: 'Limpieza', value: 'cleaning' },
|
||||
// Product categories
|
||||
{ label: 'Pan', value: 'bread' },
|
||||
{ label: 'Croissants', value: 'croissants' },
|
||||
{ label: 'Pastelería', value: 'pastries' },
|
||||
{ label: 'Tartas', value: 'cakes' },
|
||||
{ label: 'Galletas', value: 'cookies' },
|
||||
{ label: 'Muffins', value: 'muffins' },
|
||||
{ label: 'Sandwiches', value: 'sandwiches' },
|
||||
{ label: 'Temporada', value: 'seasonal' },
|
||||
{ label: 'Bebidas', value: 'beverages' },
|
||||
{ label: 'Otros', value: 'other' }
|
||||
...inventoryEnums.getIngredientCategoryOptions(),
|
||||
...inventoryEnums.getProductCategoryOptions()
|
||||
];
|
||||
|
||||
const unitOptions = [
|
||||
{ label: 'Kilogramo (kg)', value: 'kg' },
|
||||
{ label: 'Gramo (g)', value: 'g' },
|
||||
{ label: 'Litro (l)', value: 'l' },
|
||||
{ label: 'Mililitro (ml)', value: 'ml' },
|
||||
{ label: 'Unidades', value: 'units' },
|
||||
{ label: 'Piezas', value: 'pcs' },
|
||||
{ label: 'Paquetes', value: 'pkg' },
|
||||
{ label: 'Bolsas', value: 'bags' },
|
||||
{ label: 'Cajas', value: 'boxes' }
|
||||
];
|
||||
const unitOptions = inventoryEnums.getUnitOfMeasureOptions();
|
||||
|
||||
const handleFieldChange = (sectionIndex: number, fieldIndex: number, value: string | number | boolean) => {
|
||||
// Map field positions to form data fields
|
||||
@@ -82,8 +54,8 @@ export const CreateIngredientModal: React.FC<CreateIngredientModalProps> = ({
|
||||
['name', 'description', 'category', 'unit_of_measure'],
|
||||
// Cost and Quantities section
|
||||
['average_cost', 'low_stock_threshold', 'reorder_point', 'max_stock_level'],
|
||||
// Additional Information section (moved up after removing storage section)
|
||||
['supplier_id', 'notes']
|
||||
// Additional Information section
|
||||
['notes']
|
||||
];
|
||||
|
||||
const fieldName = fieldMappings[sectionIndex]?.[fieldIndex] as keyof IngredientCreate;
|
||||
@@ -146,7 +118,6 @@ export const CreateIngredientModal: React.FC<CreateIngredientModalProps> = ({
|
||||
requires_refrigeration: false,
|
||||
requires_freezing: false,
|
||||
is_seasonal: false,
|
||||
supplier_id: '',
|
||||
average_cost: 0,
|
||||
notes: ''
|
||||
});
|
||||
@@ -174,7 +145,6 @@ export const CreateIngredientModal: React.FC<CreateIngredientModalProps> = ({
|
||||
requires_refrigeration: false,
|
||||
requires_freezing: false,
|
||||
is_seasonal: false,
|
||||
supplier_id: '',
|
||||
average_cost: 0,
|
||||
notes: ''
|
||||
});
|
||||
@@ -234,7 +204,7 @@ export const CreateIngredientModal: React.FC<CreateIngredientModalProps> = ({
|
||||
{
|
||||
label: 'Costo Promedio',
|
||||
value: formData.average_cost || 0,
|
||||
type: 'number' as const,
|
||||
type: 'currency' as const,
|
||||
editable: true,
|
||||
placeholder: '0.00'
|
||||
},
|
||||
@@ -267,13 +237,6 @@ export const CreateIngredientModal: React.FC<CreateIngredientModalProps> = ({
|
||||
title: 'Información Adicional',
|
||||
icon: Settings,
|
||||
fields: [
|
||||
{
|
||||
label: 'Proveedor',
|
||||
value: formData.supplier_id || '',
|
||||
type: 'text' as const,
|
||||
editable: true,
|
||||
placeholder: 'ID o nombre del proveedor'
|
||||
},
|
||||
{
|
||||
label: 'Notas',
|
||||
value: formData.notes || '',
|
||||
|
||||
183
frontend/src/components/domain/suppliers/CreateSupplierForm.tsx
Normal file
183
frontend/src/components/domain/suppliers/CreateSupplierForm.tsx
Normal file
@@ -0,0 +1,183 @@
|
||||
// frontend/src/components/domain/suppliers/CreateSupplierForm.tsx
|
||||
/**
|
||||
* Example usage of enum helpers with i18n in a supplier form
|
||||
*/
|
||||
|
||||
import React, { useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Select } from '../../ui/Select';
|
||||
import { Button } from '../../ui/Button/Button';
|
||||
import { useSupplierEnums } from '../../../utils/enumHelpers';
|
||||
import { SupplierType, SupplierStatus, PaymentTerms } from '../../../api/types/suppliers';
|
||||
|
||||
interface CreateSupplierFormProps {
|
||||
onSubmit: (data: SupplierFormData) => void;
|
||||
onCancel: () => void;
|
||||
}
|
||||
|
||||
interface SupplierFormData {
|
||||
name: string;
|
||||
supplier_type: SupplierType;
|
||||
status: SupplierStatus;
|
||||
payment_terms: PaymentTerms;
|
||||
email: string;
|
||||
phone: string;
|
||||
}
|
||||
|
||||
export const CreateSupplierForm: React.FC<CreateSupplierFormProps> = ({
|
||||
onSubmit,
|
||||
onCancel
|
||||
}) => {
|
||||
const { t } = useTranslation(['suppliers', 'common']);
|
||||
const supplierEnums = useSupplierEnums();
|
||||
|
||||
const [formData, setFormData] = useState<SupplierFormData>({
|
||||
name: '',
|
||||
supplier_type: SupplierType.INGREDIENTS,
|
||||
status: SupplierStatus.PENDING_APPROVAL,
|
||||
payment_terms: PaymentTerms.NET_30,
|
||||
email: '',
|
||||
phone: ''
|
||||
});
|
||||
|
||||
const handleSubmit = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
onSubmit(formData);
|
||||
};
|
||||
|
||||
const handleFieldChange = (field: keyof SupplierFormData, value: any) => {
|
||||
setFormData(prev => ({
|
||||
...prev,
|
||||
[field]: value
|
||||
}));
|
||||
};
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit} className="space-y-6">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
{/* Supplier Name */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
{t('common:forms.supplier_name')} *
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
required
|
||||
value={formData.name}
|
||||
onChange={(e) => handleFieldChange('name', e.target.value)}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
placeholder={t('common:forms.enter_supplier_name')}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Supplier Type - Using enum helper */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
{supplierEnums.getFieldLabel('supplier_type')} *
|
||||
</label>
|
||||
<Select
|
||||
options={supplierEnums.getSupplierTypeOptions()}
|
||||
value={formData.supplier_type}
|
||||
onChange={(value) => handleFieldChange('supplier_type', value as SupplierType)}
|
||||
placeholder={t('common:forms.select_option')}
|
||||
helperText={supplierEnums.getFieldDescription('supplier_type')}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Supplier Status - Using enum helper */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
{supplierEnums.getFieldLabel('supplier_status')}
|
||||
</label>
|
||||
<Select
|
||||
options={supplierEnums.getSupplierStatusOptions()}
|
||||
value={formData.status}
|
||||
onChange={(value) => handleFieldChange('status', value as SupplierStatus)}
|
||||
placeholder={t('common:forms.select_option')}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Payment Terms - Using enum helper */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
{supplierEnums.getFieldLabel('payment_terms')} *
|
||||
</label>
|
||||
<Select
|
||||
options={supplierEnums.getPaymentTermsOptions()}
|
||||
value={formData.payment_terms}
|
||||
onChange={(value) => handleFieldChange('payment_terms', value as PaymentTerms)}
|
||||
placeholder={t('common:forms.select_option')}
|
||||
helperText={supplierEnums.getFieldDescription('payment_terms')}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Email */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
{t('common:forms.email')}
|
||||
</label>
|
||||
<input
|
||||
type="email"
|
||||
value={formData.email}
|
||||
onChange={(e) => handleFieldChange('email', e.target.value)}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
placeholder={t('common:forms.enter_email')}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Phone */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
{t('common:forms.phone')}
|
||||
</label>
|
||||
<input
|
||||
type="tel"
|
||||
value={formData.phone}
|
||||
onChange={(e) => handleFieldChange('phone', e.target.value)}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
placeholder={t('common:forms.enter_phone')}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex justify-end space-x-4">
|
||||
<Button
|
||||
type="button"
|
||||
variant="secondary"
|
||||
onClick={onCancel}
|
||||
>
|
||||
{t('common:actions.cancel')}
|
||||
</Button>
|
||||
<Button
|
||||
type="submit"
|
||||
variant="primary"
|
||||
>
|
||||
{t('common:actions.create')}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Display current selections for debugging */}
|
||||
<div className="mt-8 p-4 bg-gray-50 rounded-md">
|
||||
<h3 className="text-sm font-medium text-gray-700 mb-2">Current Selections:</h3>
|
||||
<ul className="text-sm text-gray-600 space-y-1">
|
||||
<li>
|
||||
<strong>Tipo:</strong> {supplierEnums.getSupplierTypeLabel(formData.supplier_type)}
|
||||
</li>
|
||||
<li>
|
||||
<strong>Estado:</strong> {supplierEnums.getSupplierStatusLabel(formData.status)}
|
||||
</li>
|
||||
<li>
|
||||
<strong>Términos de Pago:</strong> {supplierEnums.getPaymentTermsLabel(formData.payment_terms)}
|
||||
</li>
|
||||
<li>
|
||||
<strong>Debug - Payment Terms Raw:</strong> {formData.payment_terms}
|
||||
</li>
|
||||
<li>
|
||||
<strong>Debug - Translation Test:</strong> {t('suppliers:payment_terms.net_30')}
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
};
|
||||
@@ -227,10 +227,6 @@ export const StatusCard: React.FC<StatusCardProps> = ({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Spacer for alignment when no progress bar */}
|
||||
{!progress && (
|
||||
<div className="h-12" />
|
||||
)}
|
||||
|
||||
{/* Metadata */}
|
||||
{metadata.length > 0 && (
|
||||
|
||||
38
frontend/src/i18n/index.ts
Normal file
38
frontend/src/i18n/index.ts
Normal file
@@ -0,0 +1,38 @@
|
||||
// frontend/src/i18n/index.ts
|
||||
import i18n from 'i18next';
|
||||
import { initReactI18next } from 'react-i18next';
|
||||
import { resources, defaultLanguage } from '../locales';
|
||||
|
||||
i18n
|
||||
.use(initReactI18next)
|
||||
.init({
|
||||
resources,
|
||||
lng: defaultLanguage,
|
||||
fallbackLng: defaultLanguage,
|
||||
|
||||
interpolation: {
|
||||
escapeValue: false, // React already does escaping
|
||||
},
|
||||
|
||||
// Enable debug in development
|
||||
debug: process.env.NODE_ENV === 'development',
|
||||
|
||||
// Default namespace
|
||||
defaultNS: 'common',
|
||||
|
||||
// Fallback namespace
|
||||
fallbackNS: 'common',
|
||||
|
||||
// Key separator for nested keys
|
||||
keySeparator: '.',
|
||||
|
||||
// Namespace separator
|
||||
nsSeparator: ':',
|
||||
|
||||
// React options
|
||||
react: {
|
||||
useSuspense: false,
|
||||
},
|
||||
});
|
||||
|
||||
export default i18n;
|
||||
48
frontend/src/locales/es/foodSafety.json
Normal file
48
frontend/src/locales/es/foodSafety.json
Normal file
@@ -0,0 +1,48 @@
|
||||
{
|
||||
"enums": {
|
||||
"food_safety_standard": {
|
||||
"haccp": "HACCP",
|
||||
"fda": "FDA",
|
||||
"usda": "USDA",
|
||||
"fsma": "FSMA",
|
||||
"sqf": "SQF",
|
||||
"brc": "BRC",
|
||||
"ifs": "IFS",
|
||||
"iso22000": "ISO 22000",
|
||||
"organic": "Orgánico",
|
||||
"non_gmo": "Sin OGM",
|
||||
"allergen_free": "Libre de Alérgenos",
|
||||
"kosher": "Kosher",
|
||||
"halal": "Halal"
|
||||
},
|
||||
"compliance_status": {
|
||||
"compliant": "Conforme",
|
||||
"non_compliant": "No Conforme",
|
||||
"pending_review": "Pendiente de Revisión",
|
||||
"expired": "Vencido",
|
||||
"warning": "Advertencia"
|
||||
},
|
||||
"food_safety_alert_type": {
|
||||
"temperature_violation": "Violación de Temperatura",
|
||||
"expiration_warning": "Advertencia de Vencimiento",
|
||||
"expired_product": "Producto Vencido",
|
||||
"contamination_risk": "Riesgo de Contaminación",
|
||||
"allergen_cross_contamination": "Contaminación Cruzada de Alérgenos",
|
||||
"storage_violation": "Violación de Almacenamiento",
|
||||
"quality_degradation": "Degradación de Calidad",
|
||||
"recall_notice": "Aviso de Retiro",
|
||||
"certification_expiry": "Vencimiento de Certificación",
|
||||
"supplier_compliance_issue": "Problema de Cumplimiento del Proveedor"
|
||||
}
|
||||
},
|
||||
"labels": {
|
||||
"food_safety_standard": "Estándar de Seguridad Alimentaria",
|
||||
"compliance_status": "Estado de Cumplimiento",
|
||||
"food_safety_alert_type": "Tipo de Alerta de Seguridad"
|
||||
},
|
||||
"descriptions": {
|
||||
"food_safety_standard": "Estándar de seguridad alimentaria aplicable",
|
||||
"compliance_status": "Estado actual de cumplimiento normativo",
|
||||
"food_safety_alert_type": "Tipo de alerta de seguridad alimentaria"
|
||||
}
|
||||
}
|
||||
@@ -40,6 +40,81 @@
|
||||
"description": "Descripción",
|
||||
"notes": "Notas"
|
||||
},
|
||||
"enums": {
|
||||
"product_type": {
|
||||
"ingredient": "Ingrediente",
|
||||
"finished_product": "Producto Terminado"
|
||||
},
|
||||
"production_stage": {
|
||||
"raw_ingredient": "Ingrediente Crudo",
|
||||
"par_baked": "Pre-cocido",
|
||||
"fully_baked": "Completamente Cocido",
|
||||
"prepared_dough": "Masa Preparada",
|
||||
"frozen_product": "Producto Congelado"
|
||||
},
|
||||
"unit_of_measure": {
|
||||
"kg": "Kilogramos",
|
||||
"g": "Gramos",
|
||||
"l": "Litros",
|
||||
"ml": "Mililitros",
|
||||
"units": "Unidades",
|
||||
"pcs": "Piezas",
|
||||
"pkg": "Paquetes",
|
||||
"bags": "Bolsas",
|
||||
"boxes": "Cajas"
|
||||
},
|
||||
"ingredient_category": {
|
||||
"flour": "Harinas",
|
||||
"yeast": "Levaduras",
|
||||
"dairy": "Lácteos",
|
||||
"eggs": "Huevos",
|
||||
"sugar": "Azúcares",
|
||||
"fats": "Grasas",
|
||||
"salt": "Sal",
|
||||
"spices": "Especias",
|
||||
"additives": "Aditivos",
|
||||
"packaging": "Embalaje",
|
||||
"cleaning": "Limpieza",
|
||||
"other": "Otros"
|
||||
},
|
||||
"product_category": {
|
||||
"bread": "Panes",
|
||||
"croissants": "Croissants",
|
||||
"pastries": "Bollería",
|
||||
"cakes": "Tartas",
|
||||
"cookies": "Galletas",
|
||||
"muffins": "Muffins",
|
||||
"sandwiches": "Sándwiches",
|
||||
"seasonal": "Temporales",
|
||||
"beverages": "Bebidas",
|
||||
"other_products": "Otros Productos"
|
||||
},
|
||||
"stock_movement_type": {
|
||||
"PURCHASE": "Compra",
|
||||
"PRODUCTION_USE": "Uso en Producción",
|
||||
"ADJUSTMENT": "Ajuste",
|
||||
"WASTE": "Desperdicio",
|
||||
"TRANSFER": "Transferencia",
|
||||
"RETURN": "Devolución",
|
||||
"INITIAL_STOCK": "Stock Inicial",
|
||||
"TRANSFORMATION": "Transformación"
|
||||
}
|
||||
},
|
||||
"labels": {
|
||||
"product_type": "Tipo de Producto",
|
||||
"production_stage": "Etapa de Producción",
|
||||
"unit_of_measure": "Unidad de Medida",
|
||||
"ingredient_category": "Categoría de Ingrediente",
|
||||
"product_category": "Categoría de Producto",
|
||||
"stock_movement_type": "Tipo de Movimiento"
|
||||
},
|
||||
"descriptions": {
|
||||
"product_type": "Selecciona si es un ingrediente básico o un producto terminado",
|
||||
"production_stage": "Indica la etapa de producción en la que se encuentra el producto",
|
||||
"unit_of_measure": "Unidad de medida utilizada para este producto",
|
||||
"ingredient_category": "Categoría que mejor describe este ingrediente",
|
||||
"stock_movement_type": "Tipo de movimiento de inventario a registrar"
|
||||
},
|
||||
"categories": {
|
||||
"all": "Todas las categorías",
|
||||
"flour": "Harinas",
|
||||
|
||||
84
frontend/src/locales/es/suppliers.json
Normal file
84
frontend/src/locales/es/suppliers.json
Normal file
@@ -0,0 +1,84 @@
|
||||
{
|
||||
"types": {
|
||||
"ingredients": "Materias Primas",
|
||||
"packaging": "Embalaje",
|
||||
"equipment": "Equipamiento",
|
||||
"services": "Servicios",
|
||||
"utilities": "Servicios Públicos",
|
||||
"multi": "Múltiples Categorías"
|
||||
},
|
||||
"status": {
|
||||
"active": "Activo",
|
||||
"inactive": "Inactivo",
|
||||
"pending_approval": "Pendiente de Aprobación",
|
||||
"suspended": "Suspendido",
|
||||
"blacklisted": "Lista Negra"
|
||||
},
|
||||
"payment_terms": {
|
||||
"cod": "Contra Entrega",
|
||||
"net_15": "Neto 15 días",
|
||||
"net_30": "Neto 30 días",
|
||||
"net_45": "Neto 45 días",
|
||||
"net_60": "Neto 60 días",
|
||||
"prepaid": "Prepago",
|
||||
"credit_terms": "Términos de Crédito"
|
||||
},
|
||||
"purchase_order_status": {
|
||||
"draft": "Borrador",
|
||||
"pending_approval": "Pendiente de Aprobación",
|
||||
"approved": "Aprobado",
|
||||
"sent_to_supplier": "Enviado al Proveedor",
|
||||
"confirmed": "Confirmado",
|
||||
"partially_received": "Parcialmente Recibido",
|
||||
"completed": "Completado",
|
||||
"cancelled": "Cancelado",
|
||||
"disputed": "Disputado"
|
||||
},
|
||||
"delivery_status": {
|
||||
"scheduled": "Programado",
|
||||
"in_transit": "En Tránsito",
|
||||
"out_for_delivery": "En Reparto",
|
||||
"delivered": "Entregado",
|
||||
"partially_delivered": "Parcialmente Entregado",
|
||||
"failed_delivery": "Entrega Fallida",
|
||||
"returned": "Devuelto"
|
||||
},
|
||||
"quality_rating": {
|
||||
"5": "Excelente",
|
||||
"4": "Bueno",
|
||||
"3": "Promedio",
|
||||
"2": "Malo",
|
||||
"1": "Muy Malo"
|
||||
},
|
||||
"delivery_rating": {
|
||||
"5": "Excelente",
|
||||
"4": "Bueno",
|
||||
"3": "Promedio",
|
||||
"2": "Malo",
|
||||
"1": "Muy Malo"
|
||||
},
|
||||
"invoice_status": {
|
||||
"pending": "Pendiente",
|
||||
"approved": "Aprobado",
|
||||
"paid": "Pagado",
|
||||
"overdue": "Vencido",
|
||||
"disputed": "Disputado",
|
||||
"cancelled": "Cancelado"
|
||||
},
|
||||
"labels": {
|
||||
"supplier_type": "Tipo de Proveedor",
|
||||
"supplier_status": "Estado del Proveedor",
|
||||
"payment_terms": "Términos de Pago",
|
||||
"purchase_order_status": "Estado de Orden de Compra",
|
||||
"delivery_status": "Estado de Entrega",
|
||||
"quality_rating": "Calificación de Calidad",
|
||||
"delivery_rating": "Calificación de Entrega",
|
||||
"invoice_status": "Estado de Factura"
|
||||
},
|
||||
"descriptions": {
|
||||
"supplier_type": "Selecciona el tipo de productos o servicios que ofrece este proveedor",
|
||||
"payment_terms": "Términos de pago acordados con el proveedor",
|
||||
"quality_rating": "Calificación de 1 a 5 estrellas basada en la calidad de los productos",
|
||||
"delivery_rating": "Calificación de 1 a 5 estrellas basada en la puntualidad y estado de las entregas"
|
||||
}
|
||||
}
|
||||
@@ -2,6 +2,8 @@
|
||||
import commonEs from './es/common.json';
|
||||
import authEs from './es/auth.json';
|
||||
import inventoryEs from './es/inventory.json';
|
||||
import foodSafetyEs from './es/foodSafety.json';
|
||||
import suppliersEs from './es/suppliers.json';
|
||||
import errorsEs from './es/errors.json';
|
||||
|
||||
// Translation resources by language
|
||||
@@ -10,6 +12,8 @@ export const resources = {
|
||||
common: commonEs,
|
||||
auth: authEs,
|
||||
inventory: inventoryEs,
|
||||
foodSafety: foodSafetyEs,
|
||||
suppliers: suppliersEs,
|
||||
errors: errorsEs,
|
||||
},
|
||||
};
|
||||
@@ -33,7 +37,7 @@ export const languageConfig = {
|
||||
};
|
||||
|
||||
// Namespaces available in translations
|
||||
export const namespaces = ['common', 'auth', 'inventory', 'errors'] as const;
|
||||
export const namespaces = ['common', 'auth', 'inventory', 'foodSafety', 'suppliers', 'errors'] as const;
|
||||
export type Namespace = typeof namespaces[number];
|
||||
|
||||
// Helper function to get language display name
|
||||
@@ -47,7 +51,7 @@ export const isSupportedLanguage = (language: string): language is SupportedLang
|
||||
};
|
||||
|
||||
// Export individual language modules for direct imports
|
||||
export { commonEs, authEs, inventoryEs, errorsEs };
|
||||
export { commonEs, authEs, inventoryEs, foodSafetyEs, suppliersEs, errorsEs };
|
||||
|
||||
// Default export with all translations
|
||||
export default resources;
|
||||
@@ -207,6 +207,7 @@ const InventoryPage: React.FC = () => {
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
// Sort by priority: expired → out of stock → low stock → normal → overstock
|
||||
// Within each priority level, sort by most critical items first
|
||||
return items.sort((a, b) => {
|
||||
@@ -263,22 +264,8 @@ const InventoryPage: React.FC = () => {
|
||||
|
||||
// Helper function to get category display name
|
||||
const getCategoryDisplayName = (category?: string): string => {
|
||||
const categoryMappings: Record<string, string> = {
|
||||
'flour': 'Harina',
|
||||
'dairy': 'Lácteos',
|
||||
'eggs': 'Huevos',
|
||||
'sugar': 'Azúcar',
|
||||
'yeast': 'Levadura',
|
||||
'fats': 'Grasas',
|
||||
'spices': 'Especias',
|
||||
'croissants': 'Croissants',
|
||||
'pastries': 'Pastelería',
|
||||
'beverages': 'Bebidas',
|
||||
'bread': 'Pan',
|
||||
'other': 'Otros'
|
||||
};
|
||||
|
||||
return categoryMappings[category?.toLowerCase() || ''] || category || 'Sin categoría';
|
||||
if (!category) return 'Sin categoría';
|
||||
return category;
|
||||
};
|
||||
|
||||
// Focused action handlers
|
||||
|
||||
@@ -1,93 +1,54 @@
|
||||
import React, { useState } from 'react';
|
||||
import { Plus, Download, Building2, Phone, Mail, Eye, Edit, CheckCircle, AlertCircle, Timer, Users, DollarSign } from 'lucide-react';
|
||||
import { Plus, Building2, Phone, Mail, Eye, Edit, CheckCircle, AlertCircle, Timer, Users, DollarSign, Loader } from 'lucide-react';
|
||||
import { Button, Input, Card, Badge, StatsGrid, StatusCard, getStatusColor, StatusModal } from '../../../../components/ui';
|
||||
import { formatters } from '../../../../components/ui/Stats/StatsPresets';
|
||||
import { PageHeader } from '../../../../components/layout';
|
||||
import { SupplierStatus, SupplierType, PaymentTerms } from '../../../../api/types/suppliers';
|
||||
import { useSuppliers, useSupplierStatistics } from '../../../../api/hooks/suppliers';
|
||||
import { useCurrentTenant } from '../../../../stores/tenant.store';
|
||||
import { useAuthUser } from '../../../../stores/auth.store';
|
||||
import { useSupplierEnums } from '../../../../utils/enumHelpers';
|
||||
|
||||
const SuppliersPage: React.FC = () => {
|
||||
const [activeTab] = useState('all');
|
||||
const [searchTerm, setSearchTerm] = useState('');
|
||||
const [showForm, setShowForm] = useState(false);
|
||||
const [modalMode, setModalMode] = useState<'view' | 'edit'>('view');
|
||||
const [selectedSupplier, setSelectedSupplier] = useState<typeof mockSuppliers[0] | null>(null);
|
||||
const [selectedSupplier, setSelectedSupplier] = useState<any>(null);
|
||||
const [isCreating, setIsCreating] = useState(false);
|
||||
|
||||
const mockSuppliers = [
|
||||
{
|
||||
id: 'SUP-2024-001',
|
||||
supplier_code: 'HAR001',
|
||||
name: 'Harinas del Norte S.L.',
|
||||
supplier_type: SupplierType.INGREDIENTS,
|
||||
status: SupplierStatus.ACTIVE,
|
||||
contact_person: 'María González',
|
||||
email: 'maria@harinasdelnorte.es',
|
||||
phone: '+34 987 654 321',
|
||||
city: 'León',
|
||||
country: 'España',
|
||||
payment_terms: PaymentTerms.NET_30,
|
||||
credit_limit: 5000,
|
||||
currency: 'EUR',
|
||||
standard_lead_time: 5,
|
||||
minimum_order_amount: 200,
|
||||
total_orders: 45,
|
||||
total_spend: 12750.50,
|
||||
last_order_date: '2024-01-25T14:30:00Z',
|
||||
performance_score: 92,
|
||||
notes: 'Proveedor principal de harinas. Excelente calidad y puntualidad.'
|
||||
},
|
||||
{
|
||||
id: 'SUP-2024-002',
|
||||
supplier_code: 'EMB002',
|
||||
name: 'Embalajes Biodegradables SA',
|
||||
supplier_type: SupplierType.PACKAGING,
|
||||
status: SupplierStatus.ACTIVE,
|
||||
contact_person: 'Carlos Ruiz',
|
||||
email: 'carlos@embalajes-bio.com',
|
||||
phone: '+34 600 123 456',
|
||||
city: 'Valencia',
|
||||
country: 'España',
|
||||
payment_terms: PaymentTerms.NET_15,
|
||||
credit_limit: 2500,
|
||||
currency: 'EUR',
|
||||
standard_lead_time: 3,
|
||||
minimum_order_amount: 150,
|
||||
total_orders: 28,
|
||||
total_spend: 4280.75,
|
||||
last_order_date: '2024-01-24T10:15:00Z',
|
||||
performance_score: 88,
|
||||
notes: 'Especialista en packaging sostenible.'
|
||||
},
|
||||
{
|
||||
id: 'SUP-2024-003',
|
||||
supplier_code: 'MAN003',
|
||||
name: 'Maquinaria Industrial López',
|
||||
supplier_type: SupplierType.EQUIPMENT,
|
||||
status: SupplierStatus.PENDING_APPROVAL,
|
||||
contact_person: 'Ana López',
|
||||
email: 'ana@maquinaria-lopez.es',
|
||||
phone: '+34 655 987 654',
|
||||
city: 'Madrid',
|
||||
country: 'España',
|
||||
payment_terms: PaymentTerms.NET_45,
|
||||
credit_limit: 15000,
|
||||
currency: 'EUR',
|
||||
standard_lead_time: 14,
|
||||
minimum_order_amount: 500,
|
||||
total_orders: 0,
|
||||
total_spend: 0,
|
||||
last_order_date: null,
|
||||
performance_score: null,
|
||||
notes: 'Nuevo proveedor de equipamiento industrial. Pendiente de aprobación.'
|
||||
},
|
||||
];
|
||||
// Get tenant ID from tenant store (preferred) or auth user (fallback)
|
||||
const currentTenant = useCurrentTenant();
|
||||
const user = useAuthUser();
|
||||
const tenantId = currentTenant?.id || user?.tenant_id || '';
|
||||
|
||||
// API hooks
|
||||
const {
|
||||
data: suppliersData,
|
||||
isLoading: suppliersLoading,
|
||||
error: suppliersError
|
||||
} = useSuppliers(tenantId, {
|
||||
search_term: searchTerm || undefined,
|
||||
status: activeTab !== 'all' ? activeTab as any : undefined,
|
||||
limit: 100
|
||||
});
|
||||
|
||||
const {
|
||||
data: statisticsData,
|
||||
isLoading: statisticsLoading,
|
||||
error: statisticsError
|
||||
} = useSupplierStatistics(tenantId);
|
||||
|
||||
const suppliers = suppliersData || [];
|
||||
const supplierEnums = useSupplierEnums();
|
||||
|
||||
const getSupplierStatusConfig = (status: SupplierStatus) => {
|
||||
const statusConfig = {
|
||||
[SupplierStatus.ACTIVE]: { text: 'Activo', icon: CheckCircle },
|
||||
[SupplierStatus.INACTIVE]: { text: 'Inactivo', icon: Timer },
|
||||
[SupplierStatus.PENDING_APPROVAL]: { text: 'Pendiente Aprobación', icon: AlertCircle },
|
||||
[SupplierStatus.SUSPENDED]: { text: 'Suspendido', icon: AlertCircle },
|
||||
[SupplierStatus.BLACKLISTED]: { text: 'Lista Negra', icon: AlertCircle },
|
||||
[SupplierStatus.ACTIVE]: { text: supplierEnums.getSupplierStatusLabel(status), icon: CheckCircle },
|
||||
[SupplierStatus.INACTIVE]: { text: supplierEnums.getSupplierStatusLabel(status), icon: Timer },
|
||||
[SupplierStatus.PENDING_APPROVAL]: { text: supplierEnums.getSupplierStatusLabel(status), icon: AlertCircle },
|
||||
[SupplierStatus.SUSPENDED]: { text: supplierEnums.getSupplierStatusLabel(status), icon: AlertCircle },
|
||||
[SupplierStatus.BLACKLISTED]: { text: supplierEnums.getSupplierStatusLabel(status), icon: AlertCircle },
|
||||
};
|
||||
|
||||
const config = statusConfig[status];
|
||||
@@ -104,110 +65,122 @@ const SuppliersPage: React.FC = () => {
|
||||
};
|
||||
|
||||
const getSupplierTypeText = (type: SupplierType): string => {
|
||||
const typeMap = {
|
||||
[SupplierType.INGREDIENTS]: 'Ingredientes',
|
||||
[SupplierType.PACKAGING]: 'Embalajes',
|
||||
[SupplierType.EQUIPMENT]: 'Equipamiento',
|
||||
[SupplierType.SERVICES]: 'Servicios',
|
||||
[SupplierType.UTILITIES]: 'Servicios Públicos',
|
||||
[SupplierType.MULTI]: 'Múltiple',
|
||||
};
|
||||
return typeMap[type] || type;
|
||||
return supplierEnums.getSupplierTypeLabel(type);
|
||||
};
|
||||
|
||||
const getPaymentTermsText = (terms: PaymentTerms): string => {
|
||||
const termsMap = {
|
||||
[PaymentTerms.CASH_ON_DELIVERY]: 'Pago Contraentrega',
|
||||
[PaymentTerms.NET_15]: 'Neto 15 días',
|
||||
[PaymentTerms.NET_30]: 'Neto 30 días',
|
||||
[PaymentTerms.NET_45]: 'Neto 45 días',
|
||||
[PaymentTerms.NET_60]: 'Neto 60 días',
|
||||
[PaymentTerms.PREPAID]: 'Prepago',
|
||||
};
|
||||
return termsMap[terms] || terms;
|
||||
return supplierEnums.getPaymentTermsLabel(terms);
|
||||
};
|
||||
|
||||
const filteredSuppliers = mockSuppliers.filter(supplier => {
|
||||
const matchesSearch = supplier.name.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
||||
supplier.supplier_code.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
||||
supplier.email?.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
||||
supplier.contact_person?.toLowerCase().includes(searchTerm.toLowerCase());
|
||||
|
||||
const matchesTab = activeTab === 'all' || supplier.status === activeTab;
|
||||
|
||||
return matchesSearch && matchesTab;
|
||||
});
|
||||
// Filtering is now handled by the API query parameters
|
||||
const filteredSuppliers = suppliers;
|
||||
|
||||
const mockSupplierStats = {
|
||||
total: mockSuppliers.length,
|
||||
active: mockSuppliers.filter(s => s.status === SupplierStatus.ACTIVE).length,
|
||||
pendingApproval: mockSuppliers.filter(s => s.status === SupplierStatus.PENDING_APPROVAL).length,
|
||||
suspended: mockSuppliers.filter(s => s.status === SupplierStatus.SUSPENDED).length,
|
||||
totalSpend: mockSuppliers.reduce((sum, supplier) => sum + supplier.total_spend, 0),
|
||||
averageScore: mockSuppliers
|
||||
.filter(s => s.performance_score !== null)
|
||||
.reduce((sum, supplier, _, arr) => sum + (supplier.performance_score || 0) / arr.length, 0),
|
||||
totalOrders: mockSuppliers.reduce((sum, supplier) => sum + supplier.total_orders, 0),
|
||||
const supplierStats = statisticsData || {
|
||||
total_suppliers: 0,
|
||||
active_suppliers: 0,
|
||||
pending_suppliers: 0,
|
||||
avg_quality_rating: 0,
|
||||
avg_delivery_rating: 0,
|
||||
total_spend: 0
|
||||
};
|
||||
|
||||
const stats = [
|
||||
{
|
||||
title: 'Total Proveedores',
|
||||
value: mockSupplierStats.total,
|
||||
value: supplierStats.total_suppliers,
|
||||
variant: 'default' as const,
|
||||
icon: Building2,
|
||||
},
|
||||
{
|
||||
title: 'Activos',
|
||||
value: mockSupplierStats.active,
|
||||
value: supplierStats.active_suppliers,
|
||||
variant: 'success' as const,
|
||||
icon: CheckCircle,
|
||||
},
|
||||
{
|
||||
title: 'Pendientes',
|
||||
value: mockSupplierStats.pendingApproval,
|
||||
value: supplierStats.pending_suppliers,
|
||||
variant: 'warning' as const,
|
||||
icon: AlertCircle,
|
||||
},
|
||||
{
|
||||
title: 'Gasto Total',
|
||||
value: formatters.currency(mockSupplierStats.totalSpend),
|
||||
value: formatters.currency(supplierStats.total_spend),
|
||||
variant: 'info' as const,
|
||||
icon: DollarSign,
|
||||
},
|
||||
{
|
||||
title: 'Total Pedidos',
|
||||
value: mockSupplierStats.totalOrders,
|
||||
variant: 'default' as const,
|
||||
icon: Building2,
|
||||
},
|
||||
{
|
||||
title: 'Puntuación Media',
|
||||
value: mockSupplierStats.averageScore.toFixed(1),
|
||||
title: 'Calidad Media',
|
||||
value: supplierStats.avg_quality_rating?.toFixed(1) || '0.0',
|
||||
variant: 'success' as const,
|
||||
icon: CheckCircle,
|
||||
},
|
||||
{
|
||||
title: 'Entrega Media',
|
||||
value: supplierStats.avg_delivery_rating?.toFixed(1) || '0.0',
|
||||
variant: 'info' as const,
|
||||
icon: Building2,
|
||||
},
|
||||
];
|
||||
|
||||
// Loading state
|
||||
if (suppliersLoading || statisticsLoading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center min-h-[400px]">
|
||||
<div className="text-center">
|
||||
<Loader className="h-8 w-8 animate-spin mx-auto mb-4 text-[var(--primary)]" />
|
||||
<p className="text-[var(--text-secondary)]">Cargando proveedores...</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Error state
|
||||
if (suppliersError || statisticsError) {
|
||||
return (
|
||||
<div className="flex items-center justify-center min-h-[400px]">
|
||||
<div className="text-center">
|
||||
<AlertCircle className="h-8 w-8 mx-auto mb-4 text-red-500" />
|
||||
<p className="text-red-600 mb-2">Error al cargar los proveedores</p>
|
||||
<p className="text-[var(--text-secondary)] text-sm">
|
||||
{(suppliersError as any)?.message || (statisticsError as any)?.message || 'Error desconocido'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<PageHeader
|
||||
title="Gestión de Proveedores"
|
||||
description="Administra y supervisa todos los proveedores de la panadería"
|
||||
actions={[
|
||||
{
|
||||
id: "export",
|
||||
label: "Exportar",
|
||||
variant: "outline" as const,
|
||||
icon: Download,
|
||||
onClick: () => console.log('Export suppliers')
|
||||
},
|
||||
{
|
||||
id: "new",
|
||||
label: "Nuevo Proveedor",
|
||||
label: "Nuevo Proveedor",
|
||||
variant: "primary" as const,
|
||||
icon: Plus,
|
||||
onClick: () => setShowForm(true)
|
||||
onClick: () => {
|
||||
setSelectedSupplier({
|
||||
name: '',
|
||||
contact_person: '',
|
||||
email: '',
|
||||
phone: '',
|
||||
city: '',
|
||||
country: '',
|
||||
supplier_code: '',
|
||||
supplier_type: SupplierType.INGREDIENTS,
|
||||
payment_terms: PaymentTerms.NET_30,
|
||||
standard_lead_time: 3,
|
||||
minimum_order_amount: 0,
|
||||
credit_limit: 0,
|
||||
currency: 'EUR'
|
||||
});
|
||||
setIsCreating(true);
|
||||
setModalMode('edit');
|
||||
setShowForm(true);
|
||||
}
|
||||
}
|
||||
]}
|
||||
/>
|
||||
@@ -229,10 +202,6 @@ const SuppliersPage: React.FC = () => {
|
||||
className="w-full"
|
||||
/>
|
||||
</div>
|
||||
<Button variant="outline" onClick={() => console.log('Export filtered')}>
|
||||
<Download className="w-4 h-4 mr-2" />
|
||||
Exportar
|
||||
</Button>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
@@ -240,10 +209,7 @@ const SuppliersPage: React.FC = () => {
|
||||
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
|
||||
{filteredSuppliers.map((supplier) => {
|
||||
const statusConfig = getSupplierStatusConfig(supplier.status);
|
||||
const performanceNote = supplier.performance_score
|
||||
? `Puntuación: ${supplier.performance_score}/100`
|
||||
: 'Sin evaluación';
|
||||
|
||||
|
||||
return (
|
||||
<StatusCard
|
||||
key={supplier.id}
|
||||
@@ -251,35 +217,40 @@ const SuppliersPage: React.FC = () => {
|
||||
statusIndicator={statusConfig}
|
||||
title={supplier.name}
|
||||
subtitle={supplier.supplier_code}
|
||||
primaryValue={formatters.currency(supplier.total_spend)}
|
||||
primaryValueLabel={`${supplier.total_orders} pedidos`}
|
||||
primaryValue={supplier.city || 'Sin ubicación'}
|
||||
primaryValueLabel={getSupplierTypeText(supplier.supplier_type)}
|
||||
secondaryInfo={{
|
||||
label: 'Tipo',
|
||||
value: getSupplierTypeText(supplier.supplier_type)
|
||||
label: 'Condiciones',
|
||||
value: getPaymentTermsText(supplier.payment_terms)
|
||||
}}
|
||||
metadata={[
|
||||
supplier.contact_person || 'Sin contacto',
|
||||
supplier.email || 'Sin email',
|
||||
supplier.phone || 'Sin teléfono',
|
||||
performanceNote
|
||||
`Creado: ${new Date(supplier.created_at).toLocaleDateString('es-ES')}`
|
||||
]}
|
||||
actions={[
|
||||
// Primary action - View supplier details
|
||||
{
|
||||
label: 'Ver',
|
||||
label: 'Ver Detalles',
|
||||
icon: Eye,
|
||||
variant: 'outline',
|
||||
variant: 'primary',
|
||||
priority: 'primary',
|
||||
onClick: () => {
|
||||
setSelectedSupplier(supplier);
|
||||
setIsCreating(false);
|
||||
setModalMode('view');
|
||||
setShowForm(true);
|
||||
}
|
||||
},
|
||||
// Secondary action - Edit supplier
|
||||
{
|
||||
label: 'Editar',
|
||||
icon: Edit,
|
||||
variant: 'outline',
|
||||
priority: 'secondary',
|
||||
onClick: () => {
|
||||
setSelectedSupplier(supplier);
|
||||
setIsCreating(false);
|
||||
setModalMode('edit');
|
||||
setShowForm(true);
|
||||
}
|
||||
@@ -308,126 +279,200 @@ const SuppliersPage: React.FC = () => {
|
||||
)}
|
||||
|
||||
{/* Supplier Details Modal */}
|
||||
{showForm && selectedSupplier && (
|
||||
<StatusModal
|
||||
{showForm && selectedSupplier && (() => {
|
||||
const sections = [
|
||||
{
|
||||
title: 'Información de Contacto',
|
||||
icon: Users,
|
||||
fields: [
|
||||
{
|
||||
label: 'Nombre',
|
||||
value: selectedSupplier.name || '',
|
||||
type: 'text',
|
||||
highlight: true,
|
||||
editable: true,
|
||||
required: true,
|
||||
placeholder: 'Nombre del proveedor'
|
||||
},
|
||||
{
|
||||
label: 'Persona de Contacto',
|
||||
value: selectedSupplier.contact_person || '',
|
||||
type: 'text',
|
||||
editable: true,
|
||||
placeholder: 'Nombre del contacto'
|
||||
},
|
||||
{
|
||||
label: 'Email',
|
||||
value: selectedSupplier.email || '',
|
||||
type: 'email',
|
||||
editable: true,
|
||||
placeholder: 'email@ejemplo.com'
|
||||
},
|
||||
{
|
||||
label: 'Teléfono',
|
||||
value: selectedSupplier.phone || '',
|
||||
type: 'tel',
|
||||
editable: true,
|
||||
placeholder: '+34 123 456 789'
|
||||
},
|
||||
{
|
||||
label: 'Ciudad',
|
||||
value: selectedSupplier.city || '',
|
||||
type: 'text',
|
||||
editable: true,
|
||||
placeholder: 'Ciudad'
|
||||
},
|
||||
{
|
||||
label: 'País',
|
||||
value: selectedSupplier.country || '',
|
||||
type: 'text',
|
||||
editable: true,
|
||||
placeholder: 'País'
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
title: 'Información Comercial',
|
||||
icon: Building2,
|
||||
fields: [
|
||||
{
|
||||
label: 'Código de Proveedor',
|
||||
value: selectedSupplier.supplier_code || '',
|
||||
type: 'text',
|
||||
highlight: true,
|
||||
editable: true,
|
||||
placeholder: 'Código único'
|
||||
},
|
||||
{
|
||||
label: 'Tipo de Proveedor',
|
||||
value: selectedSupplier.supplier_type || SupplierType.INGREDIENTS,
|
||||
type: 'select',
|
||||
editable: true,
|
||||
options: supplierEnums.getSupplierTypeOptions()
|
||||
},
|
||||
{
|
||||
label: 'Condiciones de Pago',
|
||||
value: selectedSupplier.payment_terms || PaymentTerms.NET_30,
|
||||
type: 'select',
|
||||
editable: true,
|
||||
options: supplierEnums.getPaymentTermsOptions()
|
||||
},
|
||||
{
|
||||
label: 'Tiempo de Entrega (días)',
|
||||
value: selectedSupplier.standard_lead_time || 3,
|
||||
type: 'number',
|
||||
editable: true,
|
||||
placeholder: '3'
|
||||
},
|
||||
{
|
||||
label: 'Pedido Mínimo',
|
||||
value: selectedSupplier.minimum_order_amount || 0,
|
||||
type: 'currency',
|
||||
editable: true,
|
||||
placeholder: '0.00'
|
||||
},
|
||||
{
|
||||
label: 'Límite de Crédito',
|
||||
value: selectedSupplier.credit_limit || 0,
|
||||
type: 'currency',
|
||||
editable: true,
|
||||
placeholder: '0.00'
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
title: 'Rendimiento y Estadísticas',
|
||||
icon: DollarSign,
|
||||
fields: [
|
||||
{
|
||||
label: 'Moneda',
|
||||
value: selectedSupplier.currency || 'EUR',
|
||||
type: 'text',
|
||||
editable: true,
|
||||
placeholder: 'EUR'
|
||||
},
|
||||
{
|
||||
label: 'Fecha de Creación',
|
||||
value: selectedSupplier.created_at,
|
||||
type: 'datetime',
|
||||
highlight: true
|
||||
},
|
||||
{
|
||||
label: 'Última Actualización',
|
||||
value: selectedSupplier.updated_at,
|
||||
type: 'datetime'
|
||||
}
|
||||
]
|
||||
},
|
||||
...(selectedSupplier.notes ? [{
|
||||
title: 'Notas',
|
||||
fields: [
|
||||
{
|
||||
label: 'Observaciones',
|
||||
value: selectedSupplier.notes,
|
||||
type: 'list',
|
||||
span: 2 as const,
|
||||
editable: true,
|
||||
placeholder: 'Notas sobre el proveedor'
|
||||
}
|
||||
]
|
||||
}] : [])
|
||||
];
|
||||
|
||||
return (
|
||||
<StatusModal
|
||||
isOpen={showForm}
|
||||
onClose={() => {
|
||||
setShowForm(false);
|
||||
setSelectedSupplier(null);
|
||||
setModalMode('view');
|
||||
setIsCreating(false);
|
||||
}}
|
||||
mode={modalMode}
|
||||
onModeChange={setModalMode}
|
||||
title={selectedSupplier.name}
|
||||
subtitle={`Proveedor ${selectedSupplier.supplier_code}`}
|
||||
statusIndicator={getSupplierStatusConfig(selectedSupplier.status)}
|
||||
title={isCreating ? 'Nuevo Proveedor' : selectedSupplier.name || 'Proveedor'}
|
||||
subtitle={isCreating ? 'Crear nuevo proveedor' : `Proveedor ${selectedSupplier.supplier_code || ''}`}
|
||||
statusIndicator={isCreating ? undefined : getSupplierStatusConfig(selectedSupplier.status)}
|
||||
size="lg"
|
||||
sections={[
|
||||
{
|
||||
title: 'Información de Contacto',
|
||||
icon: Users,
|
||||
fields: [
|
||||
{
|
||||
label: 'Nombre',
|
||||
value: selectedSupplier.name,
|
||||
highlight: true
|
||||
},
|
||||
{
|
||||
label: 'Persona de Contacto',
|
||||
value: selectedSupplier.contact_person || 'No especificado'
|
||||
},
|
||||
{
|
||||
label: 'Email',
|
||||
value: selectedSupplier.email || 'No especificado'
|
||||
},
|
||||
{
|
||||
label: 'Teléfono',
|
||||
value: selectedSupplier.phone || 'No especificado'
|
||||
},
|
||||
{
|
||||
label: 'Ciudad',
|
||||
value: selectedSupplier.city || 'No especificado'
|
||||
},
|
||||
{
|
||||
label: 'País',
|
||||
value: selectedSupplier.country || 'No especificado'
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
title: 'Información Comercial',
|
||||
icon: Building2,
|
||||
fields: [
|
||||
{
|
||||
label: 'Código de Proveedor',
|
||||
value: selectedSupplier.supplier_code,
|
||||
highlight: true
|
||||
},
|
||||
{
|
||||
label: 'Tipo de Proveedor',
|
||||
value: getSupplierTypeText(selectedSupplier.supplier_type)
|
||||
},
|
||||
{
|
||||
label: 'Condiciones de Pago',
|
||||
value: getPaymentTermsText(selectedSupplier.payment_terms)
|
||||
},
|
||||
{
|
||||
label: 'Tiempo de Entrega',
|
||||
value: `${selectedSupplier.standard_lead_time} días`
|
||||
},
|
||||
{
|
||||
label: 'Pedido Mínimo',
|
||||
value: selectedSupplier.minimum_order_amount,
|
||||
type: 'currency'
|
||||
},
|
||||
{
|
||||
label: 'Límite de Crédito',
|
||||
value: selectedSupplier.credit_limit,
|
||||
type: 'currency'
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
title: 'Rendimiento y Estadísticas',
|
||||
icon: DollarSign,
|
||||
fields: [
|
||||
{
|
||||
label: 'Total de Pedidos',
|
||||
value: selectedSupplier.total_orders.toString()
|
||||
},
|
||||
{
|
||||
label: 'Gasto Total',
|
||||
value: selectedSupplier.total_spend,
|
||||
type: 'currency',
|
||||
highlight: true
|
||||
},
|
||||
{
|
||||
label: 'Puntuación de Rendimiento',
|
||||
value: selectedSupplier.performance_score ? `${selectedSupplier.performance_score}/100` : 'No evaluado'
|
||||
},
|
||||
{
|
||||
label: 'Último Pedido',
|
||||
value: selectedSupplier.last_order_date || 'Nunca',
|
||||
type: selectedSupplier.last_order_date ? 'datetime' : undefined
|
||||
}
|
||||
]
|
||||
},
|
||||
...(selectedSupplier.notes ? [{
|
||||
title: 'Notas',
|
||||
fields: [
|
||||
{
|
||||
label: 'Observaciones',
|
||||
value: selectedSupplier.notes,
|
||||
span: 2 as const
|
||||
}
|
||||
]
|
||||
}] : [])
|
||||
]}
|
||||
onEdit={() => {
|
||||
console.log('Editing supplier:', selectedSupplier.id);
|
||||
sections={sections}
|
||||
showDefaultActions={true}
|
||||
onSave={async () => {
|
||||
// TODO: Implement save functionality
|
||||
console.log('Saving supplier:', selectedSupplier);
|
||||
}}
|
||||
onFieldChange={(sectionIndex, fieldIndex, value) => {
|
||||
// Update the selectedSupplier state when fields change
|
||||
const newSupplier = { ...selectedSupplier };
|
||||
const section = sections[sectionIndex];
|
||||
const field = section.fields[fieldIndex];
|
||||
|
||||
// Map field labels to supplier properties
|
||||
const fieldMapping: { [key: string]: string } = {
|
||||
'Nombre': 'name',
|
||||
'Persona de Contacto': 'contact_person',
|
||||
'Email': 'email',
|
||||
'Teléfono': 'phone',
|
||||
'Ciudad': 'city',
|
||||
'País': 'country',
|
||||
'Código de Proveedor': 'supplier_code',
|
||||
'Tipo de Proveedor': 'supplier_type',
|
||||
'Condiciones de Pago': 'payment_terms',
|
||||
'Tiempo de Entrega (días)': 'standard_lead_time',
|
||||
'Pedido Mínimo': 'minimum_order_amount',
|
||||
'Límite de Crédito': 'credit_limit',
|
||||
'Moneda': 'currency',
|
||||
'Observaciones': 'notes'
|
||||
};
|
||||
|
||||
const propertyName = fieldMapping[field.label];
|
||||
if (propertyName) {
|
||||
newSupplier[propertyName] = value;
|
||||
setSelectedSupplier(newSupplier);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
);
|
||||
})()}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
166
frontend/src/utils/enumHelpers.ts
Normal file
166
frontend/src/utils/enumHelpers.ts
Normal file
@@ -0,0 +1,166 @@
|
||||
// frontend/src/utils/enumHelpers.ts
|
||||
/**
|
||||
* Utilities for working with enums and translations
|
||||
*/
|
||||
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import type { SelectOption } from '../components/ui/Select';
|
||||
import {
|
||||
SupplierType,
|
||||
SupplierStatus,
|
||||
PaymentTerms,
|
||||
PurchaseOrderStatus,
|
||||
DeliveryStatus,
|
||||
QualityRating,
|
||||
DeliveryRating,
|
||||
InvoiceStatus,
|
||||
type EnumOption
|
||||
} from '../api/types/suppliers';
|
||||
|
||||
/**
|
||||
* Generic function to convert enum to select options with i18n translations
|
||||
*/
|
||||
export function enumToSelectOptions<T extends Record<string, string | number>>(
|
||||
enumObject: T,
|
||||
translationKey: string,
|
||||
t: (key: string) => string,
|
||||
options?: {
|
||||
includeDescription?: boolean;
|
||||
descriptionKey?: string;
|
||||
sortAlphabetically?: boolean;
|
||||
}
|
||||
): SelectOption[] {
|
||||
const selectOptions = Object.entries(enumObject).map(([key, value]) => ({
|
||||
value,
|
||||
label: t(`${translationKey}.${value}`),
|
||||
...(options?.includeDescription && options?.descriptionKey && {
|
||||
description: t(`${options.descriptionKey}.${value}`)
|
||||
})
|
||||
}));
|
||||
|
||||
if (options?.sortAlphabetically) {
|
||||
selectOptions.sort((a, b) => a.label.localeCompare(b.label));
|
||||
}
|
||||
|
||||
return selectOptions;
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook for supplier enum utilities
|
||||
*/
|
||||
export function useSupplierEnums() {
|
||||
const { t } = useTranslation('suppliers');
|
||||
|
||||
return {
|
||||
// Supplier Type
|
||||
getSupplierTypeOptions: (): SelectOption[] =>
|
||||
enumToSelectOptions(SupplierType, 'types', t, {
|
||||
includeDescription: true,
|
||||
descriptionKey: 'descriptions'
|
||||
}),
|
||||
|
||||
getSupplierTypeLabel: (type: SupplierType): string => {
|
||||
if (!type) return 'Tipo no definido';
|
||||
return t(`types.${type}`);
|
||||
},
|
||||
|
||||
// Supplier Status
|
||||
getSupplierStatusOptions: (): SelectOption[] =>
|
||||
enumToSelectOptions(SupplierStatus, 'status', t),
|
||||
|
||||
getSupplierStatusLabel: (status: SupplierStatus): string => {
|
||||
if (!status) return 'Estado no definido';
|
||||
return t(`status.${status}`);
|
||||
},
|
||||
|
||||
// Payment Terms
|
||||
getPaymentTermsOptions: (): SelectOption[] =>
|
||||
enumToSelectOptions(PaymentTerms, 'payment_terms', t, {
|
||||
includeDescription: true,
|
||||
descriptionKey: 'descriptions'
|
||||
}),
|
||||
|
||||
getPaymentTermsLabel: (terms: PaymentTerms): string => {
|
||||
if (!terms) return 'Sin términos definidos';
|
||||
return t(`payment_terms.${terms}`);
|
||||
},
|
||||
|
||||
// Purchase Order Status
|
||||
getPurchaseOrderStatusOptions: (): SelectOption[] =>
|
||||
enumToSelectOptions(PurchaseOrderStatus, 'purchase_order_status', t),
|
||||
|
||||
getPurchaseOrderStatusLabel: (status: PurchaseOrderStatus): string => {
|
||||
if (!status) return 'Estado no definido';
|
||||
return t(`purchase_order_status.${status}`);
|
||||
},
|
||||
|
||||
// Delivery Status
|
||||
getDeliveryStatusOptions: (): SelectOption[] =>
|
||||
enumToSelectOptions(DeliveryStatus, 'delivery_status', t),
|
||||
|
||||
getDeliveryStatusLabel: (status: DeliveryStatus): string => {
|
||||
if (!status) return 'Estado no definido';
|
||||
return t(`delivery_status.${status}`);
|
||||
},
|
||||
|
||||
// Quality Rating
|
||||
getQualityRatingOptions: (): SelectOption[] =>
|
||||
enumToSelectOptions(QualityRating, 'quality_rating', t, {
|
||||
includeDescription: true,
|
||||
descriptionKey: 'descriptions'
|
||||
}),
|
||||
|
||||
getQualityRatingLabel: (rating: QualityRating): string => {
|
||||
if (rating === undefined || rating === null) return 'Sin calificación';
|
||||
return t(`quality_rating.${rating}`);
|
||||
},
|
||||
|
||||
// Delivery Rating
|
||||
getDeliveryRatingOptions: (): SelectOption[] =>
|
||||
enumToSelectOptions(DeliveryRating, 'delivery_rating', t, {
|
||||
includeDescription: true,
|
||||
descriptionKey: 'descriptions'
|
||||
}),
|
||||
|
||||
getDeliveryRatingLabel: (rating: DeliveryRating): string => {
|
||||
if (rating === undefined || rating === null) return 'Sin calificación';
|
||||
return t(`delivery_rating.${rating}`);
|
||||
},
|
||||
|
||||
// Invoice Status
|
||||
getInvoiceStatusOptions: (): SelectOption[] =>
|
||||
enumToSelectOptions(InvoiceStatus, 'invoice_status', t),
|
||||
|
||||
getInvoiceStatusLabel: (status: InvoiceStatus): string => {
|
||||
if (!status) return 'Estado no definido';
|
||||
return t(`invoice_status.${status}`);
|
||||
},
|
||||
|
||||
// Field Labels
|
||||
getFieldLabel: (field: string): string =>
|
||||
t(`labels.${field}`),
|
||||
|
||||
getFieldDescription: (field: string): string =>
|
||||
t(`descriptions.${field}`)
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Utility to get enum value from select option value
|
||||
*/
|
||||
export function getEnumFromValue<T>(
|
||||
enumObject: Record<string, T>,
|
||||
value: string | number
|
||||
): T | undefined {
|
||||
return Object.values(enumObject).find(enumValue => enumValue === value);
|
||||
}
|
||||
|
||||
/**
|
||||
* Utility to validate enum value
|
||||
*/
|
||||
export function isValidEnumValue<T>(
|
||||
enumObject: Record<string, T>,
|
||||
value: unknown
|
||||
): value is T {
|
||||
return Object.values(enumObject).includes(value as T);
|
||||
}
|
||||
109
frontend/src/utils/foodSafetyEnumHelpers.ts
Normal file
109
frontend/src/utils/foodSafetyEnumHelpers.ts
Normal file
@@ -0,0 +1,109 @@
|
||||
// frontend/src/utils/foodSafetyEnumHelpers.ts
|
||||
/**
|
||||
* Utilities for working with food safety enums and translations
|
||||
*/
|
||||
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import type { SelectOption } from '../components/ui/Select';
|
||||
import {
|
||||
FoodSafetyStandard,
|
||||
ComplianceStatus,
|
||||
FoodSafetyAlertType,
|
||||
type EnumOption
|
||||
} from '../api/types/foodSafety';
|
||||
|
||||
/**
|
||||
* Generic function to convert enum to select options with i18n translations
|
||||
*/
|
||||
export function enumToSelectOptions<T extends Record<string, string | number>>(
|
||||
enumObject: T,
|
||||
translationKey: string,
|
||||
t: (key: string) => string,
|
||||
options?: {
|
||||
includeDescription?: boolean;
|
||||
descriptionKey?: string;
|
||||
sortAlphabetically?: boolean;
|
||||
}
|
||||
): SelectOption[] {
|
||||
const selectOptions = Object.entries(enumObject).map(([, value]) => ({
|
||||
value,
|
||||
label: t(`${translationKey}.${value}`),
|
||||
...(options?.includeDescription && options?.descriptionKey && {
|
||||
description: t(`${options.descriptionKey}.${value}`)
|
||||
})
|
||||
}));
|
||||
|
||||
if (options?.sortAlphabetically) {
|
||||
selectOptions.sort((a, b) => a.label.localeCompare(b.label));
|
||||
}
|
||||
|
||||
return selectOptions;
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook for food safety enum utilities
|
||||
*/
|
||||
export function useFoodSafetyEnums() {
|
||||
const { t } = useTranslation('foodSafety');
|
||||
|
||||
return {
|
||||
// Food Safety Standard
|
||||
getFoodSafetyStandardOptions: (): SelectOption[] =>
|
||||
enumToSelectOptions(FoodSafetyStandard, 'enums.food_safety_standard', t, {
|
||||
includeDescription: true,
|
||||
descriptionKey: 'descriptions',
|
||||
sortAlphabetically: true
|
||||
}),
|
||||
|
||||
getFoodSafetyStandardLabel: (standard: FoodSafetyStandard): string =>
|
||||
t(`enums.food_safety_standard.${standard}`),
|
||||
|
||||
// Compliance Status
|
||||
getComplianceStatusOptions: (): SelectOption[] =>
|
||||
enumToSelectOptions(ComplianceStatus, 'enums.compliance_status', t, {
|
||||
includeDescription: true,
|
||||
descriptionKey: 'descriptions'
|
||||
}),
|
||||
|
||||
getComplianceStatusLabel: (status: ComplianceStatus): string =>
|
||||
t(`enums.compliance_status.${status}`),
|
||||
|
||||
// Food Safety Alert Type
|
||||
getFoodSafetyAlertTypeOptions: (): SelectOption[] =>
|
||||
enumToSelectOptions(FoodSafetyAlertType, 'enums.food_safety_alert_type', t, {
|
||||
includeDescription: true,
|
||||
descriptionKey: 'descriptions',
|
||||
sortAlphabetically: true
|
||||
}),
|
||||
|
||||
getFoodSafetyAlertTypeLabel: (type: FoodSafetyAlertType): string =>
|
||||
t(`enums.food_safety_alert_type.${type}`),
|
||||
|
||||
// Field Labels
|
||||
getFieldLabel: (field: string): string =>
|
||||
t(`labels.${field}`),
|
||||
|
||||
getFieldDescription: (field: string): string =>
|
||||
t(`descriptions.${field}`)
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Utility to get enum value from select option value
|
||||
*/
|
||||
export function getEnumFromValue<T>(
|
||||
enumObject: Record<string, T>,
|
||||
value: string | number
|
||||
): T | undefined {
|
||||
return Object.values(enumObject).find(enumValue => enumValue === value);
|
||||
}
|
||||
|
||||
/**
|
||||
* Utility to validate enum value
|
||||
*/
|
||||
export function isValidEnumValue<T>(
|
||||
enumObject: Record<string, T>,
|
||||
value: unknown
|
||||
): value is T {
|
||||
return Object.values(enumObject).includes(value as T);
|
||||
}
|
||||
140
frontend/src/utils/inventoryEnumHelpers.ts
Normal file
140
frontend/src/utils/inventoryEnumHelpers.ts
Normal file
@@ -0,0 +1,140 @@
|
||||
// frontend/src/utils/inventoryEnumHelpers.ts
|
||||
/**
|
||||
* Utilities for working with inventory enums and translations
|
||||
*/
|
||||
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import type { SelectOption } from '../components/ui/Select';
|
||||
import {
|
||||
ProductType,
|
||||
ProductionStage,
|
||||
UnitOfMeasure,
|
||||
IngredientCategory,
|
||||
ProductCategory,
|
||||
StockMovementType,
|
||||
type EnumOption
|
||||
} from '../api/types/inventory';
|
||||
|
||||
/**
|
||||
* Generic function to convert enum to select options with i18n translations
|
||||
*/
|
||||
export function enumToSelectOptions<T extends Record<string, string | number>>(
|
||||
enumObject: T,
|
||||
translationKey: string,
|
||||
t: (key: string) => string,
|
||||
options?: {
|
||||
includeDescription?: boolean;
|
||||
descriptionKey?: string;
|
||||
sortAlphabetically?: boolean;
|
||||
}
|
||||
): SelectOption[] {
|
||||
const selectOptions = Object.entries(enumObject).map(([, value]) => ({
|
||||
value,
|
||||
label: t(`${translationKey}.${value}`),
|
||||
...(options?.includeDescription && options?.descriptionKey && {
|
||||
description: t(`${options.descriptionKey}.${value}`)
|
||||
})
|
||||
}));
|
||||
|
||||
if (options?.sortAlphabetically) {
|
||||
selectOptions.sort((a, b) => a.label.localeCompare(b.label));
|
||||
}
|
||||
|
||||
return selectOptions;
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook for inventory enum utilities
|
||||
*/
|
||||
export function useInventoryEnums() {
|
||||
const { t } = useTranslation('inventory');
|
||||
|
||||
return {
|
||||
// Product Type
|
||||
getProductTypeOptions: (): SelectOption[] =>
|
||||
enumToSelectOptions(ProductType, 'enums.product_type', t, {
|
||||
includeDescription: true,
|
||||
descriptionKey: 'descriptions'
|
||||
}),
|
||||
|
||||
getProductTypeLabel: (type: ProductType): string =>
|
||||
t(`enums.product_type.${type}`),
|
||||
|
||||
// Production Stage
|
||||
getProductionStageOptions: (): SelectOption[] =>
|
||||
enumToSelectOptions(ProductionStage, 'enums.production_stage', t, {
|
||||
includeDescription: true,
|
||||
descriptionKey: 'descriptions'
|
||||
}),
|
||||
|
||||
getProductionStageLabel: (stage: ProductionStage): string =>
|
||||
t(`enums.production_stage.${stage}`),
|
||||
|
||||
// Unit of Measure
|
||||
getUnitOfMeasureOptions: (): SelectOption[] =>
|
||||
enumToSelectOptions(UnitOfMeasure, 'enums.unit_of_measure', t, {
|
||||
includeDescription: true,
|
||||
descriptionKey: 'descriptions'
|
||||
}),
|
||||
|
||||
getUnitOfMeasureLabel: (unit: UnitOfMeasure): string =>
|
||||
t(`enums.unit_of_measure.${unit}`),
|
||||
|
||||
// Ingredient Category
|
||||
getIngredientCategoryOptions: (): SelectOption[] =>
|
||||
enumToSelectOptions(IngredientCategory, 'enums.ingredient_category', t, {
|
||||
includeDescription: true,
|
||||
descriptionKey: 'descriptions',
|
||||
sortAlphabetically: true
|
||||
}),
|
||||
|
||||
getIngredientCategoryLabel: (category: IngredientCategory): string =>
|
||||
t(`enums.ingredient_category.${category}`),
|
||||
|
||||
// Product Category
|
||||
getProductCategoryOptions: (): SelectOption[] =>
|
||||
enumToSelectOptions(ProductCategory, 'enums.product_category', t, {
|
||||
sortAlphabetically: true
|
||||
}),
|
||||
|
||||
getProductCategoryLabel: (category: ProductCategory): string =>
|
||||
t(`enums.product_category.${category}`),
|
||||
|
||||
// Stock Movement Type
|
||||
getStockMovementTypeOptions: (): SelectOption[] =>
|
||||
enumToSelectOptions(StockMovementType, 'enums.stock_movement_type', t, {
|
||||
includeDescription: true,
|
||||
descriptionKey: 'descriptions'
|
||||
}),
|
||||
|
||||
getStockMovementTypeLabel: (type: StockMovementType): string =>
|
||||
t(`enums.stock_movement_type.${type}`),
|
||||
|
||||
// Field Labels
|
||||
getFieldLabel: (field: string): string =>
|
||||
t(`labels.${field}`),
|
||||
|
||||
getFieldDescription: (field: string): string =>
|
||||
t(`descriptions.${field}`)
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Utility to get enum value from select option value
|
||||
*/
|
||||
export function getEnumFromValue<T>(
|
||||
enumObject: Record<string, T>,
|
||||
value: string | number
|
||||
): T | undefined {
|
||||
return Object.values(enumObject).find(enumValue => enumValue === value);
|
||||
}
|
||||
|
||||
/**
|
||||
* Utility to validate enum value
|
||||
*/
|
||||
export function isValidEnumValue<T>(
|
||||
enumObject: Record<string, T>,
|
||||
value: unknown
|
||||
): value is T {
|
||||
return Object.values(enumObject).includes(value as T);
|
||||
}
|
||||
Reference in New Issue
Block a user