Refactor components and modals
This commit is contained in:
@@ -4,8 +4,8 @@ import { ReactQueryDevtools } from '@tanstack/react-query-devtools';
|
||||
import { BrowserRouter } from 'react-router-dom';
|
||||
import { I18nextProvider } from 'react-i18next';
|
||||
import { Toaster } from 'react-hot-toast';
|
||||
import { ErrorBoundary } from './components/shared/ErrorBoundary';
|
||||
import { LoadingSpinner } from './components/shared/LoadingSpinner';
|
||||
import { ErrorBoundary } from './components/layout/ErrorBoundary';
|
||||
import { LoadingSpinner } from './components/ui/LoadingSpinner';
|
||||
import { AppRouter } from './router/AppRouter';
|
||||
import { ThemeProvider } from './contexts/ThemeContext';
|
||||
import { AuthProvider } from './contexts/AuthContext';
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import React, { useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Building2, Settings, Wrench, Thermometer, Activity, CheckCircle, AlertTriangle, Eye, Edit } from 'lucide-react';
|
||||
import { StatusModal, StatusModalSection } from '../../ui/StatusModal/StatusModal';
|
||||
import { Equipment } from '../../../types/equipment';
|
||||
import { EditViewModal, StatusModalSection } from '../../ui/EditViewModal/EditViewModal';
|
||||
import { Equipment } from '../../../api/types/equipment';
|
||||
|
||||
interface EquipmentModalProps {
|
||||
isOpen: boolean;
|
||||
@@ -337,7 +337,7 @@ export const EquipmentModal: React.FC<EquipmentModalProps> = ({
|
||||
};
|
||||
|
||||
return (
|
||||
<StatusModal
|
||||
<EditViewModal
|
||||
isOpen={isOpen}
|
||||
onClose={() => {
|
||||
onClose();
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import React from 'react';
|
||||
import { StatusModal, StatusModalSection } from '@/components/ui/StatusModal/StatusModal';
|
||||
import { EditViewModal, StatusModalSection } from '@/components/ui/EditViewModal/EditViewModal';
|
||||
import { Badge } from '@/components/ui/Badge';
|
||||
import { Tooltip } from '@/components/ui/Tooltip';
|
||||
import { TrainedModelResponse, TrainingMetrics } from '@/types/training';
|
||||
@@ -94,7 +94,7 @@ const ModelDetailsModal: React.FC<ModelDetailsModalProps> = ({
|
||||
// Early return if model is not provided
|
||||
if (!model) {
|
||||
return (
|
||||
<StatusModal
|
||||
<EditViewModal
|
||||
isOpen={isOpen}
|
||||
onClose={onClose}
|
||||
mode="view"
|
||||
@@ -387,7 +387,7 @@ const ModelDetailsModal: React.FC<ModelDetailsModalProps> = ({
|
||||
}
|
||||
|
||||
return (
|
||||
<StatusModal
|
||||
<EditViewModal
|
||||
isOpen={isOpen}
|
||||
onClose={onClose}
|
||||
mode="view"
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
import React, { useState } from 'react';
|
||||
import { Plus, Package, Euro, Calendar, FileText, Thermometer } from 'lucide-react';
|
||||
import { StatusModal } from '../../ui/StatusModal/StatusModal';
|
||||
import { EditViewModal } from '../../ui/EditViewModal/EditViewModal';
|
||||
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 { useTranslation } from 'react-i18next';
|
||||
import { statusColors } from '../../../styles/colors';
|
||||
|
||||
interface AddStockModalProps {
|
||||
@@ -60,8 +60,14 @@ export const AddStockModal: React.FC<AddStockModalProps> = ({
|
||||
});
|
||||
const suppliers = (suppliersData || []).filter(supplier => supplier.status === 'active');
|
||||
|
||||
// Get inventory enum helpers
|
||||
const inventoryEnums = useInventoryEnums();
|
||||
// Get translations
|
||||
const { t } = useTranslation(['inventory', 'common']);
|
||||
|
||||
// Get production stage options using direct i18n
|
||||
const productionStageOptions = Object.values(ProductionStage).map(value => ({
|
||||
value,
|
||||
label: t(`inventory:production_stage.${value}`)
|
||||
}));
|
||||
|
||||
// Create supplier options for select
|
||||
const supplierOptions = [
|
||||
@@ -251,7 +257,7 @@ export const AddStockModal: React.FC<AddStockModalProps> = ({
|
||||
value: formData.production_stage || ProductionStage.RAW_INGREDIENT,
|
||||
type: 'select' as const,
|
||||
editable: true,
|
||||
options: inventoryEnums.getProductionStageOptions()
|
||||
options: productionStageOptions
|
||||
}
|
||||
]
|
||||
},
|
||||
@@ -382,7 +388,7 @@ export const AddStockModal: React.FC<AddStockModalProps> = ({
|
||||
];
|
||||
|
||||
return (
|
||||
<StatusModal
|
||||
<EditViewModal
|
||||
isOpen={isOpen}
|
||||
onClose={onClose}
|
||||
mode={mode}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import React, { useState } from 'react';
|
||||
import { Package, AlertTriangle, Clock, Archive, Thermometer, Plus, Edit, Trash2, CheckCircle, X, Save } from 'lucide-react';
|
||||
import { StatusModal } from '../../ui/StatusModal/StatusModal';
|
||||
import { EditViewModal } from '../../ui/EditViewModal/EditViewModal';
|
||||
import { IngredientResponse, StockResponse, StockUpdate } from '../../../api/types/inventory';
|
||||
import { formatters } from '../../ui/Stats/StatsPresets';
|
||||
import { statusColors } from '../../../styles/colors';
|
||||
@@ -431,7 +431,7 @@ export const BatchModal: React.FC<BatchModalProps> = ({
|
||||
}
|
||||
|
||||
return (
|
||||
<StatusModal
|
||||
<EditViewModal
|
||||
isOpen={isOpen}
|
||||
onClose={onClose}
|
||||
mode="view"
|
||||
|
||||
@@ -1,9 +1,8 @@
|
||||
import React, { useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Plus, Package, Calculator, Settings } from 'lucide-react';
|
||||
import { StatusModal } from '../../ui/StatusModal/StatusModal';
|
||||
import { AddModal } from '../../ui/AddModal/AddModal';
|
||||
import { IngredientCreate, UnitOfMeasure, IngredientCategory, ProductCategory } from '../../../api/types/inventory';
|
||||
import { useInventoryEnums } from '../../../utils/inventoryEnumHelpers';
|
||||
import { statusColors } from '../../../styles/colors';
|
||||
|
||||
interface CreateIngredientModalProps {
|
||||
@@ -21,138 +20,58 @@ export const CreateIngredientModal: React.FC<CreateIngredientModalProps> = ({
|
||||
onClose,
|
||||
onCreateIngredient
|
||||
}) => {
|
||||
const { t } = useTranslation(['inventory']);
|
||||
const [formData, setFormData] = useState<IngredientCreate>({
|
||||
name: '',
|
||||
description: '',
|
||||
category: '',
|
||||
unit_of_measure: 'kg',
|
||||
low_stock_threshold: 10,
|
||||
reorder_point: 20,
|
||||
max_stock_level: 100,
|
||||
is_seasonal: false,
|
||||
average_cost: 0,
|
||||
notes: ''
|
||||
});
|
||||
|
||||
const { t } = useTranslation(['inventory', 'common']);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [mode, setMode] = useState<'overview' | 'edit'>('edit');
|
||||
|
||||
// Get enum options using helpers
|
||||
const inventoryEnums = useInventoryEnums();
|
||||
// Get enum options using direct i18n implementation
|
||||
const ingredientCategoryOptions = Object.values(IngredientCategory).map(value => ({
|
||||
value,
|
||||
label: t(`inventory:ingredient_category.${value}`)
|
||||
})).sort((a, b) => a.label.localeCompare(b.label));
|
||||
|
||||
const productCategoryOptions = Object.values(ProductCategory).map(value => ({
|
||||
value,
|
||||
label: t(`inventory:product_category.${value}`)
|
||||
}));
|
||||
|
||||
// Combine ingredient and product categories
|
||||
const categoryOptions = [
|
||||
...inventoryEnums.getIngredientCategoryOptions(),
|
||||
...inventoryEnums.getProductCategoryOptions()
|
||||
...ingredientCategoryOptions,
|
||||
...productCategoryOptions
|
||||
];
|
||||
|
||||
const unitOptions = inventoryEnums.getUnitOfMeasureOptions();
|
||||
const unitOptions = Object.values(UnitOfMeasure).map(value => ({
|
||||
value,
|
||||
label: t(`inventory:unit_of_measure.${value}`)
|
||||
}));
|
||||
|
||||
const handleFieldChange = (sectionIndex: number, fieldIndex: number, value: string | number | boolean) => {
|
||||
// Map field positions to form data fields
|
||||
const fieldMappings = [
|
||||
// Basic Information section
|
||||
['name', 'description', 'category', 'unit_of_measure'],
|
||||
// Cost and Quantities section
|
||||
['average_cost', 'low_stock_threshold', 'reorder_point', 'max_stock_level'],
|
||||
// Additional Information section
|
||||
['notes']
|
||||
];
|
||||
|
||||
const fieldName = fieldMappings[sectionIndex]?.[fieldIndex] as keyof IngredientCreate;
|
||||
if (fieldName) {
|
||||
setFormData(prev => ({
|
||||
...prev,
|
||||
[fieldName]: value
|
||||
}));
|
||||
}
|
||||
};
|
||||
|
||||
const handleSave = async () => {
|
||||
// Validation
|
||||
if (!formData.name?.trim()) {
|
||||
alert(t('inventory:validation.name_required', 'El nombre es requerido'));
|
||||
return;
|
||||
}
|
||||
|
||||
if (!formData.category) {
|
||||
alert(t('inventory:validation.category_required', 'La categoría es requerida'));
|
||||
return;
|
||||
}
|
||||
|
||||
if (!formData.unit_of_measure) {
|
||||
alert(t('inventory:validation.unit_required', 'La unidad de medida es requerida'));
|
||||
return;
|
||||
}
|
||||
|
||||
if (!formData.low_stock_threshold || formData.low_stock_threshold < 0) {
|
||||
alert(t('inventory:validation.min_greater_than_zero', 'El umbral de stock bajo debe ser un número positivo'));
|
||||
return;
|
||||
}
|
||||
|
||||
if (!formData.reorder_point || formData.reorder_point < 0) {
|
||||
alert(t('inventory:validation.min_greater_than_zero', 'El punto de reorden debe ser un número positivo'));
|
||||
return;
|
||||
}
|
||||
|
||||
if (formData.reorder_point <= formData.low_stock_threshold) {
|
||||
alert(t('inventory:validation.max_greater_than_min', 'El punto de reorden debe ser mayor que el umbral de stock bajo'));
|
||||
return;
|
||||
}
|
||||
const handleSave = async (formData: Record<string, any>) => {
|
||||
// Transform form data to IngredientCreate format
|
||||
const ingredientData: IngredientCreate = {
|
||||
name: formData.name,
|
||||
description: formData.description || '',
|
||||
category: formData.category,
|
||||
unit_of_measure: formData.unit_of_measure,
|
||||
low_stock_threshold: Number(formData.low_stock_threshold),
|
||||
reorder_point: Number(formData.reorder_point),
|
||||
max_stock_level: Number(formData.max_stock_level),
|
||||
is_seasonal: false,
|
||||
average_cost: Number(formData.average_cost) || 0,
|
||||
notes: formData.notes || ''
|
||||
};
|
||||
|
||||
setLoading(true);
|
||||
try {
|
||||
if (onCreateIngredient) {
|
||||
await onCreateIngredient(formData);
|
||||
await onCreateIngredient(ingredientData);
|
||||
}
|
||||
|
||||
// Reset form
|
||||
setFormData({
|
||||
name: '',
|
||||
description: '',
|
||||
category: '',
|
||||
unit_of_measure: 'kg',
|
||||
low_stock_threshold: 10,
|
||||
reorder_point: 20,
|
||||
max_stock_level: 100,
|
||||
shelf_life_days: undefined,
|
||||
requires_refrigeration: false,
|
||||
requires_freezing: false,
|
||||
is_seasonal: false,
|
||||
average_cost: 0,
|
||||
notes: ''
|
||||
});
|
||||
|
||||
onClose();
|
||||
} catch (error) {
|
||||
console.error('Error creating ingredient:', error);
|
||||
alert('Error al crear el artículo. Por favor, intenta de nuevo.');
|
||||
throw error; // Let AddModal handle error display
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleCancel = () => {
|
||||
// Reset form to initial values
|
||||
setFormData({
|
||||
name: '',
|
||||
description: '',
|
||||
category: '',
|
||||
unit_of_measure: 'kg',
|
||||
low_stock_threshold: 10,
|
||||
reorder_point: 20,
|
||||
max_stock_level: 100,
|
||||
shelf_life_days: undefined,
|
||||
requires_refrigeration: false,
|
||||
requires_freezing: false,
|
||||
is_seasonal: false,
|
||||
average_cost: 0,
|
||||
notes: ''
|
||||
});
|
||||
onClose();
|
||||
};
|
||||
|
||||
const statusConfig = {
|
||||
color: statusColors.inProgress.primary,
|
||||
text: t('inventory:actions.add_item', 'Nuevo Artículo'),
|
||||
@@ -168,34 +87,36 @@ export const CreateIngredientModal: React.FC<CreateIngredientModalProps> = ({
|
||||
fields: [
|
||||
{
|
||||
label: t('inventory:fields.name', 'Nombre'),
|
||||
value: formData.name,
|
||||
name: 'name',
|
||||
type: 'text' as const,
|
||||
editable: true,
|
||||
required: true,
|
||||
placeholder: 'Ej: Harina de trigo 000'
|
||||
placeholder: 'Ej: Harina de trigo 000',
|
||||
validation: (value: string | number) => {
|
||||
const str = String(value).trim();
|
||||
return str.length < 2 ? 'El nombre debe tener al menos 2 caracteres' : null;
|
||||
}
|
||||
},
|
||||
{
|
||||
label: t('inventory:fields.description', 'Descripción'),
|
||||
value: formData.description || '',
|
||||
name: 'description',
|
||||
type: 'text' as const,
|
||||
editable: true,
|
||||
placeholder: 'Descripción opcional del artículo'
|
||||
},
|
||||
{
|
||||
label: 'Categoría',
|
||||
value: formData.category,
|
||||
name: 'category',
|
||||
type: 'select' as const,
|
||||
editable: true,
|
||||
required: true,
|
||||
options: categoryOptions
|
||||
options: categoryOptions,
|
||||
placeholder: 'Seleccionar categoría...'
|
||||
},
|
||||
{
|
||||
label: 'Unidad de Medida',
|
||||
value: formData.unit_of_measure,
|
||||
name: 'unit_of_measure',
|
||||
type: 'select' as const,
|
||||
editable: true,
|
||||
required: true,
|
||||
options: unitOptions
|
||||
options: unitOptions,
|
||||
defaultValue: 'kg'
|
||||
}
|
||||
]
|
||||
},
|
||||
@@ -205,33 +126,49 @@ export const CreateIngredientModal: React.FC<CreateIngredientModalProps> = ({
|
||||
fields: [
|
||||
{
|
||||
label: 'Costo Promedio',
|
||||
value: formData.average_cost || 0,
|
||||
name: 'average_cost',
|
||||
type: 'currency' as const,
|
||||
editable: true,
|
||||
placeholder: '0.00'
|
||||
placeholder: '0.00',
|
||||
defaultValue: 0,
|
||||
validation: (value: string | number) => {
|
||||
const num = Number(value);
|
||||
return num < 0 ? 'El costo no puede ser negativo' : null;
|
||||
}
|
||||
},
|
||||
{
|
||||
label: 'Umbral Stock Bajo',
|
||||
value: formData.low_stock_threshold,
|
||||
name: 'low_stock_threshold',
|
||||
type: 'number' as const,
|
||||
editable: true,
|
||||
required: true,
|
||||
placeholder: '10'
|
||||
placeholder: '10',
|
||||
defaultValue: 10,
|
||||
validation: (value: string | number) => {
|
||||
const num = Number(value);
|
||||
return num < 0 ? 'El umbral debe ser un número positivo' : null;
|
||||
}
|
||||
},
|
||||
{
|
||||
label: 'Punto de Reorden',
|
||||
value: formData.reorder_point,
|
||||
name: 'reorder_point',
|
||||
type: 'number' as const,
|
||||
editable: true,
|
||||
required: true,
|
||||
placeholder: '20'
|
||||
placeholder: '20',
|
||||
defaultValue: 20,
|
||||
validation: (value: string | number) => {
|
||||
const num = Number(value);
|
||||
return num < 0 ? 'El punto de reorden debe ser un número positivo' : null;
|
||||
}
|
||||
},
|
||||
{
|
||||
label: 'Stock Máximo',
|
||||
value: formData.max_stock_level || 0,
|
||||
name: 'max_stock_level',
|
||||
type: 'number' as const,
|
||||
editable: true,
|
||||
placeholder: '100'
|
||||
placeholder: '100',
|
||||
defaultValue: 100,
|
||||
validation: (value: string | number) => {
|
||||
const num = Number(value);
|
||||
return num < 0 ? 'El stock máximo debe ser un número positivo' : null;
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
@@ -241,30 +178,26 @@ export const CreateIngredientModal: React.FC<CreateIngredientModalProps> = ({
|
||||
fields: [
|
||||
{
|
||||
label: 'Notas',
|
||||
value: formData.notes || '',
|
||||
type: 'text' as const,
|
||||
editable: true,
|
||||
placeholder: 'Notas adicionales'
|
||||
name: 'notes',
|
||||
type: 'textarea' as const,
|
||||
placeholder: 'Notas adicionales',
|
||||
span: 2 // Full width
|
||||
}
|
||||
]
|
||||
}
|
||||
];
|
||||
|
||||
return (
|
||||
<StatusModal
|
||||
<AddModal
|
||||
isOpen={isOpen}
|
||||
onClose={onClose}
|
||||
mode={mode}
|
||||
title="Crear Nuevo Artículo"
|
||||
subtitle="Agregar un nuevo artículo al inventario"
|
||||
statusIndicator={statusConfig}
|
||||
sections={sections}
|
||||
size="lg"
|
||||
loading={loading}
|
||||
showDefaultActions={true}
|
||||
onSave={handleSave}
|
||||
onCancel={handleCancel}
|
||||
onFieldChange={handleFieldChange}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import React, { useState } from 'react';
|
||||
import { Package, AlertTriangle, CheckCircle, Clock, Euro, Edit, Info, Thermometer, Calendar, Tag, Save, X, TrendingUp } from 'lucide-react';
|
||||
import { StatusModal } from '../../ui/StatusModal/StatusModal';
|
||||
import { EditViewModal } from '../../ui/EditViewModal/EditViewModal';
|
||||
import { IngredientResponse } from '../../../api/types/inventory';
|
||||
import { formatters } from '../../ui/Stats/StatsPresets';
|
||||
import { statusColors } from '../../../styles/colors';
|
||||
@@ -284,7 +284,7 @@ export const ShowInfoModal: React.FC<ShowInfoModalProps> = ({
|
||||
};
|
||||
|
||||
return (
|
||||
<StatusModal
|
||||
<EditViewModal
|
||||
isOpen={isOpen}
|
||||
onClose={onClose}
|
||||
mode={isEditing ? "edit" : "view"}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import React from 'react';
|
||||
import { Clock, TrendingDown, Package, AlertCircle, RotateCcw, X } from 'lucide-react';
|
||||
import { StatusModal } from '../../ui/StatusModal/StatusModal';
|
||||
import { EditViewModal } from '../../ui/EditViewModal/EditViewModal';
|
||||
import { IngredientResponse, StockMovementResponse } from '../../../api/types/inventory';
|
||||
import { formatters } from '../../ui/Stats/StatsPresets';
|
||||
import { statusColors } from '../../../styles/colors';
|
||||
@@ -214,7 +214,7 @@ export const StockHistoryModal: React.FC<StockHistoryModalProps> = ({
|
||||
];
|
||||
|
||||
return (
|
||||
<StatusModal
|
||||
<EditViewModal
|
||||
isOpen={isOpen}
|
||||
onClose={onClose}
|
||||
mode="view"
|
||||
|
||||
@@ -7,8 +7,8 @@ import {
|
||||
Card,
|
||||
Badge
|
||||
} from '../../ui';
|
||||
import { StatusModal } from '../../ui/StatusModal';
|
||||
import type { StatusModalSection } from '../../ui/StatusModal';
|
||||
import { EditViewModal } from '../../ui/EditViewModal';
|
||||
import type { StatusModalSection } from '../../ui/EditViewModal';
|
||||
import {
|
||||
OrderCreate,
|
||||
OrderItemCreate,
|
||||
@@ -29,7 +29,7 @@ import { useIngredients } from '../../../api/hooks/inventory';
|
||||
import { ProductType, ProductCategory } from '../../../api/types/inventory';
|
||||
import { useCurrentTenant } from '../../../stores/tenant.store';
|
||||
import { useAuthUser } from '../../../stores/auth.store';
|
||||
import { useOrderEnums } from '../../../utils/enumHelpers';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
interface OrderFormModalProps {
|
||||
isOpen: boolean;
|
||||
@@ -46,7 +46,28 @@ export const OrderFormModal: React.FC<OrderFormModalProps> = ({
|
||||
const currentTenant = useCurrentTenant();
|
||||
const user = useAuthUser();
|
||||
const tenantId = currentTenant?.id || user?.tenant_id || '';
|
||||
const orderEnums = useOrderEnums();
|
||||
const { t } = useTranslation(['orders', 'common']);
|
||||
|
||||
// Create enum options using direct i18n
|
||||
const orderTypeOptions = Object.values(OrderType).map(value => ({
|
||||
value,
|
||||
label: t(`orders:order_types.${value}`)
|
||||
}));
|
||||
|
||||
const priorityLevelOptions = Object.values(PriorityLevel).map(value => ({
|
||||
value,
|
||||
label: t(`orders:priority_levels.${value}`)
|
||||
}));
|
||||
|
||||
const deliveryMethodOptions = Object.values(DeliveryMethod).map(value => ({
|
||||
value,
|
||||
label: t(`orders:delivery_methods.${value}`)
|
||||
}));
|
||||
|
||||
const customerTypeOptions = Object.values(CustomerType).map(value => ({
|
||||
value,
|
||||
label: t(`orders:customer_types.${value}`)
|
||||
}));
|
||||
|
||||
// Form state
|
||||
const [selectedCustomer, setSelectedCustomer] = useState<CustomerResponse | null>(null);
|
||||
@@ -265,21 +286,21 @@ export const OrderFormModal: React.FC<OrderFormModalProps> = ({
|
||||
value: orderData.order_type || OrderType.STANDARD,
|
||||
type: 'select',
|
||||
editable: true,
|
||||
options: orderEnums.getOrderTypeOptions()
|
||||
options: orderTypeOptions
|
||||
},
|
||||
{
|
||||
label: 'Prioridad',
|
||||
value: orderData.priority || PriorityLevel.NORMAL,
|
||||
type: 'select',
|
||||
editable: true,
|
||||
options: orderEnums.getPriorityLevelOptions()
|
||||
options: priorityLevelOptions
|
||||
},
|
||||
{
|
||||
label: 'Método de Entrega',
|
||||
value: orderData.delivery_method || DeliveryMethod.PICKUP,
|
||||
type: 'select',
|
||||
editable: true,
|
||||
options: orderEnums.getDeliveryMethodOptions()
|
||||
options: deliveryMethodOptions
|
||||
},
|
||||
{
|
||||
label: 'Fecha de Entrega',
|
||||
@@ -442,7 +463,7 @@ export const OrderFormModal: React.FC<OrderFormModalProps> = ({
|
||||
|
||||
return (
|
||||
<>
|
||||
<StatusModal
|
||||
<EditViewModal
|
||||
isOpen={isOpen}
|
||||
onClose={onClose}
|
||||
mode="edit"
|
||||
@@ -474,7 +495,7 @@ export const OrderFormModal: React.FC<OrderFormModalProps> = ({
|
||||
|
||||
|
||||
{/* New Customer Modal - Using StatusModal for consistency */}
|
||||
<StatusModal
|
||||
<EditViewModal
|
||||
isOpen={showCustomerForm}
|
||||
onClose={() => setShowCustomerForm(false)}
|
||||
mode="edit"
|
||||
@@ -515,14 +536,14 @@ export const OrderFormModal: React.FC<OrderFormModalProps> = ({
|
||||
value: newCustomerData.customer_type || CustomerType.INDIVIDUAL,
|
||||
type: 'select',
|
||||
editable: true,
|
||||
options: orderEnums.getCustomerTypeOptions()
|
||||
options: customerTypeOptions
|
||||
},
|
||||
{
|
||||
label: 'Método de Entrega Preferido',
|
||||
value: newCustomerData.preferred_delivery_method || DeliveryMethod.PICKUP,
|
||||
type: 'select',
|
||||
editable: true,
|
||||
options: orderEnums.getDeliveryMethodOptions()
|
||||
options: deliveryMethodOptions
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -574,7 +595,7 @@ export const OrderFormModal: React.FC<OrderFormModalProps> = ({
|
||||
/>
|
||||
|
||||
{/* Customer Selector Modal */}
|
||||
<StatusModal
|
||||
<EditViewModal
|
||||
isOpen={showCustomerSelector}
|
||||
onClose={() => setShowCustomerSelector(false)}
|
||||
mode="view"
|
||||
|
||||
@@ -1,13 +1,14 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { Plus, Package, Euro, Calendar, Truck, Building2, X, Save, AlertCircle } from 'lucide-react';
|
||||
import { Button } from '../../ui/Button';
|
||||
import { Input } from '../../ui/Input';
|
||||
import { Card } from '../../ui/Card';
|
||||
import React, { useState, useEffect, useMemo } from 'react';
|
||||
import { Plus, Package, Calendar, Building2 } from 'lucide-react';
|
||||
import { AddModal } from '../../ui/AddModal/AddModal';
|
||||
import { useSuppliers } from '../../../api/hooks/suppliers';
|
||||
import { useCreatePurchaseOrder } from '../../../api/hooks/suppliers';
|
||||
import { useIngredients } from '../../../api/hooks/inventory';
|
||||
import { useTenantStore } from '../../../stores/tenant.store';
|
||||
import type { ProcurementRequirementResponse, PurchaseOrderItem } from '../../../api/types/orders';
|
||||
import type { SupplierSummary } from '../../../api/types/suppliers';
|
||||
import type { IngredientResponse } from '../../../api/types/inventory';
|
||||
import { statusColors } from '../../../styles/colors';
|
||||
|
||||
interface CreatePurchaseOrderModalProps {
|
||||
isOpen: boolean;
|
||||
@@ -16,6 +17,7 @@ interface CreatePurchaseOrderModalProps {
|
||||
onSuccess?: () => void;
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* CreatePurchaseOrderModal - Modal for creating purchase orders from procurement requirements
|
||||
* Allows supplier selection and purchase order creation for ingredients
|
||||
@@ -27,29 +29,8 @@ export const CreatePurchaseOrderModal: React.FC<CreatePurchaseOrderModalProps> =
|
||||
requirements,
|
||||
onSuccess
|
||||
}) => {
|
||||
const [selectedSupplierId, setSelectedSupplierId] = useState<string>('');
|
||||
const [deliveryDate, setDeliveryDate] = useState<string>('');
|
||||
const [notes, setNotes] = useState<string>('');
|
||||
const [selectedRequirements, setSelectedRequirements] = useState<Record<string, boolean>>({});
|
||||
const [quantities, setQuantities] = useState<Record<string, number>>({});
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
// For manual creation when no requirements are provided
|
||||
const [manualItems, setManualItems] = useState<Array<{
|
||||
id: string;
|
||||
product_name: string;
|
||||
product_sku?: string;
|
||||
unit_of_measure: string;
|
||||
unit_price: number;
|
||||
}>>([]);
|
||||
const [manualItemInputs, setManualItemInputs] = useState({
|
||||
product_name: '',
|
||||
product_sku: '',
|
||||
unit_of_measure: '',
|
||||
unit_price: '',
|
||||
quantity: ''
|
||||
});
|
||||
const [selectedSupplier, setSelectedSupplier] = useState<string>('');
|
||||
|
||||
// Get current tenant
|
||||
const { currentTenant } = useTenantStore();
|
||||
@@ -63,657 +44,312 @@ export const CreatePurchaseOrderModal: React.FC<CreatePurchaseOrderModalProps> =
|
||||
);
|
||||
const suppliers = (suppliersData || []).filter(supplier => supplier.status === 'active');
|
||||
|
||||
// Fetch ingredients filtered by selected supplier (only when manually adding products)
|
||||
const { data: ingredientsData = [] } = useIngredients(
|
||||
tenantId,
|
||||
selectedSupplier ? { supplier_id: selectedSupplier } : {},
|
||||
{ enabled: !!tenantId && isOpen && !requirements?.length && !!selectedSupplier }
|
||||
);
|
||||
|
||||
// Create purchase order mutation
|
||||
const createPurchaseOrderMutation = useCreatePurchaseOrder();
|
||||
|
||||
// Initialize quantities when requirements change
|
||||
useEffect(() => {
|
||||
if (requirements && requirements.length > 0) {
|
||||
// Initialize from requirements (existing behavior)
|
||||
const initialQuantities: Record<string, number> = {};
|
||||
requirements.forEach(req => {
|
||||
initialQuantities[req.id] = req.approved_quantity || req.net_requirement || req.required_quantity;
|
||||
});
|
||||
setQuantities(initialQuantities);
|
||||
const supplierOptions = useMemo(() => suppliers.map(supplier => ({
|
||||
value: supplier.id,
|
||||
label: `${supplier.name} (${supplier.supplier_code})`
|
||||
})), [suppliers]);
|
||||
|
||||
// Initialize all requirements as selected
|
||||
const initialSelected: Record<string, boolean> = {};
|
||||
requirements.forEach(req => {
|
||||
initialSelected[req.id] = true;
|
||||
});
|
||||
setSelectedRequirements(initialSelected);
|
||||
|
||||
// Clear manual items when using requirements
|
||||
setManualItems([]);
|
||||
} else {
|
||||
// Reset for manual creation
|
||||
setQuantities({});
|
||||
setSelectedRequirements({});
|
||||
setManualItems([]);
|
||||
}
|
||||
}, [requirements]);
|
||||
// Create ingredient options from supplier-filtered ingredients
|
||||
const ingredientOptions = useMemo(() => ingredientsData.map(ingredient => ({
|
||||
value: ingredient.id,
|
||||
label: ingredient.name,
|
||||
data: ingredient // Store full ingredient data for later use
|
||||
})), [ingredientsData]);
|
||||
|
||||
// Group requirements by supplier (only when requirements exist)
|
||||
const groupedRequirements = requirements && requirements.length > 0 ?
|
||||
requirements.reduce((acc, req) => {
|
||||
const supplierId = req.preferred_supplier_id || 'unassigned';
|
||||
if (!acc[supplierId]) {
|
||||
acc[supplierId] = [];
|
||||
}
|
||||
acc[supplierId].push(req);
|
||||
return acc;
|
||||
}, {} as Record<string, ProcurementRequirementResponse[]>) :
|
||||
{};
|
||||
|
||||
const handleQuantityChange = (requirementId: string, value: string) => {
|
||||
const numValue = parseFloat(value) || 0;
|
||||
setQuantities(prev => ({
|
||||
...prev,
|
||||
[requirementId]: numValue
|
||||
}));
|
||||
};
|
||||
|
||||
const handleSelectRequirement = (requirementId: string, checked: boolean) => {
|
||||
setSelectedRequirements(prev => ({
|
||||
...prev,
|
||||
[requirementId]: checked
|
||||
}));
|
||||
};
|
||||
|
||||
const handleSelectAll = (supplierId: string, checked: boolean) => {
|
||||
const supplierRequirements = groupedRequirements[supplierId] || [];
|
||||
const updatedSelected = { ...selectedRequirements };
|
||||
|
||||
supplierRequirements.forEach(req => {
|
||||
updatedSelected[req.id] = checked;
|
||||
});
|
||||
|
||||
setSelectedRequirements(updatedSelected);
|
||||
};
|
||||
|
||||
// Manual item functions
|
||||
const handleAddManualItem = () => {
|
||||
if (!manualItemInputs.product_name || !manualItemInputs.unit_of_measure || !manualItemInputs.unit_price) {
|
||||
return;
|
||||
}
|
||||
|
||||
const newItem = {
|
||||
id: `manual-${Date.now()}`,
|
||||
product_name: manualItemInputs.product_name,
|
||||
product_sku: manualItemInputs.product_sku || undefined,
|
||||
unit_of_measure: manualItemInputs.unit_of_measure,
|
||||
unit_price: parseFloat(manualItemInputs.unit_price) || 0
|
||||
};
|
||||
|
||||
setManualItems(prev => [...prev, newItem]);
|
||||
|
||||
// Reset inputs
|
||||
setManualItemInputs({
|
||||
product_name: '',
|
||||
product_sku: '',
|
||||
unit_of_measure: '',
|
||||
unit_price: '',
|
||||
quantity: ''
|
||||
});
|
||||
};
|
||||
|
||||
const handleRemoveManualItem = (id: string) => {
|
||||
setManualItems(prev => prev.filter(item => item.id !== id));
|
||||
};
|
||||
|
||||
const handleManualItemQuantityChange = (id: string, value: string) => {
|
||||
const numValue = parseFloat(value) || 0;
|
||||
setQuantities(prev => ({
|
||||
...prev,
|
||||
[id]: numValue
|
||||
}));
|
||||
};
|
||||
|
||||
const handleCreatePurchaseOrder = async () => {
|
||||
if (!selectedSupplierId) {
|
||||
setError('Por favor, selecciona un proveedor');
|
||||
return;
|
||||
}
|
||||
|
||||
let items: PurchaseOrderItem[] = [];
|
||||
|
||||
if (requirements && requirements.length > 0) {
|
||||
// Create items from requirements
|
||||
const selectedReqs = requirements.filter(req => selectedRequirements[req.id]);
|
||||
|
||||
if (selectedReqs.length === 0) {
|
||||
setError('Por favor, selecciona al menos un ingrediente');
|
||||
return;
|
||||
}
|
||||
|
||||
// Validate quantities
|
||||
const invalidQuantities = selectedReqs.some(req => quantities[req.id] <= 0);
|
||||
if (invalidQuantities) {
|
||||
setError('Todas las cantidades deben ser mayores a 0');
|
||||
return;
|
||||
}
|
||||
|
||||
// Prepare purchase order items from requirements
|
||||
items = selectedReqs.map(req => ({
|
||||
inventory_product_id: req.product_id,
|
||||
product_code: req.product_sku || '',
|
||||
product_name: req.product_name,
|
||||
ordered_quantity: quantities[req.id],
|
||||
unit_of_measure: req.unit_of_measure,
|
||||
unit_price: req.estimated_unit_cost || 0,
|
||||
quality_requirements: req.quality_specifications ? JSON.stringify(req.quality_specifications) : undefined,
|
||||
notes: req.special_requirements || undefined
|
||||
}));
|
||||
} else {
|
||||
// Create items from manual entries
|
||||
if (manualItems.length === 0) {
|
||||
setError('Por favor, agrega al menos un producto');
|
||||
return;
|
||||
}
|
||||
|
||||
// Validate quantities for manual items
|
||||
const invalidQuantities = manualItems.some(item => quantities[item.id] <= 0);
|
||||
if (invalidQuantities) {
|
||||
setError('Todas las cantidades deben ser mayores a 0');
|
||||
return;
|
||||
}
|
||||
|
||||
// Prepare purchase order items from manual entries
|
||||
items = manualItems.map(item => ({
|
||||
inventory_product_id: '', // Not applicable for manual items
|
||||
product_code: item.product_sku || '',
|
||||
product_name: item.product_name,
|
||||
ordered_quantity: quantities[item.id],
|
||||
unit_of_measure: item.unit_of_measure,
|
||||
unit_price: item.unit_price,
|
||||
quality_requirements: undefined,
|
||||
notes: undefined
|
||||
}));
|
||||
}
|
||||
// Unit options for select field
|
||||
const unitOptions = [
|
||||
{ value: 'kg', label: 'Kilogramos' },
|
||||
{ value: 'g', label: 'Gramos' },
|
||||
{ value: 'l', label: 'Litros' },
|
||||
{ value: 'ml', label: 'Mililitros' },
|
||||
{ value: 'units', label: 'Unidades' },
|
||||
{ value: 'boxes', label: 'Cajas' },
|
||||
{ value: 'bags', label: 'Bolsas' }
|
||||
];
|
||||
|
||||
const handleSave = async (formData: Record<string, any>) => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
|
||||
// Update selectedSupplier if it changed
|
||||
if (formData.supplier_id && formData.supplier_id !== selectedSupplier) {
|
||||
setSelectedSupplier(formData.supplier_id);
|
||||
}
|
||||
|
||||
try {
|
||||
let items: PurchaseOrderItem[] = [];
|
||||
|
||||
if (requirements && requirements.length > 0) {
|
||||
// Create items from requirements list
|
||||
const requiredIngredients = formData.required_ingredients || [];
|
||||
|
||||
if (requiredIngredients.length === 0) {
|
||||
throw new Error('Por favor, selecciona al menos un ingrediente');
|
||||
}
|
||||
|
||||
// Validate quantities
|
||||
const invalidQuantities = requiredIngredients.some((item: any) => item.quantity <= 0);
|
||||
if (invalidQuantities) {
|
||||
throw new Error('Todas las cantidades deben ser mayores a 0');
|
||||
}
|
||||
|
||||
// Prepare purchase order items from requirements
|
||||
items = requiredIngredients.map((item: any) => {
|
||||
// Find original requirement to get product_id
|
||||
const originalReq = requirements.find(req => req.id === item.id);
|
||||
return {
|
||||
inventory_product_id: originalReq?.product_id || '',
|
||||
product_code: item.product_sku || '',
|
||||
product_name: item.product_name,
|
||||
ordered_quantity: item.quantity,
|
||||
unit_of_measure: item.unit_of_measure,
|
||||
unit_price: item.unit_price,
|
||||
quality_requirements: originalReq?.quality_specifications ? JSON.stringify(originalReq.quality_specifications) : undefined,
|
||||
notes: originalReq?.special_requirements || undefined
|
||||
};
|
||||
});
|
||||
} else {
|
||||
// Create items from manual entries
|
||||
const manualProducts = formData.manual_products || [];
|
||||
|
||||
if (manualProducts.length === 0) {
|
||||
throw new Error('Por favor, agrega al menos un producto');
|
||||
}
|
||||
|
||||
// Validate quantities for manual items
|
||||
const invalidQuantities = manualProducts.some((item: any) => item.quantity <= 0);
|
||||
if (invalidQuantities) {
|
||||
throw new Error('Todas las cantidades deben ser mayores a 0');
|
||||
}
|
||||
|
||||
// Validate required fields
|
||||
const invalidProducts = manualProducts.some((item: any) => !item.ingredient_id);
|
||||
if (invalidProducts) {
|
||||
throw new Error('Todos los productos deben tener un ingrediente seleccionado');
|
||||
}
|
||||
|
||||
// Prepare purchase order items from manual entries with ingredient data
|
||||
items = manualProducts.map((item: any) => {
|
||||
// Find the selected ingredient data
|
||||
const selectedIngredient = ingredientsData.find(ing => ing.id === item.ingredient_id);
|
||||
|
||||
return {
|
||||
inventory_product_id: item.ingredient_id,
|
||||
product_code: selectedIngredient?.sku || '',
|
||||
product_name: selectedIngredient?.name || 'Ingrediente desconocido',
|
||||
ordered_quantity: item.quantity,
|
||||
unit_of_measure: item.unit_of_measure,
|
||||
unit_price: item.unit_price,
|
||||
quality_requirements: undefined,
|
||||
notes: undefined
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
// Create purchase order
|
||||
await createPurchaseOrderMutation.mutateAsync({
|
||||
supplier_id: selectedSupplierId,
|
||||
supplier_id: formData.supplier_id,
|
||||
priority: 'normal',
|
||||
required_delivery_date: deliveryDate || undefined,
|
||||
notes: notes || undefined,
|
||||
required_delivery_date: formData.delivery_date || undefined,
|
||||
notes: formData.notes || undefined,
|
||||
items
|
||||
});
|
||||
|
||||
// Close modal and trigger success callback
|
||||
onClose();
|
||||
// Purchase order created successfully
|
||||
|
||||
// Trigger success callback
|
||||
if (onSuccess) {
|
||||
onSuccess();
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Error creating purchase order:', err);
|
||||
setError('Error al crear la orden de compra. Por favor, intenta de nuevo.');
|
||||
} catch (error) {
|
||||
console.error('Error creating purchase order:', error);
|
||||
throw error; // Let AddModal handle error display
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Log suppliers when they change for debugging
|
||||
useEffect(() => {
|
||||
// console.log('Suppliers updated:', suppliers);
|
||||
}, [suppliers]);
|
||||
const statusConfig = {
|
||||
color: statusColors.inProgress.primary,
|
||||
text: 'Nueva Orden',
|
||||
icon: Plus,
|
||||
isCritical: false,
|
||||
isHighlight: true
|
||||
};
|
||||
|
||||
if (!isOpen) return null;
|
||||
|
||||
const sections = [
|
||||
{
|
||||
title: 'Información del Proveedor',
|
||||
icon: Building2,
|
||||
fields: [
|
||||
{
|
||||
label: 'Proveedor',
|
||||
name: 'supplier_id',
|
||||
type: 'select' as const,
|
||||
required: true,
|
||||
options: supplierOptions,
|
||||
placeholder: 'Seleccionar proveedor...',
|
||||
span: 2
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
title: 'Detalles de la Orden',
|
||||
icon: Calendar,
|
||||
fields: [
|
||||
{
|
||||
label: 'Fecha de Entrega Requerida',
|
||||
name: 'delivery_date',
|
||||
type: 'date' as const,
|
||||
helpText: 'Fecha límite para la entrega (opcional)'
|
||||
},
|
||||
{
|
||||
label: 'Notas',
|
||||
name: 'notes',
|
||||
type: 'textarea' as const,
|
||||
placeholder: 'Instrucciones especiales para el proveedor...',
|
||||
span: 2,
|
||||
helpText: 'Información adicional o instrucciones especiales'
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
title: requirements && requirements.length > 0 ? 'Ingredientes Requeridos' : 'Productos a Comprar',
|
||||
icon: Package,
|
||||
fields: [
|
||||
requirements && requirements.length > 0 ? {
|
||||
label: 'Ingredientes Requeridos',
|
||||
name: 'required_ingredients',
|
||||
type: 'list' as const,
|
||||
span: 2,
|
||||
defaultValue: requirements.map(req => ({
|
||||
id: req.id,
|
||||
product_name: req.product_name,
|
||||
product_sku: req.product_sku || '',
|
||||
quantity: req.approved_quantity || req.net_requirement || req.required_quantity,
|
||||
unit_of_measure: req.unit_of_measure,
|
||||
unit_price: req.estimated_unit_cost || 0,
|
||||
selected: true
|
||||
})),
|
||||
listConfig: {
|
||||
itemFields: [
|
||||
{
|
||||
name: 'product_name',
|
||||
label: 'Producto',
|
||||
type: 'text',
|
||||
required: false // Read-only display
|
||||
},
|
||||
{
|
||||
name: 'product_sku',
|
||||
label: 'SKU',
|
||||
type: 'text',
|
||||
required: false
|
||||
},
|
||||
{
|
||||
name: 'quantity',
|
||||
label: 'Cantidad Requerida',
|
||||
type: 'number',
|
||||
required: true
|
||||
},
|
||||
{
|
||||
name: 'unit_of_measure',
|
||||
label: 'Unidad',
|
||||
type: 'text',
|
||||
required: false
|
||||
},
|
||||
{
|
||||
name: 'unit_price',
|
||||
label: 'Precio Est. (€)',
|
||||
type: 'currency',
|
||||
required: true
|
||||
}
|
||||
],
|
||||
addButtonLabel: 'Agregar Ingrediente',
|
||||
emptyStateText: 'No hay ingredientes requeridos',
|
||||
showSubtotals: true,
|
||||
subtotalFields: { quantity: 'quantity', price: 'unit_price' }
|
||||
},
|
||||
helpText: 'Revisa y ajusta las cantidades y precios de los ingredientes requeridos'
|
||||
} : {
|
||||
label: 'Productos a Comprar',
|
||||
name: 'manual_products',
|
||||
type: 'list' as const,
|
||||
span: 2,
|
||||
defaultValue: [],
|
||||
listConfig: {
|
||||
itemFields: [
|
||||
{
|
||||
name: 'ingredient_id',
|
||||
label: 'Ingrediente',
|
||||
type: 'select',
|
||||
required: true,
|
||||
options: ingredientOptions,
|
||||
placeholder: 'Seleccionar ingrediente...',
|
||||
disabled: false
|
||||
},
|
||||
{
|
||||
name: 'quantity',
|
||||
label: 'Cantidad',
|
||||
type: 'number',
|
||||
required: true,
|
||||
defaultValue: 1
|
||||
},
|
||||
{
|
||||
name: 'unit_of_measure',
|
||||
label: 'Unidad',
|
||||
type: 'select',
|
||||
required: true,
|
||||
defaultValue: 'kg',
|
||||
options: unitOptions
|
||||
},
|
||||
{
|
||||
name: 'unit_price',
|
||||
label: 'Precio Unitario (€)',
|
||||
type: 'currency',
|
||||
required: true,
|
||||
defaultValue: 0,
|
||||
placeholder: '0.00'
|
||||
}
|
||||
],
|
||||
addButtonLabel: 'Agregar Ingrediente',
|
||||
emptyStateText: 'No hay ingredientes disponibles para este proveedor',
|
||||
showSubtotals: true,
|
||||
subtotalFields: { quantity: 'quantity', price: 'unit_price' },
|
||||
disabled: !selectedSupplier
|
||||
},
|
||||
helpText: 'Selecciona ingredientes disponibles del proveedor seleccionado'
|
||||
}
|
||||
]
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="fixed top-[var(--header-height)] left-0 right-0 bottom-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
|
||||
<div className="bg-[var(--bg-primary)] rounded-lg shadow-xl max-w-4xl w-full max-h-[90vh] overflow-hidden mx-4">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between p-6 border-b border-[var(--border-primary)]">
|
||||
<div className="flex items-center space-x-3">
|
||||
<div className="flex items-center justify-center w-8 h-8 rounded-full bg-blue-100">
|
||||
<Plus className="w-5 h-5 text-blue-600" />
|
||||
</div>
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold text-[var(--text-primary)]">
|
||||
Crear Orden de Compra
|
||||
</h2>
|
||||
<p className="text-sm text-[var(--text-secondary)]">
|
||||
Selecciona proveedor e ingredientes para crear una orden de compra
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={onClose}
|
||||
className="p-2"
|
||||
disabled={loading}
|
||||
>
|
||||
<X className="w-4 h-4" />
|
||||
</Button>
|
||||
</div>
|
||||
<>
|
||||
<AddModal
|
||||
isOpen={isOpen}
|
||||
onClose={onClose}
|
||||
title="Crear Orden de Compra"
|
||||
subtitle={requirements && requirements.length > 0
|
||||
? "Generar orden de compra desde requerimientos de procuración"
|
||||
: "Crear orden de compra manual"}
|
||||
statusIndicator={statusConfig}
|
||||
sections={sections}
|
||||
size="xl"
|
||||
loading={loading}
|
||||
onSave={handleSave}
|
||||
/>
|
||||
|
||||
{/* Content */}
|
||||
<div className="p-6 overflow-y-auto max-h-[calc(90vh-200px)]">
|
||||
{error && (
|
||||
<div className="mb-4 p-3 bg-red-50 border border-red-200 rounded-lg flex items-center">
|
||||
<AlertCircle className="w-5 h-5 text-red-500 mr-2 flex-shrink-0" />
|
||||
<span className="text-red-700 text-sm">{error}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Supplier Selection */}
|
||||
<div className="mb-6">
|
||||
<label className="block text-sm font-medium text-[var(--text-secondary)] mb-2">
|
||||
Proveedor
|
||||
</label>
|
||||
{!tenantId ? (
|
||||
<div className="p-3 bg-yellow-50 border border-yellow-200 rounded-lg text-yellow-700 text-sm">
|
||||
Cargando información del tenant...
|
||||
</div>
|
||||
) : isLoadingSuppliers ? (
|
||||
<div className="animate-pulse h-10 bg-gray-200 rounded"></div>
|
||||
) : isSuppliersError ? (
|
||||
<div className="p-3 bg-red-50 border border-red-200 rounded-lg text-red-700 text-sm">
|
||||
Error al cargar proveedores: {suppliersError?.message || 'Error desconocido'}
|
||||
</div>
|
||||
) : (
|
||||
<select
|
||||
value={selectedSupplierId}
|
||||
onChange={(e) => setSelectedSupplierId(e.target.value)}
|
||||
className="w-full px-3 py-2 text-sm border border-[var(--border-primary)] rounded-lg
|
||||
bg-[var(--bg-primary)] text-[var(--text-primary)]
|
||||
focus:ring-2 focus:ring-[var(--color-primary)]/20 focus:border-[var(--color-primary)]
|
||||
transition-colors duration-200"
|
||||
disabled={loading}
|
||||
>
|
||||
<option value="">Seleccionar proveedor...</option>
|
||||
{suppliers.length > 0 ? (
|
||||
suppliers.map((supplier: SupplierSummary) => (
|
||||
<option key={supplier.id} value={supplier.id}>
|
||||
{supplier.name} ({supplier.supplier_code})
|
||||
</option>
|
||||
))
|
||||
) : (
|
||||
<option value="" disabled>
|
||||
No hay proveedores activos disponibles
|
||||
</option>
|
||||
)}
|
||||
</select>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Delivery Date */}
|
||||
<div className="mb-6">
|
||||
<label className="block text-sm font-medium text-[var(--text-secondary)] mb-2">
|
||||
Fecha de Entrega Requerida (Opcional)
|
||||
</label>
|
||||
<Input
|
||||
type="date"
|
||||
value={deliveryDate}
|
||||
onChange={(e) => setDeliveryDate(e.target.value)}
|
||||
className="w-full"
|
||||
disabled={loading}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Notes */}
|
||||
<div className="mb-6">
|
||||
<label className="block text-sm font-medium text-[var(--text-secondary)] mb-2">
|
||||
Notas (Opcional)
|
||||
</label>
|
||||
<textarea
|
||||
value={notes}
|
||||
onChange={(e) => setNotes(e.target.value)}
|
||||
placeholder="Instrucciones especiales para el proveedor..."
|
||||
className="w-full px-3 py-2 text-sm border border-[var(--border-primary)] rounded-lg
|
||||
bg-[var(--bg-primary)] text-[var(--text-primary)] placeholder-[var(--text-tertiary)]
|
||||
focus:ring-2 focus:ring-[var(--color-primary)]/20 focus:border-[var(--color-primary)]
|
||||
transition-colors duration-200 resize-vertical min-h-[80px]"
|
||||
disabled={loading}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Requirements by Supplier or Manual Items */}
|
||||
<div className="space-y-6">
|
||||
<h3 className="text-md font-semibold text-[var(--text-primary)] border-b pb-2">
|
||||
{requirements && requirements.length > 0 ? 'Ingredientes a Comprar' : 'Productos a Comprar'}
|
||||
</h3>
|
||||
|
||||
{requirements && requirements.length > 0 ? (
|
||||
// Show requirements when they exist
|
||||
Object.entries(groupedRequirements).map(([supplierId, reqs]) => {
|
||||
const supplierName = supplierId === 'unassigned'
|
||||
? 'Sin proveedor asignado'
|
||||
: suppliers.find(s => s.id === supplierId)?.name || 'Proveedor desconocido';
|
||||
|
||||
const allSelected = reqs.every(req => selectedRequirements[req.id]);
|
||||
const someSelected = reqs.some(req => selectedRequirements[req.id]);
|
||||
|
||||
return (
|
||||
<Card key={supplierId} className="p-4">
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<div className="flex items-center space-x-2">
|
||||
<Building2 className="w-4 h-4 text-[var(--text-secondary)]" />
|
||||
<h4 className="font-medium text-[var(--text-primary)]">{supplierName}</h4>
|
||||
<span className="text-xs text-[var(--text-secondary)] bg-[var(--bg-secondary)] px-2 py-1 rounded">
|
||||
{reqs.length} {reqs.length === 1 ? 'ingrediente' : 'ingredientes'}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center">
|
||||
<input
|
||||
type="checkbox"
|
||||
id={`select-all-${supplierId}`}
|
||||
checked={allSelected}
|
||||
ref={el => {
|
||||
if (el) el.indeterminate = someSelected && !allSelected;
|
||||
}}
|
||||
onChange={(e) => handleSelectAll(supplierId, e.target.checked)}
|
||||
className="w-4 h-4 text-blue-600 bg-gray-100 border-gray-300 rounded focus:ring-blue-500"
|
||||
disabled={loading}
|
||||
/>
|
||||
<label
|
||||
htmlFor={`select-all-${supplierId}`}
|
||||
className="ml-2 text-sm text-[var(--text-secondary)]"
|
||||
>
|
||||
Seleccionar todo
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-3">
|
||||
{reqs.map((req) => (
|
||||
<div
|
||||
key={req.id}
|
||||
className={`flex items-center p-3 rounded-lg border ${
|
||||
selectedRequirements[req.id]
|
||||
? 'border-[var(--color-primary)] bg-[var(--color-primary)]/5'
|
||||
: 'border-[var(--border-primary)]'
|
||||
}`}
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={!!selectedRequirements[req.id]}
|
||||
onChange={(e) => handleSelectRequirement(req.id, e.target.checked)}
|
||||
className="w-4 h-4 text-blue-600 bg-gray-100 border-gray-300 rounded focus:ring-blue-500"
|
||||
disabled={loading}
|
||||
/>
|
||||
<div className="ml-3 flex-1 min-w-0">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="min-w-0">
|
||||
<p className="text-sm font-medium text-[var(--text-primary)] truncate">
|
||||
{req.product_name}
|
||||
</p>
|
||||
<p className="text-xs text-[var(--text-secondary)]">
|
||||
{req.product_sku || 'Sin SKU'} • {req.unit_of_measure}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center space-x-2">
|
||||
<div className="flex items-center">
|
||||
<Euro className="w-3 h-3 text-[var(--text-secondary)] mr-1" />
|
||||
<span className="text-xs text-[var(--text-secondary)]">
|
||||
{req.estimated_unit_cost?.toFixed(2) || '0.00'}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-2 flex items-center space-x-2">
|
||||
<div className="flex-1">
|
||||
<label className="block text-xs text-[var(--text-secondary)] mb-1">
|
||||
Cantidad requerida
|
||||
</label>
|
||||
<div className="flex items-center">
|
||||
<Input
|
||||
type="number"
|
||||
min="0"
|
||||
step="0.01"
|
||||
value={quantities[req.id] || ''}
|
||||
onChange={(e) => handleQuantityChange(req.id, e.target.value)}
|
||||
className="w-24 text-sm"
|
||||
disabled={loading || !selectedRequirements[req.id]}
|
||||
/>
|
||||
<span className="ml-2 text-sm text-[var(--text-secondary)]">
|
||||
{req.unit_of_measure}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex-1">
|
||||
<label className="block text-xs text-[var(--text-secondary)] mb-1">
|
||||
Stock actual
|
||||
</label>
|
||||
<div className="flex items-center">
|
||||
<span className="text-sm text-[var(--text-primary)]">
|
||||
{req.current_stock_level || 0}
|
||||
</span>
|
||||
<span className="ml-1 text-xs text-[var(--text-secondary)]">
|
||||
{req.unit_of_measure}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex-1">
|
||||
<label className="block text-xs text-[var(--text-secondary)] mb-1">
|
||||
Total estimado
|
||||
</label>
|
||||
<div className="flex items-center">
|
||||
<Euro className="w-3 h-3 text-[var(--text-secondary)] mr-1" />
|
||||
<span className="text-sm text-[var(--text-primary)]">
|
||||
{((quantities[req.id] || 0) * (req.estimated_unit_cost || 0)).toFixed(2)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
})
|
||||
) : (
|
||||
// Show manual item creation when no requirements exist
|
||||
<Card className="p-4">
|
||||
<div className="space-y-4">
|
||||
{/* Manual Item Input Form */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-5 gap-3">
|
||||
<div className="md:col-span-2">
|
||||
<label className="block text-xs font-medium text-[var(--text-secondary)] mb-1">
|
||||
Nombre del Producto *
|
||||
</label>
|
||||
<Input
|
||||
type="text"
|
||||
value={manualItemInputs.product_name}
|
||||
onChange={(e) => setManualItemInputs(prev => ({
|
||||
...prev,
|
||||
product_name: e.target.value
|
||||
}))}
|
||||
placeholder="Harina de Trigo"
|
||||
className="w-full text-sm"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-[var(--text-secondary)] mb-1">
|
||||
SKU
|
||||
</label>
|
||||
<Input
|
||||
type="text"
|
||||
value={manualItemInputs.product_sku}
|
||||
onChange={(e) => setManualItemInputs(prev => ({
|
||||
...prev,
|
||||
product_sku: e.target.value
|
||||
}))}
|
||||
placeholder="HT-001"
|
||||
className="w-full text-sm"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-[var(--text-secondary)] mb-1">
|
||||
Unidad *
|
||||
</label>
|
||||
<Input
|
||||
type="text"
|
||||
value={manualItemInputs.unit_of_measure}
|
||||
onChange={(e) => setManualItemInputs(prev => ({
|
||||
...prev,
|
||||
unit_of_measure: e.target.value
|
||||
}))}
|
||||
placeholder="kg"
|
||||
className="w-full text-sm"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-[var(--text-secondary)] mb-1">
|
||||
Precio Unitario *
|
||||
</label>
|
||||
<div className="relative">
|
||||
<span className="absolute left-2 top-1/2 transform -translate-y-1/2 text-[var(--text-secondary)] text-sm">€</span>
|
||||
<Input
|
||||
type="number"
|
||||
min="0"
|
||||
step="0.01"
|
||||
value={manualItemInputs.unit_price}
|
||||
onChange={(e) => setManualItemInputs(prev => ({
|
||||
...prev,
|
||||
unit_price: e.target.value
|
||||
}))}
|
||||
placeholder="2.50"
|
||||
className="w-full text-sm pl-6"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end">
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={handleAddManualItem}
|
||||
disabled={!manualItemInputs.product_name || !manualItemInputs.unit_of_measure || !manualItemInputs.unit_price}
|
||||
>
|
||||
<Plus className="w-4 h-4 mr-2" />
|
||||
Agregar Producto
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Manual Items List */}
|
||||
{manualItems.length > 0 && (
|
||||
<div className="mt-4 space-y-3">
|
||||
<h4 className="text-sm font-medium text-[var(--text-primary)]">
|
||||
Productos Agregados ({manualItems.length})
|
||||
</h4>
|
||||
|
||||
{manualItems.map((item) => (
|
||||
<div
|
||||
key={item.id}
|
||||
className="flex items-center p-3 rounded-lg border border-[var(--border-primary)]"
|
||||
>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="min-w-0">
|
||||
<p className="text-sm font-medium text-[var(--text-primary)] truncate">
|
||||
{item.product_name}
|
||||
</p>
|
||||
<p className="text-xs text-[var(--text-secondary)]">
|
||||
{item.product_sku || 'Sin SKU'} • {item.unit_of_measure}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center space-x-2">
|
||||
<div className="flex items-center">
|
||||
<Euro className="w-3 h-3 text-[var(--text-secondary)] mr-1" />
|
||||
<span className="text-xs text-[var(--text-secondary)]">
|
||||
{item.unit_price.toFixed(2)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-2 flex items-center space-x-2">
|
||||
<div className="flex-1">
|
||||
<label className="block text-xs text-[var(--text-secondary)] mb-1">
|
||||
Cantidad
|
||||
</label>
|
||||
<div className="flex items-center">
|
||||
<Input
|
||||
type="number"
|
||||
min="0"
|
||||
step="0.01"
|
||||
value={quantities[item.id] || ''}
|
||||
onChange={(e) => handleManualItemQuantityChange(item.id, e.target.value)}
|
||||
className="w-24 text-sm"
|
||||
disabled={loading}
|
||||
/>
|
||||
<span className="ml-2 text-sm text-[var(--text-secondary)]">
|
||||
{item.unit_of_measure}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex-1">
|
||||
<label className="block text-xs text-[var(--text-secondary)] mb-1">
|
||||
Total
|
||||
</label>
|
||||
<div className="flex items-center">
|
||||
<Euro className="w-3 h-3 text-[var(--text-secondary)] mr-1" />
|
||||
<span className="text-sm text-[var(--text-primary)]">
|
||||
{((quantities[item.id] || 0) * item.unit_price).toFixed(2)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => handleRemoveManualItem(item.id)}
|
||||
disabled={loading}
|
||||
>
|
||||
<X className="w-4 h-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<div className="flex justify-end space-x-3 p-6 border-t border-[var(--border-primary)]">
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={onClose}
|
||||
disabled={loading}
|
||||
>
|
||||
Cancelar
|
||||
</Button>
|
||||
<Button
|
||||
variant="primary"
|
||||
onClick={handleCreatePurchaseOrder}
|
||||
disabled={loading || !selectedSupplierId}
|
||||
>
|
||||
{loading ? (
|
||||
<>
|
||||
<div className="w-4 h-4 mr-2 border-2 border-white border-t-transparent rounded-full animate-spin"></div>
|
||||
Creando...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Save className="w-4 h-4 mr-2" />
|
||||
Crear Orden de Compra
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -1,15 +1,15 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { Package, Clock, Users, AlertCircle, ChefHat } from 'lucide-react';
|
||||
import { StatusModal, StatusModalSection, StatusModalField } from '../../ui/StatusModal/StatusModal';
|
||||
import { Button, Input, Select } from '../../ui';
|
||||
import React, { useState, useEffect, useMemo } from 'react';
|
||||
import { Package, Clock, Users, AlertCircle, Plus } from 'lucide-react';
|
||||
import { AddModal } from '../../ui/AddModal/AddModal';
|
||||
import {
|
||||
ProductionBatchCreate,
|
||||
ProductionPriorityEnum
|
||||
} from '../../../api/types/production';
|
||||
import { useProductionEnums } from '../../../utils/enumHelpers';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useRecipes } from '../../../api/hooks/recipes';
|
||||
import { useIngredients } from '../../../api/hooks/inventory';
|
||||
import { useCurrentTenant } from '../../../stores/tenant.store';
|
||||
import { statusColors } from '../../../styles/colors';
|
||||
|
||||
interface CreateProductionBatchModalProps {
|
||||
isOpen: boolean;
|
||||
@@ -26,164 +26,127 @@ export const CreateProductionBatchModal: React.FC<CreateProductionBatchModalProp
|
||||
onClose,
|
||||
onCreateBatch
|
||||
}) => {
|
||||
const productionEnums = useProductionEnums();
|
||||
const { t } = useTranslation(['production', 'common']);
|
||||
const currentTenant = useCurrentTenant();
|
||||
const tenantId = currentTenant?.id || '';
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
// API Data
|
||||
const { data: recipes = [], isLoading: recipesLoading } = useRecipes(tenantId);
|
||||
const { data: ingredients = [], isLoading: ingredientsLoading } = useIngredients(tenantId);
|
||||
|
||||
const [formData, setFormData] = useState<ProductionBatchCreate>({
|
||||
product_id: '',
|
||||
product_name: '',
|
||||
recipe_id: '',
|
||||
planned_start_time: '',
|
||||
planned_end_time: '',
|
||||
planned_quantity: 1,
|
||||
planned_duration_minutes: 60,
|
||||
priority: ProductionPriorityEnum.MEDIUM,
|
||||
is_rush_order: false,
|
||||
is_special_recipe: false,
|
||||
production_notes: '',
|
||||
batch_number: '',
|
||||
order_id: '',
|
||||
forecast_id: '',
|
||||
equipment_used: [],
|
||||
staff_assigned: [],
|
||||
station_id: ''
|
||||
});
|
||||
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
|
||||
// Reset form when modal opens
|
||||
useEffect(() => {
|
||||
if (isOpen) {
|
||||
setFormData({
|
||||
product_id: '',
|
||||
product_name: '',
|
||||
recipe_id: '',
|
||||
planned_start_time: '',
|
||||
planned_end_time: '',
|
||||
planned_quantity: 1,
|
||||
planned_duration_minutes: 60,
|
||||
priority: ProductionPriorityEnum.MEDIUM,
|
||||
is_rush_order: false,
|
||||
is_special_recipe: false,
|
||||
production_notes: '',
|
||||
batch_number: '',
|
||||
order_id: '',
|
||||
forecast_id: '',
|
||||
equipment_used: [],
|
||||
staff_assigned: [],
|
||||
station_id: ''
|
||||
});
|
||||
}
|
||||
}, [isOpen]);
|
||||
|
||||
// Filter finished products (ingredients that are finished products)
|
||||
const finishedProducts = ingredients.filter(ing =>
|
||||
const finishedProducts = useMemo(() => ingredients.filter(ing =>
|
||||
ing.type === 'finished_product' ||
|
||||
ing.category === 'finished_products' ||
|
||||
ing.name.toLowerCase().includes('pan') ||
|
||||
ing.name.toLowerCase().includes('pastel') ||
|
||||
ing.name.toLowerCase().includes('torta')
|
||||
);
|
||||
), [ingredients]);
|
||||
|
||||
const productOptions = finishedProducts.map(product => ({
|
||||
const productOptions = useMemo(() => finishedProducts.map(product => ({
|
||||
value: product.id,
|
||||
label: product.name
|
||||
}));
|
||||
})), [finishedProducts]);
|
||||
|
||||
const recipeOptions = recipes.map(recipe => ({
|
||||
const recipeOptions = useMemo(() => recipes.map(recipe => ({
|
||||
value: recipe.id,
|
||||
label: recipe.name
|
||||
}));
|
||||
|
||||
const handleSubmit = async () => {
|
||||
// Validate required fields
|
||||
if (!formData.product_name.trim()) {
|
||||
alert('El nombre del producto es obligatorio');
|
||||
return;
|
||||
}
|
||||
|
||||
if (!formData.product_id.trim()) {
|
||||
alert('El ID del producto es obligatorio');
|
||||
return;
|
||||
}
|
||||
|
||||
if (!formData.planned_start_time) {
|
||||
alert('La fecha de inicio planificada es obligatoria');
|
||||
return;
|
||||
}
|
||||
|
||||
if (!formData.planned_end_time) {
|
||||
alert('La fecha de fin planificada es obligatoria');
|
||||
return;
|
||||
}
|
||||
|
||||
if (formData.planned_quantity <= 0) {
|
||||
alert('La cantidad planificada debe ser mayor a 0');
|
||||
return;
|
||||
}
|
||||
|
||||
if (formData.planned_duration_minutes <= 0) {
|
||||
alert('La duración planificada debe ser mayor a 0');
|
||||
return;
|
||||
}
|
||||
})), [recipes]);
|
||||
|
||||
const handleSave = async (formData: Record<string, any>) => {
|
||||
// Validate that end time is after start time
|
||||
const startTime = new Date(formData.planned_start_time);
|
||||
const endTime = new Date(formData.planned_end_time);
|
||||
|
||||
if (endTime <= startTime) {
|
||||
alert('La fecha de fin debe ser posterior a la fecha de inicio');
|
||||
return;
|
||||
throw new Error('La fecha de fin debe ser posterior a la fecha de inicio');
|
||||
}
|
||||
|
||||
setIsSubmitting(true);
|
||||
setLoading(true);
|
||||
|
||||
try {
|
||||
// Find the selected product to get the name
|
||||
const selectedProduct = finishedProducts.find(p => p.id === formData.product_id);
|
||||
|
||||
// Convert staff_assigned from string to array
|
||||
const staffArray = formData.staff_assigned
|
||||
? formData.staff_assigned.split(',').map((s: string) => s.trim()).filter((s: string) => s.length > 0)
|
||||
: [];
|
||||
|
||||
const batchData: ProductionBatchCreate = {
|
||||
product_id: formData.product_id,
|
||||
product_name: selectedProduct?.name || '',
|
||||
recipe_id: formData.recipe_id || '',
|
||||
planned_start_time: formData.planned_start_time,
|
||||
planned_end_time: formData.planned_end_time,
|
||||
planned_quantity: Number(formData.planned_quantity),
|
||||
planned_duration_minutes: Number(formData.planned_duration_minutes),
|
||||
priority: formData.priority as ProductionPriorityEnum,
|
||||
is_rush_order: formData.is_rush_order || false,
|
||||
is_special_recipe: formData.is_special_recipe || false,
|
||||
production_notes: formData.production_notes || '',
|
||||
batch_number: formData.batch_number || '',
|
||||
order_id: formData.order_id || '',
|
||||
forecast_id: formData.forecast_id || '',
|
||||
equipment_used: [], // TODO: Add equipment selection if needed
|
||||
staff_assigned: staffArray,
|
||||
station_id: formData.station_id || ''
|
||||
};
|
||||
|
||||
if (onCreateBatch) {
|
||||
await onCreateBatch(formData);
|
||||
await onCreateBatch(batchData);
|
||||
}
|
||||
onClose();
|
||||
} catch (error) {
|
||||
console.error('Error creating production batch:', error);
|
||||
alert('Error al crear el lote de producción. Por favor, inténtalo de nuevo.');
|
||||
throw error; // Let AddModal handle error display
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Define sections for StatusModal
|
||||
const sections: StatusModalSection[] = [
|
||||
const statusConfig = useMemo(() => ({
|
||||
color: statusColors.inProgress.primary,
|
||||
text: 'Nueva Orden',
|
||||
icon: Plus,
|
||||
isCritical: false,
|
||||
isHighlight: true
|
||||
}), []);
|
||||
|
||||
// Auto-calculate end time based on start time and duration
|
||||
const calculateEndTime = (startTime: string, durationMinutes: number): string => {
|
||||
if (!startTime || !durationMinutes) return '';
|
||||
const start = new Date(startTime);
|
||||
const end = new Date(start.getTime() + durationMinutes * 60000);
|
||||
return end.toISOString().slice(0, 16);
|
||||
};
|
||||
|
||||
const sections = useMemo(() => [
|
||||
{
|
||||
title: 'Información del Producto',
|
||||
icon: Package,
|
||||
fields: [
|
||||
{
|
||||
label: 'Producto a Producir *',
|
||||
value: formData.product_id,
|
||||
type: 'select',
|
||||
editable: true,
|
||||
label: 'Producto a Producir',
|
||||
name: 'product_id',
|
||||
type: 'select' as const,
|
||||
required: true,
|
||||
options: productOptions,
|
||||
placeholder: 'Seleccionar producto...',
|
||||
span: 2
|
||||
},
|
||||
{
|
||||
label: 'Receta a Utilizar',
|
||||
value: formData.recipe_id || '',
|
||||
type: 'select',
|
||||
editable: true,
|
||||
name: 'recipe_id',
|
||||
type: 'select' as const,
|
||||
options: recipeOptions,
|
||||
placeholder: 'Seleccionar receta...',
|
||||
span: 2
|
||||
},
|
||||
{
|
||||
label: 'Número de Lote',
|
||||
value: formData.batch_number || '',
|
||||
type: 'text',
|
||||
editable: true,
|
||||
name: 'batch_number',
|
||||
type: 'text' as const,
|
||||
placeholder: 'Se generará automáticamente si se deja vacío'
|
||||
}
|
||||
]
|
||||
@@ -193,32 +156,39 @@ export const CreateProductionBatchModal: React.FC<CreateProductionBatchModalProp
|
||||
icon: Clock,
|
||||
fields: [
|
||||
{
|
||||
label: 'Inicio Planificado *',
|
||||
value: formData.planned_start_time,
|
||||
type: 'datetime-local',
|
||||
editable: true,
|
||||
label: 'Inicio Planificado',
|
||||
name: 'planned_start_time',
|
||||
type: 'date' as const,
|
||||
required: true
|
||||
},
|
||||
{
|
||||
label: 'Fin Planificado *',
|
||||
value: formData.planned_end_time,
|
||||
type: 'datetime-local',
|
||||
editable: true,
|
||||
required: true
|
||||
label: 'Fin Planificado',
|
||||
name: 'planned_end_time',
|
||||
type: 'date' as const,
|
||||
required: true,
|
||||
helpText: 'Se calcula automáticamente basado en inicio y duración'
|
||||
},
|
||||
{
|
||||
label: 'Cantidad Planificada *',
|
||||
value: formData.planned_quantity,
|
||||
type: 'number',
|
||||
editable: true,
|
||||
required: true
|
||||
label: 'Cantidad Planificada',
|
||||
name: 'planned_quantity',
|
||||
type: 'number' as const,
|
||||
required: true,
|
||||
defaultValue: 1,
|
||||
validation: (value: string | number) => {
|
||||
const num = Number(value);
|
||||
return num <= 0 ? 'La cantidad debe ser mayor a 0' : null;
|
||||
}
|
||||
},
|
||||
{
|
||||
label: 'Duración (minutos) *',
|
||||
value: formData.planned_duration_minutes,
|
||||
type: 'number',
|
||||
editable: true,
|
||||
required: true
|
||||
label: 'Duración (minutos)',
|
||||
name: 'planned_duration_minutes',
|
||||
type: 'number' as const,
|
||||
required: true,
|
||||
defaultValue: 60,
|
||||
validation: (value: string | number) => {
|
||||
const num = Number(value);
|
||||
return num <= 0 ? 'La duración debe ser mayor a 0' : null;
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
@@ -227,32 +197,35 @@ export const CreateProductionBatchModal: React.FC<CreateProductionBatchModalProp
|
||||
icon: AlertCircle,
|
||||
fields: [
|
||||
{
|
||||
label: 'Prioridad *',
|
||||
value: formData.priority,
|
||||
type: 'select',
|
||||
editable: true,
|
||||
label: 'Prioridad',
|
||||
name: 'priority',
|
||||
type: 'select' as const,
|
||||
required: true,
|
||||
options: productionEnums.getProductionPriorityOptions()
|
||||
defaultValue: ProductionPriorityEnum.MEDIUM,
|
||||
options: Object.values(ProductionPriorityEnum).map(value => ({
|
||||
value,
|
||||
label: t(`production:priority.${value.toLowerCase()}`)
|
||||
}))
|
||||
},
|
||||
{
|
||||
label: 'Orden Urgente',
|
||||
value: formData.is_rush_order ? 'Sí' : 'No',
|
||||
type: 'select',
|
||||
editable: true,
|
||||
name: 'is_rush_order',
|
||||
type: 'select' as const,
|
||||
options: [
|
||||
{ label: 'Sí', value: 'true' },
|
||||
{ label: 'No', value: 'false' }
|
||||
]
|
||||
{ label: 'No', value: false },
|
||||
{ label: 'Sí', value: true }
|
||||
],
|
||||
defaultValue: false
|
||||
},
|
||||
{
|
||||
label: 'Receta Especial',
|
||||
value: formData.is_special_recipe ? 'Sí' : 'No',
|
||||
type: 'select',
|
||||
editable: true,
|
||||
name: 'is_special_recipe',
|
||||
type: 'select' as const,
|
||||
options: [
|
||||
{ label: 'Sí', value: 'true' },
|
||||
{ label: 'No', value: 'false' }
|
||||
]
|
||||
{ label: 'No', value: false },
|
||||
{ label: 'Sí', value: true }
|
||||
],
|
||||
defaultValue: false
|
||||
}
|
||||
]
|
||||
},
|
||||
@@ -262,120 +235,34 @@ export const CreateProductionBatchModal: React.FC<CreateProductionBatchModalProp
|
||||
fields: [
|
||||
{
|
||||
label: 'Personal Asignado',
|
||||
value: Array.isArray(formData.staff_assigned) ? formData.staff_assigned.join(', ') : '',
|
||||
type: 'text',
|
||||
editable: true,
|
||||
name: 'staff_assigned',
|
||||
type: 'text' as const,
|
||||
placeholder: 'Separar nombres con comas (opcional)',
|
||||
span: 2
|
||||
span: 2,
|
||||
helpText: 'Ej: Juan Pérez, María García'
|
||||
},
|
||||
{
|
||||
label: 'Notas de Producción',
|
||||
value: formData.production_notes || '',
|
||||
type: 'text',
|
||||
editable: true,
|
||||
name: 'production_notes',
|
||||
type: 'textarea' as const,
|
||||
placeholder: 'Instrucciones especiales, observaciones, etc.',
|
||||
span: 2
|
||||
}
|
||||
]
|
||||
}
|
||||
];
|
||||
|
||||
// Handle field changes
|
||||
const handleFieldChange = (sectionIndex: number, fieldIndex: number, value: string | number) => {
|
||||
// Map section and field indices to form data properties
|
||||
switch (sectionIndex) {
|
||||
case 0: // Product Information
|
||||
switch (fieldIndex) {
|
||||
case 0: // Product ID
|
||||
const selectedProduct = finishedProducts.find(p => p.id === value);
|
||||
setFormData(prev => ({
|
||||
...prev,
|
||||
product_id: String(value),
|
||||
product_name: selectedProduct?.name || ''
|
||||
}));
|
||||
break;
|
||||
case 1: // Recipe ID
|
||||
setFormData(prev => ({ ...prev, recipe_id: String(value) }));
|
||||
break;
|
||||
case 2: // Batch Number
|
||||
setFormData(prev => ({ ...prev, batch_number: String(value) }));
|
||||
break;
|
||||
}
|
||||
break;
|
||||
case 1: // Production Planning
|
||||
switch (fieldIndex) {
|
||||
case 0: // Start Time
|
||||
const startTime = String(value);
|
||||
setFormData(prev => ({ ...prev, planned_start_time: startTime }));
|
||||
// Auto-calculate end time
|
||||
if (startTime && formData.planned_duration_minutes > 0) {
|
||||
const start = new Date(startTime);
|
||||
const end = new Date(start.getTime() + formData.planned_duration_minutes * 60000);
|
||||
const endTimeString = end.toISOString().slice(0, 16);
|
||||
setFormData(prev => ({ ...prev, planned_end_time: endTimeString }));
|
||||
}
|
||||
break;
|
||||
case 1: // End Time
|
||||
setFormData(prev => ({ ...prev, planned_end_time: String(value) }));
|
||||
break;
|
||||
case 2: // Quantity
|
||||
setFormData(prev => ({ ...prev, planned_quantity: Number(value) || 1 }));
|
||||
break;
|
||||
case 3: // Duration
|
||||
const duration = Number(value) || 60;
|
||||
setFormData(prev => ({ ...prev, planned_duration_minutes: duration }));
|
||||
// Auto-calculate end time
|
||||
if (formData.planned_start_time && duration > 0) {
|
||||
const start = new Date(formData.planned_start_time);
|
||||
const end = new Date(start.getTime() + duration * 60000);
|
||||
const endTimeString = end.toISOString().slice(0, 16);
|
||||
setFormData(prev => ({ ...prev, planned_end_time: endTimeString }));
|
||||
}
|
||||
break;
|
||||
}
|
||||
break;
|
||||
case 2: // Configuration
|
||||
switch (fieldIndex) {
|
||||
case 0: // Priority
|
||||
setFormData(prev => ({ ...prev, priority: value as ProductionPriorityEnum }));
|
||||
break;
|
||||
case 1: // Rush Order
|
||||
setFormData(prev => ({ ...prev, is_rush_order: String(value) === 'true' }));
|
||||
break;
|
||||
case 2: // Special Recipe
|
||||
setFormData(prev => ({ ...prev, is_special_recipe: String(value) === 'true' }));
|
||||
break;
|
||||
}
|
||||
break;
|
||||
case 3: // Resources and Notes
|
||||
switch (fieldIndex) {
|
||||
case 0: // Staff Assigned
|
||||
const staff = String(value).split(',').map(s => s.trim()).filter(s => s.length > 0);
|
||||
setFormData(prev => ({ ...prev, staff_assigned: staff }));
|
||||
break;
|
||||
case 1: // Production Notes
|
||||
setFormData(prev => ({ ...prev, production_notes: String(value) }));
|
||||
break;
|
||||
}
|
||||
break;
|
||||
}
|
||||
};
|
||||
], [productOptions, recipeOptions, t]);
|
||||
|
||||
return (
|
||||
<StatusModal
|
||||
<AddModal
|
||||
isOpen={isOpen}
|
||||
onClose={onClose}
|
||||
mode="edit"
|
||||
title="Nueva Orden de Producción"
|
||||
subtitle="Crear un nuevo lote de producción"
|
||||
statusIndicator={statusConfig}
|
||||
sections={sections}
|
||||
size="xl"
|
||||
loading={isSubmitting}
|
||||
onFieldChange={handleFieldChange}
|
||||
image={undefined}
|
||||
onSave={handleSubmit}
|
||||
onCancel={onClose}
|
||||
showDefaultActions={true}
|
||||
loading={loading}
|
||||
onSave={handleSave}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -3,7 +3,7 @@ import { Clock, Timer, CheckCircle, AlertCircle, Package, Play, Pause, X, Edit,
|
||||
import { StatusCard, StatusIndicatorConfig } from '../../ui/StatusCard/StatusCard';
|
||||
import { statusColors } from '../../../styles/colors';
|
||||
import { ProductionBatchResponse, ProductionStatus, ProductionPriority } from '../../../api/types/production';
|
||||
import { useProductionEnums } from '../../../utils/enumHelpers';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
export interface ProductionStatusCardProps {
|
||||
batch: ProductionBatchResponse;
|
||||
@@ -32,7 +32,7 @@ export const ProductionStatusCard: React.FC<ProductionStatusCardProps> = ({
|
||||
showDetailedProgress = true,
|
||||
compact = false
|
||||
}) => {
|
||||
const productionEnums = useProductionEnums();
|
||||
const { t } = useTranslation(['production', 'common']);
|
||||
|
||||
const getProductionStatusConfig = (status: ProductionStatus, priority: ProductionPriority): StatusIndicatorConfig => {
|
||||
const statusConfig = {
|
||||
@@ -76,7 +76,7 @@ export const ProductionStatusCard: React.FC<ProductionStatusCardProps> = ({
|
||||
|
||||
return {
|
||||
color: getStatusColorForProduction(status),
|
||||
text: productionEnums.getProductionStatusLabel(status),
|
||||
text: t(`production:status.${status}`),
|
||||
icon: Icon,
|
||||
isCritical,
|
||||
isHighlight
|
||||
@@ -313,7 +313,7 @@ export const ProductionStatusCard: React.FC<ProductionStatusCardProps> = ({
|
||||
|
||||
return {
|
||||
label: 'Prioridad',
|
||||
value: productionEnums.getProductionPriorityLabel(batch.priority)
|
||||
value: t(`production:priority.${batch.priority}`)
|
||||
};
|
||||
};
|
||||
|
||||
|
||||
@@ -26,7 +26,7 @@ import {
|
||||
Modal,
|
||||
StatsGrid
|
||||
} from '../../ui';
|
||||
import { LoadingSpinner } from '../../shared';
|
||||
import { LoadingSpinner } from '../../ui';
|
||||
import { PageHeader } from '../../layout';
|
||||
import { useCurrentTenant } from '../../../stores/tenant.store';
|
||||
import {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import React from 'react';
|
||||
import { Card } from '../../ui';
|
||||
import { Card } from '../../../ui';
|
||||
import { LucideIcon } from 'lucide-react';
|
||||
|
||||
export interface AnalyticsWidgetProps {
|
||||
@@ -2,8 +2,8 @@ import React from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Brain, TrendingUp, AlertTriangle, Target, Zap, DollarSign, Clock } from 'lucide-react';
|
||||
import { AnalyticsWidget } from '../AnalyticsWidget';
|
||||
import { Badge, Button } from '../../../ui';
|
||||
import { useCurrentTenant } from '../../../../stores/tenant.store';
|
||||
import { Badge, Button } from '../../../../ui';
|
||||
import { useCurrentTenant } from '../../../../../stores/tenant.store';
|
||||
|
||||
interface AIInsight {
|
||||
id: string;
|
||||
@@ -3,9 +3,9 @@ import { useTranslation } from 'react-i18next';
|
||||
import { BarChart3, TrendingUp, AlertTriangle, Zap } from 'lucide-react';
|
||||
import { AnalyticsWidget } from '../AnalyticsWidget';
|
||||
import { AnalyticsChart, ChartSeries } from '../AnalyticsChart';
|
||||
import { Button, ProgressBar } from '../../../ui';
|
||||
import { useProductionDashboard, useCapacityStatus } from '../../../../api/hooks/production';
|
||||
import { useCurrentTenant } from '../../../../stores/tenant.store';
|
||||
import { Button, ProgressBar } from '../../../../ui';
|
||||
import { useProductionDashboard, useCapacityStatus } from '../../../../../api/hooks/production';
|
||||
import { useCurrentTenant } from '../../../../../stores/tenant.store';
|
||||
|
||||
interface CapacityData {
|
||||
resource: string;
|
||||
@@ -3,9 +3,9 @@ import { useTranslation } from 'react-i18next';
|
||||
import { DollarSign, TrendingUp, TrendingDown, PieChart } from 'lucide-react';
|
||||
import { AnalyticsWidget } from '../AnalyticsWidget';
|
||||
import { AnalyticsChart, ChartSeries } from '../AnalyticsChart';
|
||||
import { Button } from '../../../ui';
|
||||
import { useActiveBatches } from '../../../../api/hooks/production';
|
||||
import { useCurrentTenant } from '../../../../stores/tenant.store';
|
||||
import { Button } from '../../../../ui';
|
||||
import { useActiveBatches } from '../../../../../api/hooks/production';
|
||||
import { useCurrentTenant } from '../../../../../stores/tenant.store';
|
||||
|
||||
interface ProductCostData {
|
||||
product: string;
|
||||
@@ -3,8 +3,8 @@ import { useTranslation } from 'react-i18next';
|
||||
import { Zap, TrendingUp, TrendingDown, BarChart3, Target } from 'lucide-react';
|
||||
import { AnalyticsWidget } from '../AnalyticsWidget';
|
||||
import { AnalyticsChart, ChartSeries } from '../AnalyticsChart';
|
||||
import { Button } from '../../../ui';
|
||||
import { useCurrentTenant } from '../../../../stores/tenant.store';
|
||||
import { Button } from '../../../../ui';
|
||||
import { useCurrentTenant } from '../../../../../stores/tenant.store';
|
||||
|
||||
interface EquipmentEfficiencyData {
|
||||
equipmentId: string;
|
||||
@@ -3,8 +3,8 @@ import { useTranslation } from 'react-i18next';
|
||||
import { Settings, AlertTriangle, CheckCircle, Clock, Zap } from 'lucide-react';
|
||||
import { AnalyticsWidget } from '../AnalyticsWidget';
|
||||
import { AnalyticsChart, ChartSeries } from '../AnalyticsChart';
|
||||
import { Badge, Button } from '../../../ui';
|
||||
import { useCurrentTenant } from '../../../../stores/tenant.store';
|
||||
import { Badge, Button } from '../../../../ui';
|
||||
import { useCurrentTenant } from '../../../../../stores/tenant.store';
|
||||
|
||||
interface EquipmentStatus {
|
||||
id: string;
|
||||
@@ -2,10 +2,10 @@ import React from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Activity, Clock, AlertCircle, CheckCircle, Play, Pause } from 'lucide-react';
|
||||
import { AnalyticsWidget } from '../AnalyticsWidget';
|
||||
import { Badge, ProgressBar, Button } from '../../../ui';
|
||||
import { useActiveBatches } from '../../../../api/hooks/production';
|
||||
import { useCurrentTenant } from '../../../../stores/tenant.store';
|
||||
import { ProductionStatus } from '../../../../api/types/production';
|
||||
import { Badge, ProgressBar, Button } from '../../../../ui';
|
||||
import { useActiveBatches } from '../../../../../api/hooks/production';
|
||||
import { useCurrentTenant } from '../../../../../stores/tenant.store';
|
||||
import { ProductionStatus } from '../../../../../api/types/production';
|
||||
|
||||
export const LiveBatchTrackerWidget: React.FC = () => {
|
||||
const { t } = useTranslation('production');
|
||||
@@ -2,8 +2,8 @@ import React from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Calendar, Clock, Wrench, AlertCircle, CheckCircle2 } from 'lucide-react';
|
||||
import { AnalyticsWidget } from '../AnalyticsWidget';
|
||||
import { Badge, Button } from '../../../ui';
|
||||
import { useCurrentTenant } from '../../../../stores/tenant.store';
|
||||
import { Badge, Button } from '../../../../ui';
|
||||
import { useCurrentTenant } from '../../../../../stores/tenant.store';
|
||||
|
||||
interface MaintenanceTask {
|
||||
id: string;
|
||||
@@ -3,9 +3,9 @@ import { useTranslation } from 'react-i18next';
|
||||
import { Clock, TrendingUp, TrendingDown, Target } from 'lucide-react';
|
||||
import { AnalyticsWidget } from '../AnalyticsWidget';
|
||||
import { AnalyticsChart, ChartSeries } from '../AnalyticsChart';
|
||||
import { Button } from '../../../ui';
|
||||
import { useProductionDashboard } from '../../../../api/hooks/production';
|
||||
import { useCurrentTenant } from '../../../../stores/tenant.store';
|
||||
import { Button } from '../../../../ui';
|
||||
import { useProductionDashboard } from '../../../../../api/hooks/production';
|
||||
import { useCurrentTenant } from '../../../../../stores/tenant.store';
|
||||
|
||||
export const OnTimeCompletionWidget: React.FC = () => {
|
||||
const { t } = useTranslation('production');
|
||||
@@ -3,8 +3,8 @@ import { useTranslation } from 'react-i18next';
|
||||
import { Brain, AlertTriangle, TrendingDown, Calendar, Wrench, Target } from 'lucide-react';
|
||||
import { AnalyticsWidget } from '../AnalyticsWidget';
|
||||
import { AnalyticsChart, ChartSeries } from '../AnalyticsChart';
|
||||
import { Badge, Button } from '../../../ui';
|
||||
import { useCurrentTenant } from '../../../../stores/tenant.store';
|
||||
import { Badge, Button } from '../../../../ui';
|
||||
import { useCurrentTenant } from '../../../../../stores/tenant.store';
|
||||
|
||||
interface PredictiveMaintenanceAlert {
|
||||
id: string;
|
||||
@@ -3,9 +3,9 @@ import { useTranslation } from 'react-i18next';
|
||||
import { Star, TrendingUp, TrendingDown, Award } from 'lucide-react';
|
||||
import { AnalyticsWidget } from '../AnalyticsWidget';
|
||||
import { AnalyticsChart, ChartSeries } from '../AnalyticsChart';
|
||||
import { Button } from '../../../ui';
|
||||
import { useActiveBatches } from '../../../../api/hooks/production';
|
||||
import { useCurrentTenant } from '../../../../stores/tenant.store';
|
||||
import { Button } from '../../../../ui';
|
||||
import { useActiveBatches } from '../../../../../api/hooks/production';
|
||||
import { useCurrentTenant } from '../../../../../stores/tenant.store';
|
||||
|
||||
export const QualityScoreTrendsWidget: React.FC = () => {
|
||||
const { t } = useTranslation('production');
|
||||
@@ -2,10 +2,10 @@ import React from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Calendar, Clock, Users, Target, AlertCircle } from 'lucide-react';
|
||||
import { AnalyticsWidget } from '../AnalyticsWidget';
|
||||
import { Badge, Button } from '../../../ui';
|
||||
import { useProductionDashboard, useProductionSchedule } from '../../../../api/hooks/production';
|
||||
import { useCurrentTenant } from '../../../../stores/tenant.store';
|
||||
import { ProductionStatus } from '../../../../api/types/production';
|
||||
import { Badge, Button } from '../../../../ui';
|
||||
import { useProductionDashboard, useProductionSchedule } from '../../../../../api/hooks/production';
|
||||
import { useCurrentTenant } from '../../../../../stores/tenant.store';
|
||||
import { ProductionStatus } from '../../../../../api/types/production';
|
||||
|
||||
export const TodaysScheduleSummaryWidget: React.FC = () => {
|
||||
const { t } = useTranslation('production');
|
||||
@@ -3,9 +3,9 @@ import { useTranslation } from 'react-i18next';
|
||||
import { AlertCircle, Eye, Camera, CheckSquare } from 'lucide-react';
|
||||
import { AnalyticsWidget } from '../AnalyticsWidget';
|
||||
import { AnalyticsChart, ChartSeries } from '../AnalyticsChart';
|
||||
import { Button, Badge } from '../../../ui';
|
||||
import { useActiveBatches } from '../../../../api/hooks/production';
|
||||
import { useCurrentTenant } from '../../../../stores/tenant.store';
|
||||
import { Button, Badge } from '../../../../ui';
|
||||
import { useActiveBatches } from '../../../../../api/hooks/production';
|
||||
import { useCurrentTenant } from '../../../../../stores/tenant.store';
|
||||
|
||||
interface DefectType {
|
||||
type: string;
|
||||
@@ -3,9 +3,9 @@ import { useTranslation } from 'react-i18next';
|
||||
import { AlertTriangle, Trash2, Target, TrendingDown } from 'lucide-react';
|
||||
import { AnalyticsWidget } from '../AnalyticsWidget';
|
||||
import { AnalyticsChart, ChartSeries } from '../AnalyticsChart';
|
||||
import { Button, Badge } from '../../../ui';
|
||||
import { useActiveBatches } from '../../../../api/hooks/production';
|
||||
import { useCurrentTenant } from '../../../../stores/tenant.store';
|
||||
import { Button, Badge } from '../../../../ui';
|
||||
import { useActiveBatches } from '../../../../../api/hooks/production';
|
||||
import { useCurrentTenant } from '../../../../../stores/tenant.store';
|
||||
|
||||
interface WasteSource {
|
||||
source: string;
|
||||
@@ -3,9 +3,9 @@ import { useTranslation } from 'react-i18next';
|
||||
import { Target, TrendingUp, Award, BarChart3 } from 'lucide-react';
|
||||
import { AnalyticsWidget } from '../AnalyticsWidget';
|
||||
import { AnalyticsChart, ChartSeries } from '../AnalyticsChart';
|
||||
import { Button, Badge } from '../../../ui';
|
||||
import { useActiveBatches } from '../../../../api/hooks/production';
|
||||
import { useCurrentTenant } from '../../../../stores/tenant.store';
|
||||
import { Button, Badge } from '../../../../ui';
|
||||
import { useActiveBatches } from '../../../../../api/hooks/production';
|
||||
import { useCurrentTenant } from '../../../../../stores/tenant.store';
|
||||
|
||||
interface ProductYieldData {
|
||||
product: string;
|
||||
@@ -1,9 +1,10 @@
|
||||
import React, { useState, useMemo, useEffect } from 'react';
|
||||
import { ChefHat, Package, Clock, Euro, Star, Plus, Trash2, X, Timer, Thermometer, Settings, FileText } from 'lucide-react';
|
||||
import { StatusModal, StatusModalField, StatusModalSection } from '../../ui/StatusModal/StatusModal';
|
||||
import React, { useState, useMemo } from 'react';
|
||||
import { ChefHat, Package, Clock, Star, Plus, Trash2, Settings, FileText } from 'lucide-react';
|
||||
import { AddModal } from '../../ui/AddModal/AddModal';
|
||||
import { RecipeCreate, RecipeIngredientCreate, MeasurementUnit } from '../../../api/types/recipes';
|
||||
import { useIngredients } from '../../../api/hooks/inventory';
|
||||
import { useCurrentTenant } from '../../../stores/tenant.store';
|
||||
import { statusColors } from '../../../styles/colors';
|
||||
|
||||
interface CreateRecipeModalProps {
|
||||
isOpen: boolean;
|
||||
@@ -15,7 +16,7 @@ interface CreateRecipeModalProps {
|
||||
* CreateRecipeModal - Modal for creating a new recipe
|
||||
* Comprehensive form for adding new recipes
|
||||
*/
|
||||
// Custom Ingredients Component for StatusModal
|
||||
// Custom Ingredients Component for AddModal
|
||||
const IngredientsComponent: React.FC<{
|
||||
value: RecipeIngredientCreate[];
|
||||
onChange: (value: RecipeIngredientCreate[]) => void;
|
||||
@@ -84,7 +85,7 @@ const IngredientsComponent: React.FC<{
|
||||
className="w-full px-2 py-1.5 border border-[var(--border-secondary)] rounded focus:outline-none focus:ring-2 focus:ring-[var(--color-primary)] bg-[var(--bg-primary)] text-[var(--text-primary)] text-sm"
|
||||
>
|
||||
<option value="">Seleccionar...</option>
|
||||
{availableIngredients.map(ing => (
|
||||
{(availableIngredients || []).map(ing => (
|
||||
<option key={ing.value} value={ing.value}>{ing.label}</option>
|
||||
))}
|
||||
</select>
|
||||
@@ -141,16 +142,6 @@ export const CreateRecipeModal: React.FC<CreateRecipeModalProps> = ({
|
||||
onClose,
|
||||
onCreateRecipe
|
||||
}) => {
|
||||
// Extended form data interface to include UI-specific fields
|
||||
interface ExtendedFormData extends Omit<RecipeCreate, 'allergen_info' | 'dietary_tags' | 'nutritional_info'> {
|
||||
allergen_info_text: string;
|
||||
dietary_tags_text: string;
|
||||
nutritional_info_text: string;
|
||||
allergen_info: Record<string, any> | null;
|
||||
dietary_tags: Record<string, any> | null;
|
||||
nutritional_info: Record<string, any> | null;
|
||||
}
|
||||
|
||||
const [ingredientsList, setIngredientsList] = useState<RecipeIngredientCreate[]>([{
|
||||
ingredient_id: '',
|
||||
quantity: 1,
|
||||
@@ -158,50 +149,7 @@ export const CreateRecipeModal: React.FC<CreateRecipeModalProps> = ({
|
||||
ingredient_order: 1,
|
||||
is_optional: false
|
||||
}]);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
|
||||
const [formData, setFormData] = useState<ExtendedFormData>({
|
||||
name: '',
|
||||
recipe_code: '',
|
||||
version: '1.0',
|
||||
finished_product_id: '', // This should come from a product selector
|
||||
description: '',
|
||||
category: '',
|
||||
cuisine_type: '',
|
||||
difficulty_level: 1,
|
||||
yield_quantity: 1,
|
||||
yield_unit: MeasurementUnit.UNITS,
|
||||
prep_time_minutes: 0,
|
||||
cook_time_minutes: 0,
|
||||
total_time_minutes: 0,
|
||||
rest_time_minutes: 0,
|
||||
target_margin_percentage: 30,
|
||||
instructions: null,
|
||||
preparation_notes: '',
|
||||
storage_instructions: '',
|
||||
quality_standards: '',
|
||||
quality_check_configuration: null,
|
||||
quality_check_points: null,
|
||||
common_issues: null,
|
||||
serves_count: 1,
|
||||
is_seasonal: false,
|
||||
season_start_month: undefined,
|
||||
season_end_month: undefined,
|
||||
is_signature_item: false,
|
||||
batch_size_multiplier: 1.0,
|
||||
minimum_batch_size: undefined,
|
||||
maximum_batch_size: undefined,
|
||||
optimal_production_temperature: undefined,
|
||||
optimal_humidity: undefined,
|
||||
allergen_info_text: '',
|
||||
dietary_tags_text: '',
|
||||
nutritional_info_text: '',
|
||||
allergen_info: null,
|
||||
dietary_tags: null,
|
||||
nutritional_info: null,
|
||||
ingredients: []
|
||||
});
|
||||
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
// Get tenant and fetch inventory data
|
||||
const currentTenant = useCurrentTenant();
|
||||
@@ -215,7 +163,8 @@ export const CreateRecipeModal: React.FC<CreateRecipeModalProps> = ({
|
||||
|
||||
// Separate finished products and ingredients
|
||||
const finishedProducts = useMemo(() =>
|
||||
inventoryItems.filter(item => item.product_type === 'finished_product')
|
||||
(inventoryItems || [])
|
||||
.filter(item => item.product_type === 'finished_product')
|
||||
.map(product => ({
|
||||
value: product.id,
|
||||
label: `${product.name} (${product.category || 'Sin categoría'})`
|
||||
@@ -225,7 +174,8 @@ export const CreateRecipeModal: React.FC<CreateRecipeModalProps> = ({
|
||||
|
||||
// Available ingredients for recipe ingredients
|
||||
const availableIngredients = useMemo(() =>
|
||||
inventoryItems.filter(item => item.product_type !== 'finished_product')
|
||||
(inventoryItems || [])
|
||||
.filter(item => item.product_type !== 'finished_product')
|
||||
.map(ingredient => ({
|
||||
value: ingredient.id,
|
||||
label: `${ingredient.name} (${ingredient.category || 'Sin categoría'})`
|
||||
@@ -233,7 +183,6 @@ export const CreateRecipeModal: React.FC<CreateRecipeModalProps> = ({
|
||||
[inventoryItems]
|
||||
);
|
||||
|
||||
|
||||
// Category options
|
||||
const categoryOptions = [
|
||||
{ value: 'bread', label: 'Pan' },
|
||||
@@ -285,100 +234,92 @@ export const CreateRecipeModal: React.FC<CreateRecipeModalProps> = ({
|
||||
{ value: 12, label: 'Diciembre' }
|
||||
];
|
||||
|
||||
|
||||
// Auto-calculate total time when time values change
|
||||
useEffect(() => {
|
||||
const prepTime = formData.prep_time_minutes || 0;
|
||||
const cookTime = formData.cook_time_minutes || 0;
|
||||
const restTime = formData.rest_time_minutes || 0;
|
||||
const newTotal = prepTime + cookTime + restTime;
|
||||
|
||||
if (newTotal !== formData.total_time_minutes) {
|
||||
setFormData(prev => ({
|
||||
...prev,
|
||||
total_time_minutes: newTotal
|
||||
}));
|
||||
}
|
||||
}, [formData.prep_time_minutes, formData.cook_time_minutes, formData.rest_time_minutes]);
|
||||
|
||||
const handleSubmit = async () => {
|
||||
if (!formData.name.trim()) {
|
||||
alert('El nombre de la receta es obligatorio');
|
||||
return;
|
||||
const validateIngredients = (ingredients: RecipeIngredientCreate[]): string | null => {
|
||||
if (!ingredients || ingredients.length === 0) {
|
||||
return 'Debe agregar al menos un ingrediente';
|
||||
}
|
||||
|
||||
if (!formData.category?.trim()) {
|
||||
alert('Debe seleccionar una categoría');
|
||||
return;
|
||||
const emptyIngredients = ingredients.filter(ing => !ing.ingredient_id.trim());
|
||||
if (emptyIngredients.length > 0) {
|
||||
return 'Todos los ingredientes deben tener un ingrediente seleccionado';
|
||||
}
|
||||
|
||||
if (!formData.finished_product_id.trim()) {
|
||||
alert('Debe seleccionar un producto terminado');
|
||||
return;
|
||||
const invalidQuantities = ingredients.filter(ing => ing.quantity <= 0);
|
||||
if (invalidQuantities.length > 0) {
|
||||
return 'Todas las cantidades deben ser mayor que 0';
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
const handleSave = async (formData: Record<string, any>) => {
|
||||
// Validate ingredients
|
||||
const ingredientError = validateIngredients();
|
||||
const ingredientError = validateIngredients(formData.ingredients || []);
|
||||
if (ingredientError) {
|
||||
alert(ingredientError);
|
||||
return;
|
||||
throw new Error(ingredientError);
|
||||
}
|
||||
|
||||
// Validate seasonal dates if seasonal is enabled
|
||||
if (formData.is_seasonal) {
|
||||
if (!formData.season_start_month || !formData.season_end_month) {
|
||||
alert('Para recetas estacionales, debe especificar los meses de inicio y fin');
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Validate difficulty level
|
||||
if (!formData.difficulty_level || formData.difficulty_level < 1 || formData.difficulty_level > 5) {
|
||||
alert('Debe especificar un nivel de dificultad válido (1-5)');
|
||||
return;
|
||||
if (formData.is_seasonal && (!formData.season_start_month || !formData.season_end_month)) {
|
||||
throw new Error('Para recetas estacionales, debe especificar los meses de inicio y fin');
|
||||
}
|
||||
|
||||
// Validate batch sizes
|
||||
if (formData.minimum_batch_size && formData.maximum_batch_size) {
|
||||
if (formData.minimum_batch_size > formData.maximum_batch_size) {
|
||||
alert('El tamaño mínimo de lote no puede ser mayor que el máximo');
|
||||
return;
|
||||
}
|
||||
if (formData.minimum_batch_size && formData.maximum_batch_size && formData.minimum_batch_size > formData.maximum_batch_size) {
|
||||
throw new Error('El tamaño mínimo de lote no puede ser mayor que el máximo');
|
||||
}
|
||||
|
||||
setLoading(true);
|
||||
try {
|
||||
setIsLoading(true);
|
||||
|
||||
// Generate recipe code if not provided
|
||||
const recipeCode = formData.recipe_code ||
|
||||
formData.name.substring(0, 3).toUpperCase() +
|
||||
String(Date.now()).slice(-3);
|
||||
|
||||
// Calculate total time including rest time
|
||||
const totalTime = (formData.prep_time_minutes || 0) +
|
||||
(formData.cook_time_minutes || 0) +
|
||||
(formData.rest_time_minutes || 0);
|
||||
const totalTime = (Number(formData.prep_time_minutes) || 0) +
|
||||
(Number(formData.cook_time_minutes) || 0) +
|
||||
(Number(formData.rest_time_minutes) || 0);
|
||||
|
||||
const recipeData: RecipeCreate = {
|
||||
...formData,
|
||||
version: formData.version || '1.0',
|
||||
name: formData.name,
|
||||
recipe_code: recipeCode,
|
||||
version: formData.version || '1.0',
|
||||
finished_product_id: formData.finished_product_id,
|
||||
description: formData.description || '',
|
||||
category: formData.category,
|
||||
cuisine_type: formData.cuisine_type || '',
|
||||
difficulty_level: Number(formData.difficulty_level),
|
||||
yield_quantity: Number(formData.yield_quantity),
|
||||
yield_unit: formData.yield_unit as MeasurementUnit,
|
||||
prep_time_minutes: Number(formData.prep_time_minutes) || 0,
|
||||
cook_time_minutes: Number(formData.cook_time_minutes) || 0,
|
||||
total_time_minutes: totalTime,
|
||||
// Transform string fields to proper objects/dictionaries
|
||||
rest_time_minutes: Number(formData.rest_time_minutes) || 0,
|
||||
target_margin_percentage: Number(formData.target_margin_percentage) || 30,
|
||||
instructions: formData.preparation_notes ? { steps: formData.preparation_notes } : null,
|
||||
preparation_notes: formData.preparation_notes || '',
|
||||
storage_instructions: formData.storage_instructions || '',
|
||||
quality_standards: formData.quality_standards || '',
|
||||
quality_check_configuration: null,
|
||||
quality_check_points: null,
|
||||
common_issues: null,
|
||||
serves_count: Number(formData.serves_count) || 1,
|
||||
is_seasonal: formData.is_seasonal || false,
|
||||
season_start_month: formData.is_seasonal ? Number(formData.season_start_month) : undefined,
|
||||
season_end_month: formData.is_seasonal ? Number(formData.season_end_month) : undefined,
|
||||
is_signature_item: formData.is_signature_item || false,
|
||||
batch_size_multiplier: Number(formData.batch_size_multiplier) || 1.0,
|
||||
minimum_batch_size: formData.minimum_batch_size ? Number(formData.minimum_batch_size) : undefined,
|
||||
maximum_batch_size: formData.maximum_batch_size ? Number(formData.maximum_batch_size) : undefined,
|
||||
optimal_production_temperature: formData.optimal_production_temperature ? Number(formData.optimal_production_temperature) : undefined,
|
||||
optimal_humidity: formData.optimal_humidity ? Number(formData.optimal_humidity) : undefined,
|
||||
allergen_info: formData.allergen_info_text ? { allergens: formData.allergen_info_text.split(',').map((a: string) => a.trim()) } : null,
|
||||
dietary_tags: formData.dietary_tags_text ? { tags: formData.dietary_tags_text.split(',').map((t: string) => t.trim()) } : null,
|
||||
nutritional_info: formData.nutritional_info_text ? { info: formData.nutritional_info_text } : null,
|
||||
// Clean up undefined values for optional fields
|
||||
season_start_month: formData.is_seasonal ? formData.season_start_month : undefined,
|
||||
season_end_month: formData.is_seasonal ? formData.season_end_month : undefined,
|
||||
minimum_batch_size: formData.minimum_batch_size || undefined,
|
||||
maximum_batch_size: formData.maximum_batch_size || undefined,
|
||||
optimal_production_temperature: formData.optimal_production_temperature || undefined,
|
||||
optimal_humidity: formData.optimal_humidity || undefined,
|
||||
// Validate and use the ingredients list from state
|
||||
ingredients: ingredientsList.filter(ing => ing.ingredient_id.trim() !== '')
|
||||
.map((ing, index) => ({
|
||||
// Use the ingredients from form data
|
||||
ingredients: (formData.ingredients || []).filter((ing: RecipeIngredientCreate) => ing.ingredient_id.trim() !== '')
|
||||
.map((ing: RecipeIngredientCreate, index: number) => ({
|
||||
...ing,
|
||||
ingredient_order: index + 1
|
||||
}))
|
||||
@@ -388,9 +329,7 @@ export const CreateRecipeModal: React.FC<CreateRecipeModalProps> = ({
|
||||
await onCreateRecipe(recipeData);
|
||||
}
|
||||
|
||||
onClose();
|
||||
|
||||
// Reset form and ingredients list
|
||||
// Reset ingredients list (AddModal will handle form reset)
|
||||
setIngredientsList([{
|
||||
ingredient_id: '',
|
||||
quantity: 1,
|
||||
@@ -398,225 +337,87 @@ export const CreateRecipeModal: React.FC<CreateRecipeModalProps> = ({
|
||||
ingredient_order: 1,
|
||||
is_optional: false
|
||||
}]);
|
||||
setFormData({
|
||||
name: '',
|
||||
recipe_code: '',
|
||||
version: '1.0',
|
||||
finished_product_id: '',
|
||||
description: '',
|
||||
category: '',
|
||||
cuisine_type: '',
|
||||
difficulty_level: 1,
|
||||
yield_quantity: 1,
|
||||
yield_unit: MeasurementUnit.UNITS,
|
||||
prep_time_minutes: 0,
|
||||
cook_time_minutes: 0,
|
||||
total_time_minutes: 0,
|
||||
rest_time_minutes: 0,
|
||||
target_margin_percentage: 30,
|
||||
instructions: null,
|
||||
preparation_notes: '',
|
||||
storage_instructions: '',
|
||||
quality_standards: '',
|
||||
quality_check_configuration: null,
|
||||
quality_check_points: null,
|
||||
common_issues: null,
|
||||
serves_count: 1,
|
||||
is_seasonal: false,
|
||||
season_start_month: undefined,
|
||||
season_end_month: undefined,
|
||||
is_signature_item: false,
|
||||
batch_size_multiplier: 1.0,
|
||||
minimum_batch_size: undefined,
|
||||
maximum_batch_size: undefined,
|
||||
optimal_production_temperature: undefined,
|
||||
optimal_humidity: undefined,
|
||||
allergen_info_text: '',
|
||||
dietary_tags_text: '',
|
||||
nutritional_info_text: '',
|
||||
allergen_info: null,
|
||||
dietary_tags: null,
|
||||
nutritional_info: null,
|
||||
ingredients: []
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error creating recipe:', error);
|
||||
alert('Error al crear la receta. Por favor, inténtelo de nuevo.');
|
||||
throw error; // Let AddModal handle error display
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Helper functions for ingredients management
|
||||
const addIngredient = () => {
|
||||
const newIngredient: RecipeIngredientCreate = {
|
||||
ingredient_id: '',
|
||||
quantity: 1,
|
||||
unit: MeasurementUnit.GRAMS,
|
||||
ingredient_order: ingredientsList.length + 1,
|
||||
is_optional: false
|
||||
};
|
||||
setIngredientsList(prev => [...prev, newIngredient]);
|
||||
const statusConfig = {
|
||||
color: statusColors.inProgress.primary,
|
||||
text: 'Nueva Receta',
|
||||
icon: Plus,
|
||||
isCritical: false,
|
||||
isHighlight: true
|
||||
};
|
||||
|
||||
const removeIngredient = (index: number) => {
|
||||
if (ingredientsList.length > 1) {
|
||||
setIngredientsList(prev => prev.filter((_, i) => i !== index));
|
||||
}
|
||||
};
|
||||
|
||||
const updateIngredient = (index: number, field: keyof RecipeIngredientCreate, value: any) => {
|
||||
setIngredientsList(prev => prev.map((ingredient, i) =>
|
||||
i === index ? { ...ingredient, [field]: value } : ingredient
|
||||
));
|
||||
};
|
||||
|
||||
const validateIngredients = (): string | null => {
|
||||
if (ingredientsList.length === 0) {
|
||||
return 'Debe agregar al menos un ingrediente';
|
||||
}
|
||||
|
||||
const emptyIngredients = ingredientsList.filter(ing => !ing.ingredient_id.trim());
|
||||
if (emptyIngredients.length > 0) {
|
||||
return 'Todos los ingredientes deben tener un ingrediente seleccionado';
|
||||
}
|
||||
|
||||
const invalidQuantities = ingredientsList.filter(ing => ing.quantity <= 0);
|
||||
if (invalidQuantities.length > 0) {
|
||||
return 'Todas las cantidades deben ser mayor que 0';
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
// Removed the getModalSections function since we're now using a custom modal
|
||||
|
||||
// Field change handler for StatusModal
|
||||
const handleFieldChange = (sectionIndex: number, fieldIndex: number, value: string | number) => {
|
||||
const sections = getSections();
|
||||
const section = sections[sectionIndex];
|
||||
const field = section.fields[fieldIndex];
|
||||
|
||||
// Special handling for ingredients component
|
||||
if (field.type === 'component' && field.componentProps?.onChange) {
|
||||
// This is handled directly by the component
|
||||
return;
|
||||
}
|
||||
|
||||
// Update form data based on field mapping
|
||||
const fieldMapping: Record<string, string> = {
|
||||
'Nombre': 'name',
|
||||
'Código de receta': 'recipe_code',
|
||||
'Versión': 'version',
|
||||
'Descripción': 'description',
|
||||
'Categoría': 'category',
|
||||
'Producto terminado': 'finished_product_id',
|
||||
'Tipo de cocina': 'cuisine_type',
|
||||
'Nivel de dificultad': 'difficulty_level',
|
||||
'Cantidad de rendimiento': 'yield_quantity',
|
||||
'Unidad de rendimiento': 'yield_unit',
|
||||
'Tiempo de preparación (min)': 'prep_time_minutes',
|
||||
'Tiempo de cocción (min)': 'cook_time_minutes',
|
||||
'Tiempo de reposo (min)': 'rest_time_minutes',
|
||||
'Porciones': 'serves_count',
|
||||
'Margen objetivo (%)': 'target_margin_percentage',
|
||||
'Receta estacional': 'is_seasonal',
|
||||
'Mes de inicio': 'season_start_month',
|
||||
'Mes de fin': 'season_end_month',
|
||||
'Receta estrella': 'is_signature_item',
|
||||
'Multiplicador de lote': 'batch_size_multiplier',
|
||||
'Tamaño mínimo de lote': 'minimum_batch_size',
|
||||
'Tamaño máximo de lote': 'maximum_batch_size',
|
||||
'Temperatura óptima (°C)': 'optimal_production_temperature',
|
||||
'Humedad óptima (%)': 'optimal_humidity',
|
||||
'Notas de preparación': 'preparation_notes',
|
||||
'Instrucciones de almacenamiento': 'storage_instructions',
|
||||
'Estándares de calidad': 'quality_standards',
|
||||
'Información de alérgenos': 'allergen_info_text',
|
||||
'Etiquetas dietéticas': 'dietary_tags_text',
|
||||
'Información nutricional': 'nutritional_info_text',
|
||||
};
|
||||
|
||||
const fieldKey = fieldMapping[field.label];
|
||||
if (fieldKey) {
|
||||
if (fieldKey === 'is_seasonal' || fieldKey === 'is_signature_item') {
|
||||
setFormData(prev => ({ ...prev, [fieldKey]: value === 'Sí' }));
|
||||
} else if (typeof value === 'string' && ['difficulty_level', 'yield_quantity', 'prep_time_minutes', 'cook_time_minutes', 'rest_time_minutes', 'serves_count', 'target_margin_percentage', 'season_start_month', 'season_end_month', 'batch_size_multiplier', 'minimum_batch_size', 'maximum_batch_size', 'optimal_production_temperature', 'optimal_humidity'].includes(fieldKey)) {
|
||||
setFormData(prev => ({ ...prev, [fieldKey]: Number(value) || 0 }));
|
||||
} else {
|
||||
setFormData(prev => ({ ...prev, [fieldKey]: value }));
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// Create sections for StatusModal
|
||||
const getSections = (): StatusModalSection[] => [
|
||||
const sections = [
|
||||
{
|
||||
title: 'Información Básica',
|
||||
icon: ChefHat,
|
||||
fields: [
|
||||
{
|
||||
label: 'Nombre',
|
||||
value: formData.name,
|
||||
type: 'text',
|
||||
editable: true,
|
||||
name: 'name',
|
||||
type: 'text' as const,
|
||||
required: true,
|
||||
placeholder: 'Ej: Pan de molde integral',
|
||||
validation: (value) => !String(value).trim() ? 'El nombre es obligatorio' : null
|
||||
validation: (value: string | number) => {
|
||||
const str = String(value).trim();
|
||||
return str.length < 2 ? 'El nombre debe tener al menos 2 caracteres' : null;
|
||||
}
|
||||
},
|
||||
{
|
||||
label: 'Código de receta',
|
||||
value: formData.recipe_code,
|
||||
type: 'text',
|
||||
editable: true,
|
||||
name: 'recipe_code',
|
||||
type: 'text' as const,
|
||||
placeholder: 'Ej: PAN001 (opcional)'
|
||||
},
|
||||
{
|
||||
label: 'Versión',
|
||||
value: formData.version,
|
||||
type: 'text',
|
||||
editable: true,
|
||||
name: 'version',
|
||||
type: 'text' as const,
|
||||
defaultValue: '1.0',
|
||||
placeholder: '1.0'
|
||||
},
|
||||
{
|
||||
label: 'Descripción',
|
||||
value: formData.description,
|
||||
type: 'textarea',
|
||||
editable: true,
|
||||
name: 'description',
|
||||
type: 'textarea' as const,
|
||||
placeholder: 'Descripción de la receta...',
|
||||
span: 2
|
||||
},
|
||||
{
|
||||
label: 'Categoría',
|
||||
value: formData.category,
|
||||
type: 'select',
|
||||
editable: true,
|
||||
name: 'category',
|
||||
type: 'select' as const,
|
||||
required: true,
|
||||
options: categoryOptions,
|
||||
validation: (value) => !String(value).trim() ? 'La categoría es obligatoria' : null
|
||||
placeholder: 'Seleccionar categoría...'
|
||||
},
|
||||
{
|
||||
label: 'Producto terminado',
|
||||
value: formData.finished_product_id,
|
||||
type: 'select',
|
||||
editable: true,
|
||||
name: 'finished_product_id',
|
||||
type: 'select' as const,
|
||||
required: true,
|
||||
options: finishedProducts,
|
||||
validation: (value) => !String(value).trim() ? 'Debe seleccionar un producto terminado' : null
|
||||
placeholder: 'Seleccionar producto...'
|
||||
},
|
||||
{
|
||||
label: 'Tipo de cocina',
|
||||
value: formData.cuisine_type,
|
||||
type: 'select',
|
||||
editable: true,
|
||||
options: cuisineTypeOptions
|
||||
name: 'cuisine_type',
|
||||
type: 'select' as const,
|
||||
options: cuisineTypeOptions,
|
||||
placeholder: 'Seleccionar tipo...'
|
||||
},
|
||||
{
|
||||
label: 'Nivel de dificultad',
|
||||
value: formData.difficulty_level,
|
||||
type: 'select',
|
||||
editable: true,
|
||||
name: 'difficulty_level',
|
||||
type: 'select' as const,
|
||||
required: true,
|
||||
defaultValue: 1,
|
||||
options: [
|
||||
{ value: 1, label: '1 - Fácil' },
|
||||
{ value: 2, label: '2 - Medio' },
|
||||
@@ -627,122 +428,105 @@ export const CreateRecipeModal: React.FC<CreateRecipeModalProps> = ({
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
title: 'Ingredientes',
|
||||
icon: Package,
|
||||
fields: [
|
||||
{
|
||||
label: 'Lista de Ingredientes',
|
||||
value: ingredientsList,
|
||||
type: 'component',
|
||||
editable: true,
|
||||
component: IngredientsComponent,
|
||||
componentProps: {
|
||||
availableIngredients,
|
||||
unitOptions,
|
||||
onChange: setIngredientsList
|
||||
},
|
||||
span: 2,
|
||||
validation: () => validateIngredients()
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
title: 'Rendimiento y Tiempos',
|
||||
icon: Clock,
|
||||
fields: [
|
||||
{
|
||||
label: 'Cantidad de rendimiento',
|
||||
value: formData.yield_quantity,
|
||||
type: 'number',
|
||||
editable: true,
|
||||
name: 'yield_quantity',
|
||||
type: 'number' as const,
|
||||
required: true,
|
||||
validation: (value) => Number(value) <= 0 ? 'La cantidad debe ser mayor a 0' : null
|
||||
defaultValue: 1,
|
||||
validation: (value: string | number) => {
|
||||
const num = Number(value);
|
||||
return num <= 0 ? 'La cantidad debe ser mayor a 0' : null;
|
||||
}
|
||||
},
|
||||
{
|
||||
label: 'Unidad de rendimiento',
|
||||
value: formData.yield_unit,
|
||||
type: 'select',
|
||||
editable: true,
|
||||
name: 'yield_unit',
|
||||
type: 'select' as const,
|
||||
required: true,
|
||||
options: unitOptions
|
||||
options: unitOptions,
|
||||
defaultValue: MeasurementUnit.UNITS
|
||||
},
|
||||
{
|
||||
label: 'Porciones',
|
||||
value: formData.serves_count,
|
||||
type: 'number',
|
||||
editable: true,
|
||||
validation: (value) => Number(value) <= 0 ? 'Las porciones deben ser mayor a 0' : null
|
||||
name: 'serves_count',
|
||||
type: 'number' as const,
|
||||
defaultValue: 1,
|
||||
validation: (value: string | number) => {
|
||||
const num = Number(value);
|
||||
return num <= 0 ? 'Las porciones deben ser mayor a 0' : null;
|
||||
}
|
||||
},
|
||||
{
|
||||
label: 'Tiempo de preparación (min)',
|
||||
value: formData.prep_time_minutes || 0,
|
||||
type: 'number',
|
||||
editable: true,
|
||||
name: 'prep_time_minutes',
|
||||
type: 'number' as const,
|
||||
defaultValue: 0,
|
||||
helpText: 'Tiempo de preparación de ingredientes'
|
||||
},
|
||||
{
|
||||
label: 'Tiempo de cocción (min)',
|
||||
value: formData.cook_time_minutes || 0,
|
||||
type: 'number',
|
||||
editable: true,
|
||||
name: 'cook_time_minutes',
|
||||
type: 'number' as const,
|
||||
defaultValue: 0,
|
||||
helpText: 'Tiempo de horneado o cocción'
|
||||
},
|
||||
{
|
||||
label: 'Tiempo de reposo (min)',
|
||||
value: formData.rest_time_minutes || 0,
|
||||
type: 'number',
|
||||
editable: true,
|
||||
name: 'rest_time_minutes',
|
||||
type: 'number' as const,
|
||||
defaultValue: 0,
|
||||
helpText: 'Tiempo de fermentación o reposo'
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
title: 'Configuración Estacional y Especial',
|
||||
title: 'Configuración Especial',
|
||||
icon: Star,
|
||||
collapsible: true,
|
||||
fields: [
|
||||
{
|
||||
label: 'Receta estacional',
|
||||
value: formData.is_seasonal ? 'Sí' : 'No',
|
||||
type: 'select',
|
||||
editable: true,
|
||||
name: 'is_seasonal',
|
||||
type: 'select' as const,
|
||||
options: [
|
||||
{ value: 'No', label: 'No' },
|
||||
{ value: 'Sí', label: 'Sí' }
|
||||
]
|
||||
{ value: false, label: 'No' },
|
||||
{ value: true, label: 'Sí' }
|
||||
],
|
||||
defaultValue: false
|
||||
},
|
||||
{
|
||||
label: 'Mes de inicio',
|
||||
name: 'season_start_month',
|
||||
type: 'select' as const,
|
||||
options: monthOptions,
|
||||
placeholder: 'Seleccionar mes...'
|
||||
},
|
||||
{
|
||||
label: 'Mes de fin',
|
||||
name: 'season_end_month',
|
||||
type: 'select' as const,
|
||||
options: monthOptions,
|
||||
placeholder: 'Seleccionar mes...'
|
||||
},
|
||||
...(formData.is_seasonal ? [
|
||||
{
|
||||
label: 'Mes de inicio',
|
||||
value: formData.season_start_month || '',
|
||||
type: 'select' as const,
|
||||
editable: true,
|
||||
options: monthOptions
|
||||
},
|
||||
{
|
||||
label: 'Mes de fin',
|
||||
value: formData.season_end_month || '',
|
||||
type: 'select' as const,
|
||||
editable: true,
|
||||
options: monthOptions
|
||||
}
|
||||
] : []),
|
||||
{
|
||||
label: 'Receta estrella',
|
||||
value: formData.is_signature_item ? 'Sí' : 'No',
|
||||
type: 'select',
|
||||
editable: true,
|
||||
name: 'is_signature_item',
|
||||
type: 'select' as const,
|
||||
options: [
|
||||
{ value: 'No', label: 'No' },
|
||||
{ value: 'Sí', label: 'Sí' }
|
||||
]
|
||||
{ value: false, label: 'No' },
|
||||
{ value: true, label: 'Sí' }
|
||||
],
|
||||
defaultValue: false
|
||||
},
|
||||
{
|
||||
label: 'Margen objetivo (%)',
|
||||
value: formData.target_margin_percentage || 30,
|
||||
type: 'number',
|
||||
editable: true,
|
||||
name: 'target_margin_percentage',
|
||||
type: 'number' as const,
|
||||
defaultValue: 30,
|
||||
helpText: 'Margen de beneficio objetivo para esta receta'
|
||||
}
|
||||
]
|
||||
@@ -750,41 +534,36 @@ export const CreateRecipeModal: React.FC<CreateRecipeModalProps> = ({
|
||||
{
|
||||
title: 'Configuración de Producción',
|
||||
icon: Settings,
|
||||
collapsible: true,
|
||||
fields: [
|
||||
{
|
||||
label: 'Multiplicador de lote',
|
||||
value: formData.batch_size_multiplier,
|
||||
type: 'number',
|
||||
editable: true,
|
||||
name: 'batch_size_multiplier',
|
||||
type: 'number' as const,
|
||||
defaultValue: 1.0,
|
||||
helpText: 'Factor de escala para producción'
|
||||
},
|
||||
{
|
||||
label: 'Tamaño mínimo de lote',
|
||||
value: formData.minimum_batch_size || '',
|
||||
type: 'number',
|
||||
editable: true,
|
||||
name: 'minimum_batch_size',
|
||||
type: 'number' as const,
|
||||
helpText: 'Cantidad mínima recomendada'
|
||||
},
|
||||
{
|
||||
label: 'Tamaño máximo de lote',
|
||||
value: formData.maximum_batch_size || '',
|
||||
type: 'number',
|
||||
editable: true,
|
||||
name: 'maximum_batch_size',
|
||||
type: 'number' as const,
|
||||
helpText: 'Cantidad máxima recomendada'
|
||||
},
|
||||
{
|
||||
label: 'Temperatura óptima (°C)',
|
||||
value: formData.optimal_production_temperature || '',
|
||||
type: 'number',
|
||||
editable: true,
|
||||
name: 'optimal_production_temperature',
|
||||
type: 'number' as const,
|
||||
helpText: 'Temperatura ideal de producción'
|
||||
},
|
||||
{
|
||||
label: 'Humedad óptima (%)',
|
||||
value: formData.optimal_humidity || '',
|
||||
type: 'number',
|
||||
editable: true,
|
||||
name: 'optimal_humidity',
|
||||
type: 'number' as const,
|
||||
helpText: 'Humedad ideal de producción'
|
||||
}
|
||||
]
|
||||
@@ -792,64 +571,76 @@ export const CreateRecipeModal: React.FC<CreateRecipeModalProps> = ({
|
||||
{
|
||||
title: 'Instrucciones y Calidad',
|
||||
icon: FileText,
|
||||
collapsible: true,
|
||||
fields: [
|
||||
{
|
||||
label: 'Notas de preparación',
|
||||
value: formData.preparation_notes,
|
||||
type: 'textarea',
|
||||
editable: true,
|
||||
name: 'preparation_notes',
|
||||
type: 'textarea' as const,
|
||||
placeholder: 'Instrucciones detalladas de preparación...',
|
||||
span: 2,
|
||||
helpText: 'Pasos detallados para la preparación'
|
||||
},
|
||||
{
|
||||
label: 'Instrucciones de almacenamiento',
|
||||
value: formData.storage_instructions,
|
||||
type: 'textarea',
|
||||
editable: true,
|
||||
name: 'storage_instructions',
|
||||
type: 'textarea' as const,
|
||||
placeholder: 'Como conservar el producto terminado...',
|
||||
span: 2,
|
||||
helpText: 'Condiciones de almacenamiento del producto final'
|
||||
},
|
||||
{
|
||||
label: 'Estándares de calidad',
|
||||
value: formData.quality_standards,
|
||||
type: 'textarea',
|
||||
editable: true,
|
||||
name: 'quality_standards',
|
||||
type: 'textarea' as const,
|
||||
placeholder: 'Criterios de calidad que debe cumplir...',
|
||||
span: 2,
|
||||
helpText: 'Criterios que debe cumplir el producto final'
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
title: 'Ingredientes',
|
||||
icon: Package,
|
||||
columns: 1,
|
||||
fields: [
|
||||
{
|
||||
label: 'Lista de ingredientes',
|
||||
name: 'ingredients',
|
||||
type: 'component' as const,
|
||||
component: IngredientsComponent,
|
||||
componentProps: {
|
||||
availableIngredients,
|
||||
unitOptions
|
||||
},
|
||||
defaultValue: ingredientsList,
|
||||
span: 2,
|
||||
helpText: 'Agrega los ingredientes necesarios para esta receta'
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
title: 'Información Nutricional',
|
||||
icon: Package,
|
||||
collapsible: true,
|
||||
columns: 1,
|
||||
fields: [
|
||||
{
|
||||
label: 'Información de alérgenos',
|
||||
value: formData.allergen_info_text,
|
||||
type: 'text',
|
||||
editable: true,
|
||||
name: 'allergen_info_text',
|
||||
type: 'text' as const,
|
||||
placeholder: 'Ej: Gluten, Lácteos, Huevos',
|
||||
helpText: 'Separar con comas los alérgenos presentes'
|
||||
},
|
||||
{
|
||||
label: 'Etiquetas dietéticas',
|
||||
value: formData.dietary_tags_text,
|
||||
type: 'text',
|
||||
editable: true,
|
||||
name: 'dietary_tags_text',
|
||||
type: 'text' as const,
|
||||
placeholder: 'Ej: Vegano, Sin gluten, Orgánico',
|
||||
helpText: 'Separar con comas las etiquetas dietéticas'
|
||||
},
|
||||
{
|
||||
label: 'Información nutricional',
|
||||
value: formData.nutritional_info_text,
|
||||
type: 'textarea',
|
||||
editable: true,
|
||||
name: 'nutritional_info_text',
|
||||
type: 'textarea' as const,
|
||||
placeholder: 'Calorías, proteínas, carbohidratos, etc.',
|
||||
helpText: 'Información nutricional por porción'
|
||||
}
|
||||
@@ -858,26 +649,19 @@ export const CreateRecipeModal: React.FC<CreateRecipeModalProps> = ({
|
||||
];
|
||||
|
||||
return (
|
||||
<StatusModal
|
||||
<AddModal
|
||||
isOpen={isOpen}
|
||||
onClose={onClose}
|
||||
mode="edit"
|
||||
title="Nueva Receta"
|
||||
subtitle="Crear una nueva receta para la panadería"
|
||||
statusIndicator={{
|
||||
color: '#10b981',
|
||||
text: 'Creando',
|
||||
icon: ChefHat
|
||||
}}
|
||||
sections={getSections()}
|
||||
onFieldChange={handleFieldChange}
|
||||
onSave={handleSubmit}
|
||||
statusIndicator={statusConfig}
|
||||
sections={sections}
|
||||
size="2xl"
|
||||
loading={isLoading}
|
||||
mobileOptimized={true}
|
||||
showDefaultActions={true}
|
||||
loading={loading}
|
||||
onSave={handleSave}
|
||||
initialData={{ ingredients: ingredientsList }}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export default CreateRecipeModal;
|
||||
export default CreateRecipeModal;
|
||||
@@ -14,7 +14,7 @@ import {
|
||||
Badge,
|
||||
Select
|
||||
} from '../../ui';
|
||||
import { LoadingSpinner } from '../../shared';
|
||||
import { LoadingSpinner } from '../../ui';
|
||||
import { useCurrentTenant } from '../../../stores/tenant.store';
|
||||
import { useQualityTemplatesForRecipe } from '../../../api/hooks/qualityTemplates';
|
||||
import {
|
||||
|
||||
@@ -7,7 +7,6 @@ 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 {
|
||||
@@ -29,7 +28,6 @@ export const CreateSupplierForm: React.FC<CreateSupplierFormProps> = ({
|
||||
onCancel
|
||||
}) => {
|
||||
const { t } = useTranslation(['suppliers', 'common']);
|
||||
const supplierEnums = useSupplierEnums();
|
||||
|
||||
const [formData, setFormData] = useState<SupplierFormData>({
|
||||
name: '',
|
||||
@@ -73,24 +71,30 @@ export const CreateSupplierForm: React.FC<CreateSupplierFormProps> = ({
|
||||
{/* Supplier Type - Using enum helper */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
{supplierEnums.getFieldLabel('supplier_type')} *
|
||||
{t('suppliers:labels.supplier_type')} *
|
||||
</label>
|
||||
<Select
|
||||
options={supplierEnums.getSupplierTypeOptions()}
|
||||
options={Object.values(SupplierType).map(value => ({
|
||||
value,
|
||||
label: t(`suppliers:types.${value.toLowerCase()}`)
|
||||
}))}
|
||||
value={formData.supplier_type}
|
||||
onChange={(value) => handleFieldChange('supplier_type', value as SupplierType)}
|
||||
placeholder={t('common:forms.select_option')}
|
||||
helperText={supplierEnums.getFieldDescription('supplier_type')}
|
||||
helperText={t('suppliers:descriptions.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')}
|
||||
{t('suppliers:labels.supplier_status')}
|
||||
</label>
|
||||
<Select
|
||||
options={supplierEnums.getSupplierStatusOptions()}
|
||||
options={Object.values(SupplierStatus).map(value => ({
|
||||
value,
|
||||
label: t(`suppliers:status.${value.toLowerCase()}`)
|
||||
}))}
|
||||
value={formData.status}
|
||||
onChange={(value) => handleFieldChange('status', value as SupplierStatus)}
|
||||
placeholder={t('common:forms.select_option')}
|
||||
@@ -100,14 +104,17 @@ export const CreateSupplierForm: React.FC<CreateSupplierFormProps> = ({
|
||||
{/* Payment Terms - Using enum helper */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
{supplierEnums.getFieldLabel('payment_terms')} *
|
||||
{t('suppliers:labels.payment_terms')} *
|
||||
</label>
|
||||
<Select
|
||||
options={supplierEnums.getPaymentTermsOptions()}
|
||||
options={Object.values(PaymentTerms).map(value => ({
|
||||
value,
|
||||
label: t(`suppliers:payment_terms.${value.toLowerCase()}`)
|
||||
}))}
|
||||
value={formData.payment_terms}
|
||||
onChange={(value) => handleFieldChange('payment_terms', value as PaymentTerms)}
|
||||
placeholder={t('common:forms.select_option')}
|
||||
helperText={supplierEnums.getFieldDescription('payment_terms')}
|
||||
helperText={t('suppliers:descriptions.payment_terms')}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -162,13 +169,13 @@ export const CreateSupplierForm: React.FC<CreateSupplierFormProps> = ({
|
||||
<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)}
|
||||
<strong>Tipo:</strong> {t(`suppliers:types.${formData.supplier_type.toLowerCase()}`)}
|
||||
</li>
|
||||
<li>
|
||||
<strong>Estado:</strong> {supplierEnums.getSupplierStatusLabel(formData.status)}
|
||||
<strong>Estado:</strong> {t(`suppliers:status.${formData.status.toLowerCase()}`)}
|
||||
</li>
|
||||
<li>
|
||||
<strong>Términos de Pago:</strong> {supplierEnums.getPaymentTermsLabel(formData.payment_terms)}
|
||||
<strong>Términos de Pago:</strong> {t(`suppliers:payment_terms.${formData.payment_terms.toLowerCase()}`)}
|
||||
</li>
|
||||
<li>
|
||||
<strong>Debug - Payment Terms Raw:</strong> {formData.payment_terms}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import React, { useState } from 'react';
|
||||
import { Plus, Users, Shield, Eye } from 'lucide-react';
|
||||
import { StatusModal } from '../../ui/StatusModal/StatusModal';
|
||||
import { Plus, Users, Shield } from 'lucide-react';
|
||||
import { AddModal } from '../../ui/AddModal/AddModal';
|
||||
import { TENANT_ROLES } from '../../../types/roles';
|
||||
import { statusColors } from '../../../styles/colors';
|
||||
|
||||
@@ -21,41 +21,16 @@ export const AddTeamMemberModal: React.FC<AddTeamMemberModalProps> = ({
|
||||
onAddMember,
|
||||
availableUsers
|
||||
}) => {
|
||||
const [formData, setFormData] = useState({
|
||||
userId: '',
|
||||
userEmail: '', // Add email field for manual input
|
||||
role: TENANT_ROLES.MEMBER
|
||||
});
|
||||
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [mode, setMode] = useState<'view' | 'edit'>('edit');
|
||||
|
||||
const handleFieldChange = (sectionIndex: number, fieldIndex: number, value: string | number | boolean) => {
|
||||
// Map field positions to form data fields
|
||||
const fieldMappings = [
|
||||
// Basic Information section
|
||||
['userId', 'userEmail', 'role']
|
||||
];
|
||||
|
||||
const fieldName = fieldMappings[sectionIndex]?.[fieldIndex] as keyof typeof formData;
|
||||
if (fieldName) {
|
||||
setFormData(prev => ({
|
||||
...prev,
|
||||
[fieldName]: value
|
||||
}));
|
||||
}
|
||||
};
|
||||
|
||||
const handleSave = async () => {
|
||||
const handleSave = async (formData: Record<string, any>) => {
|
||||
// Validation - need either userId OR userEmail
|
||||
if (!formData.userId && !formData.userEmail) {
|
||||
alert('Por favor selecciona un usuario o ingresa un email');
|
||||
return;
|
||||
throw new Error('Por favor selecciona un usuario o ingresa un email');
|
||||
}
|
||||
|
||||
if (!formData.role) {
|
||||
alert('Por favor selecciona un rol');
|
||||
return;
|
||||
throw new Error('Por favor selecciona un rol');
|
||||
}
|
||||
|
||||
setLoading(true);
|
||||
@@ -66,33 +41,14 @@ export const AddTeamMemberModal: React.FC<AddTeamMemberModalProps> = ({
|
||||
role: formData.role
|
||||
});
|
||||
}
|
||||
|
||||
// Reset form
|
||||
setFormData({
|
||||
userId: '',
|
||||
userEmail: '',
|
||||
role: TENANT_ROLES.MEMBER
|
||||
});
|
||||
|
||||
onClose();
|
||||
} catch (error) {
|
||||
console.error('Error adding team member:', error);
|
||||
alert('Error al agregar el miembro del equipo. Por favor, intenta de nuevo.');
|
||||
throw error; // Let AddModal handle error display
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleCancel = () => {
|
||||
// Reset form to initial values
|
||||
setFormData({
|
||||
userId: '',
|
||||
userEmail: '',
|
||||
role: TENANT_ROLES.MEMBER
|
||||
});
|
||||
onClose();
|
||||
};
|
||||
|
||||
const statusConfig = {
|
||||
color: statusColors.inProgress.primary,
|
||||
text: 'Nuevo Miembro',
|
||||
@@ -114,74 +70,69 @@ export const AddTeamMemberModal: React.FC<AddTeamMemberModalProps> = ({
|
||||
}))
|
||||
: [];
|
||||
|
||||
const getRoleDescription = (role: string) => {
|
||||
switch (role) {
|
||||
case TENANT_ROLES.ADMIN:
|
||||
return 'Los administradores pueden gestionar miembros del equipo y configuraciones.';
|
||||
case TENANT_ROLES.MEMBER:
|
||||
return 'Los miembros tienen acceso completo para trabajar con datos y funcionalidades.';
|
||||
case TENANT_ROLES.VIEWER:
|
||||
return 'Los observadores solo pueden ver datos, sin realizar cambios.';
|
||||
default:
|
||||
return '';
|
||||
// Create fields array conditionally based on available users
|
||||
const userFields = [];
|
||||
|
||||
// Add user selection field if we have users available
|
||||
if (userOptions.length > 0) {
|
||||
userFields.push({
|
||||
label: 'Usuario',
|
||||
name: 'userId',
|
||||
type: 'select' as const,
|
||||
options: userOptions,
|
||||
placeholder: 'Seleccionar usuario...',
|
||||
helpText: 'Selecciona un usuario existente o ingresa un email manualmente abajo'
|
||||
});
|
||||
}
|
||||
|
||||
// Add email field (always present)
|
||||
userFields.push({
|
||||
label: userOptions.length > 0 ? 'O Email del Usuario' : 'Email del Usuario',
|
||||
name: 'userEmail',
|
||||
type: 'email' as const,
|
||||
placeholder: 'usuario@ejemplo.com',
|
||||
helpText: userOptions.length > 0
|
||||
? 'Alternativamente, ingresa el email de un usuario nuevo'
|
||||
: 'Ingresa el email del usuario que quieres agregar',
|
||||
validation: (value: string | number) => {
|
||||
const email = String(value);
|
||||
if (email && !email.includes('@')) {
|
||||
return 'Por favor ingresa un email válido';
|
||||
}
|
||||
return null;
|
||||
}
|
||||
};
|
||||
});
|
||||
|
||||
// Add role field
|
||||
userFields.push({
|
||||
label: 'Rol',
|
||||
name: 'role',
|
||||
type: 'select' as const,
|
||||
required: true,
|
||||
options: roleOptions,
|
||||
defaultValue: TENANT_ROLES.MEMBER,
|
||||
helpText: 'Selecciona el nivel de acceso para este usuario'
|
||||
});
|
||||
|
||||
const sections = [
|
||||
{
|
||||
title: 'Información del Miembro',
|
||||
icon: Users,
|
||||
fields: [
|
||||
...(userOptions.length > 0 ? [{
|
||||
label: 'Usuario',
|
||||
value: formData.userId,
|
||||
type: 'select' as const,
|
||||
editable: true,
|
||||
required: !formData.userEmail, // Only required if email not provided
|
||||
options: userOptions,
|
||||
placeholder: 'Seleccionar usuario...'
|
||||
}] : []),
|
||||
{
|
||||
label: userOptions.length > 0 ? 'O Email del Usuario' : 'Email del Usuario',
|
||||
value: formData.userEmail,
|
||||
type: 'email' as const,
|
||||
editable: true,
|
||||
required: !formData.userId, // Only required if user not selected
|
||||
placeholder: 'usuario@ejemplo.com'
|
||||
},
|
||||
{
|
||||
label: 'Rol',
|
||||
value: formData.role,
|
||||
type: 'select' as const,
|
||||
editable: true,
|
||||
required: true,
|
||||
options: roleOptions
|
||||
},
|
||||
{
|
||||
label: 'Descripción del Rol',
|
||||
value: getRoleDescription(formData.role),
|
||||
type: 'text' as const,
|
||||
editable: false
|
||||
}
|
||||
]
|
||||
fields: userFields
|
||||
}
|
||||
];
|
||||
|
||||
return (
|
||||
<StatusModal
|
||||
<AddModal
|
||||
isOpen={isOpen}
|
||||
onClose={onClose}
|
||||
mode={mode}
|
||||
title="Agregar Miembro al Equipo"
|
||||
subtitle="Agregar un nuevo miembro al equipo de la panadería"
|
||||
statusIndicator={statusConfig}
|
||||
sections={sections}
|
||||
size="lg"
|
||||
loading={loading}
|
||||
showDefaultActions={true}
|
||||
onSave={handleSave}
|
||||
onCancel={handleCancel}
|
||||
onFieldChange={handleFieldChange}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,70 +0,0 @@
|
||||
# MinimalSidebar Component
|
||||
|
||||
A minimalist, responsive sidebar component for the Panadería IA application, inspired by grok.com's clean design.
|
||||
|
||||
## Features
|
||||
|
||||
- **Minimalist Design**: Clean, uncluttered interface following modern UI principles
|
||||
- **Responsive**: Works on both desktop and mobile devices
|
||||
- **Collapsible**: Can be collapsed on desktop to save space
|
||||
- **Navigation Hierarchy**: Supports nested menu items with expand/collapse functionality
|
||||
- **Profile Integration**: Includes user profile section with logout functionality
|
||||
- **Theme Consistency**: Follows the application's global color palette and design system
|
||||
- **Accessibility**: Proper ARIA labels and keyboard navigation support
|
||||
|
||||
## Usage
|
||||
|
||||
```tsx
|
||||
import { MinimalSidebar } from './MinimalSidebar';
|
||||
|
||||
// Basic usage
|
||||
<MinimalSidebar />
|
||||
|
||||
// With custom props
|
||||
<MinimalSidebar
|
||||
isCollapsed={isSidebarCollapsed}
|
||||
onToggleCollapse={toggleSidebar}
|
||||
isOpen={isMobileMenuOpen}
|
||||
onClose={closeMobileMenu}
|
||||
/>
|
||||
```
|
||||
|
||||
## Props
|
||||
|
||||
| Prop | Type | Description |
|
||||
|------|------|-------------|
|
||||
| `className` | `string` | Additional CSS classes |
|
||||
| `isOpen` | `boolean` | Whether the mobile drawer is open |
|
||||
| `isCollapsed` | `boolean` | Whether the desktop sidebar is collapsed |
|
||||
| `onClose` | `() => void` | Callback when sidebar is closed (mobile) |
|
||||
| `onToggleCollapse` | `() => void` | Callback when collapse state changes (desktop) |
|
||||
| `customItems` | `NavigationItem[]` | Custom navigation items |
|
||||
| `showCollapseButton` | `boolean` | Whether to show the collapse button |
|
||||
| `showFooter` | `boolean` | Whether to show the footer section |
|
||||
|
||||
## Design Principles
|
||||
|
||||
- **Minimalist Aesthetic**: Clean lines, ample whitespace, and focused content
|
||||
- **Grok.com Inspired**: Follows the clean, functional design of grok.com
|
||||
- **Consistent with Brand**: Uses the application's color palette and typography
|
||||
- **Mobile First**: Responsive design that works well on all screen sizes
|
||||
- **Performance Focused**: Lightweight implementation with minimal dependencies
|
||||
|
||||
## Color Palette
|
||||
|
||||
The component uses the application's global CSS variables for consistent theming:
|
||||
|
||||
- `--color-primary`: Primary brand color (orange)
|
||||
- `--color-secondary`: Secondary brand color (green)
|
||||
- `--bg-primary`: Main background color
|
||||
- `--bg-secondary`: Secondary background color
|
||||
- `--text-primary`: Primary text color
|
||||
- `--text-secondary`: Secondary text color
|
||||
- `--border-primary`: Primary border color
|
||||
|
||||
## Accessibility
|
||||
|
||||
- Proper ARIA attributes for screen readers
|
||||
- Keyboard navigation support
|
||||
- Focus management
|
||||
- Semantic HTML structure
|
||||
@@ -7,6 +7,7 @@ export { PageHeader } from './PageHeader';
|
||||
export { Footer } from './Footer';
|
||||
export { PublicHeader } from './PublicHeader';
|
||||
export { PublicLayout } from './PublicLayout';
|
||||
export { ErrorBoundary } from './ErrorBoundary';
|
||||
|
||||
// Export types
|
||||
export type { AppShellProps } from './AppShell';
|
||||
@@ -16,4 +17,5 @@ export type { BreadcrumbsProps, BreadcrumbItem } from './Breadcrumbs';
|
||||
export type { PageHeaderProps } from './PageHeader';
|
||||
export type { FooterProps } from './Footer';
|
||||
export type { PublicHeaderProps, PublicHeaderRef } from './PublicHeader';
|
||||
export type { PublicLayoutProps, PublicLayoutRef } from './PublicLayout';
|
||||
export type { PublicLayoutProps, PublicLayoutRef } from './PublicLayout';
|
||||
export type { ErrorBoundaryProps } from './ErrorBoundary';
|
||||
@@ -1,297 +0,0 @@
|
||||
import React, { forwardRef, useEffect, useCallback } from 'react';
|
||||
import { clsx } from 'clsx';
|
||||
import { Modal, Button } from '../../ui';
|
||||
import type { ModalProps, ButtonProps } from '../../ui';
|
||||
|
||||
export interface ConfirmDialogProps {
|
||||
/** Mostrar el diálogo */
|
||||
isOpen: boolean;
|
||||
/** Función al cerrar el diálogo */
|
||||
onClose: () => void;
|
||||
/** Función al confirmar */
|
||||
onConfirm: () => void | Promise<void>;
|
||||
/** Título del diálogo */
|
||||
title?: string;
|
||||
/** Mensaje del diálogo */
|
||||
message?: string;
|
||||
/** Nivel de peligro */
|
||||
variant?: 'info' | 'warning' | 'danger';
|
||||
/** Texto del botón de confirmación */
|
||||
confirmText?: string;
|
||||
/** Texto del botón de cancelación */
|
||||
cancelText?: string;
|
||||
/** Icono personalizado */
|
||||
icon?: React.ReactNode;
|
||||
/** Mostrar loading en el botón de confirmación */
|
||||
isLoading?: boolean;
|
||||
/** Deshabilitar la confirmación */
|
||||
isDisabled?: boolean;
|
||||
/** Auto-cerrar después de la confirmación */
|
||||
autoCloseOnConfirm?: boolean;
|
||||
/** Habilitar atajos de teclado (Enter/Escape) */
|
||||
enableKeyboardShortcuts?: boolean;
|
||||
/** Foco automático en el botón de confirmación */
|
||||
autoFocusConfirm?: boolean;
|
||||
/** Requerir confirmación doble para acciones peligrosas */
|
||||
requireDoubleConfirm?: boolean;
|
||||
/** Texto de confirmación doble */
|
||||
doubleConfirmText?: string;
|
||||
/** Contenido adicional personalizado */
|
||||
children?: React.ReactNode;
|
||||
/** Clase CSS adicional */
|
||||
className?: string;
|
||||
/** Función llamada al cancelar */
|
||||
onCancel?: () => void;
|
||||
}
|
||||
|
||||
// Iconos por defecto para cada variante
|
||||
const DefaultIcons = {
|
||||
info: (
|
||||
<svg className="w-6 h-6 text-color-info" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
),
|
||||
warning: (
|
||||
<svg className="w-6 h-6 text-color-warning" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-2.5L13.732 4c-.77-.833-1.964-.833-2.732 0L3.732 16.5c-.77.833.192 2.5 1.732 2.5z" />
|
||||
</svg>
|
||||
),
|
||||
danger: (
|
||||
<svg className="w-6 h-6 text-color-error" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-2.5L13.732 4c-.77-.833-1.964-.833-2.732 0L3.732 16.5c-.77.833.192 2.5 1.732 2.5z" />
|
||||
</svg>
|
||||
),
|
||||
};
|
||||
|
||||
// Mensajes por defecto
|
||||
const DefaultMessages = {
|
||||
info: {
|
||||
title: 'Confirmar acción',
|
||||
message: '¿Está seguro de que desea continuar?',
|
||||
confirmText: 'Continuar',
|
||||
},
|
||||
warning: {
|
||||
title: 'Advertencia',
|
||||
message: 'Esta acción puede tener consecuencias. ¿Desea continuar?',
|
||||
confirmText: 'Sí, continuar',
|
||||
},
|
||||
danger: {
|
||||
title: 'Acción peligrosa',
|
||||
message: 'Esta acción no se puede deshacer. ¿Está absolutamente seguro?',
|
||||
confirmText: 'Sí, eliminar',
|
||||
},
|
||||
};
|
||||
|
||||
const ConfirmDialog = forwardRef<HTMLDivElement, ConfirmDialogProps>(({
|
||||
isOpen,
|
||||
onClose,
|
||||
onConfirm,
|
||||
title,
|
||||
message,
|
||||
variant = 'info',
|
||||
confirmText,
|
||||
cancelText = 'Cancelar',
|
||||
icon,
|
||||
isLoading = false,
|
||||
isDisabled = false,
|
||||
autoCloseOnConfirm = true,
|
||||
enableKeyboardShortcuts = true,
|
||||
autoFocusConfirm = false,
|
||||
requireDoubleConfirm = false,
|
||||
doubleConfirmText = 'ELIMINAR',
|
||||
children,
|
||||
className,
|
||||
onCancel,
|
||||
...props
|
||||
}, ref) => {
|
||||
const [doubleConfirmValue, setDoubleConfirmValue] = React.useState('');
|
||||
const [isDoubleConfirmValid, setIsDoubleConfirmValid] = React.useState(false);
|
||||
const confirmButtonRef = React.useRef<HTMLButtonElement>(null);
|
||||
|
||||
const defaultMessage = DefaultMessages[variant] || DefaultMessages.info;
|
||||
const displayTitle = title || defaultMessage.title;
|
||||
const displayMessage = message || defaultMessage.message;
|
||||
const displayConfirmText = confirmText || defaultMessage.confirmText;
|
||||
const displayIcon = icon || DefaultIcons[variant];
|
||||
|
||||
// Validar confirmación doble
|
||||
React.useEffect(() => {
|
||||
if (requireDoubleConfirm) {
|
||||
setIsDoubleConfirmValid(doubleConfirmValue.toUpperCase() === doubleConfirmText.toUpperCase());
|
||||
}
|
||||
}, [doubleConfirmValue, doubleConfirmText, requireDoubleConfirm]);
|
||||
|
||||
// Auto-foco en el botón de confirmación
|
||||
React.useEffect(() => {
|
||||
if (isOpen && autoFocusConfirm && confirmButtonRef.current) {
|
||||
const timer = setTimeout(() => {
|
||||
confirmButtonRef.current?.focus();
|
||||
}, 100);
|
||||
return () => clearTimeout(timer);
|
||||
}
|
||||
}, [isOpen, autoFocusConfirm]);
|
||||
|
||||
// Reset del estado cuando se cierra
|
||||
React.useEffect(() => {
|
||||
if (!isOpen) {
|
||||
setDoubleConfirmValue('');
|
||||
setIsDoubleConfirmValid(false);
|
||||
}
|
||||
}, [isOpen]);
|
||||
|
||||
// Manejar atajos de teclado
|
||||
const handleKeyDown = useCallback((event: KeyboardEvent) => {
|
||||
if (!isOpen || !enableKeyboardShortcuts) return;
|
||||
|
||||
if (event.key === 'Escape') {
|
||||
event.preventDefault();
|
||||
handleCancel();
|
||||
} else if (event.key === 'Enter' && (event.metaKey || event.ctrlKey)) {
|
||||
event.preventDefault();
|
||||
if (!isDisabled && !isLoading && (!requireDoubleConfirm || isDoubleConfirmValid)) {
|
||||
handleConfirm();
|
||||
}
|
||||
}
|
||||
}, [isOpen, enableKeyboardShortcuts, isDisabled, isLoading, requireDoubleConfirm, isDoubleConfirmValid]);
|
||||
|
||||
useEffect(() => {
|
||||
document.addEventListener('keydown', handleKeyDown);
|
||||
return () => document.removeEventListener('keydown', handleKeyDown);
|
||||
}, [handleKeyDown]);
|
||||
|
||||
const handleConfirm = async () => {
|
||||
try {
|
||||
await onConfirm();
|
||||
if (autoCloseOnConfirm) {
|
||||
onClose();
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error en confirmación:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const handleCancel = () => {
|
||||
if (onCancel) {
|
||||
onCancel();
|
||||
}
|
||||
onClose();
|
||||
};
|
||||
|
||||
const getConfirmButtonVariant = (): ButtonProps['variant'] => {
|
||||
switch (variant) {
|
||||
case 'danger':
|
||||
return 'danger';
|
||||
case 'warning':
|
||||
return 'warning';
|
||||
default:
|
||||
return 'primary';
|
||||
}
|
||||
};
|
||||
|
||||
const isConfirmDisabled = isDisabled || isLoading || (requireDoubleConfirm && !isDoubleConfirmValid);
|
||||
|
||||
return (
|
||||
<Modal
|
||||
isOpen={isOpen}
|
||||
onClose={onClose}
|
||||
size="sm"
|
||||
className={className}
|
||||
{...props}
|
||||
>
|
||||
<div ref={ref} className="p-6">
|
||||
{/* Header con icono y título */}
|
||||
<div className="flex items-start gap-4 mb-4">
|
||||
{displayIcon && (
|
||||
<div className="flex-shrink-0">
|
||||
{displayIcon}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex-1">
|
||||
{displayTitle && (
|
||||
<h3 className="text-lg font-semibold text-text-primary mb-2">
|
||||
{displayTitle}
|
||||
</h3>
|
||||
)}
|
||||
|
||||
{displayMessage && (
|
||||
<p className="text-text-secondary leading-relaxed">
|
||||
{displayMessage}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Contenido adicional */}
|
||||
{children && (
|
||||
<div className="mb-6">
|
||||
{children}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Confirmación doble */}
|
||||
{requireDoubleConfirm && (
|
||||
<div className="mb-6">
|
||||
<label className="block text-sm font-medium text-text-primary mb-2">
|
||||
Para confirmar, escriba <span className="font-mono bg-bg-tertiary px-1 rounded">{doubleConfirmText}</span>:
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={doubleConfirmValue}
|
||||
onChange={(e) => setDoubleConfirmValue(e.target.value)}
|
||||
className={clsx(
|
||||
'w-full px-3 py-2 border rounded-md focus:outline-none focus:ring-2 transition-colors',
|
||||
isDoubleConfirmValid
|
||||
? 'border-color-success focus:ring-color-success/20'
|
||||
: 'border-border-secondary focus:ring-color-primary/20'
|
||||
)}
|
||||
placeholder={`Escriba ${doubleConfirmText}`}
|
||||
autoComplete="off"
|
||||
spellCheck={false}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Botones de acción */}
|
||||
<div className="flex flex-col-reverse sm:flex-row gap-3 sm:justify-end">
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={handleCancel}
|
||||
disabled={isLoading}
|
||||
className="w-full sm:w-auto"
|
||||
>
|
||||
{cancelText}
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
ref={confirmButtonRef}
|
||||
variant={getConfirmButtonVariant()}
|
||||
onClick={handleConfirm}
|
||||
disabled={isConfirmDisabled}
|
||||
isLoading={isLoading}
|
||||
className="w-full sm:w-auto"
|
||||
loadingText="Procesando..."
|
||||
>
|
||||
{displayConfirmText}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Ayuda de atajos de teclado */}
|
||||
{enableKeyboardShortcuts && (
|
||||
<div className="mt-4 pt-4 border-t border-border-primary">
|
||||
<p className="text-xs text-text-tertiary text-center">
|
||||
Presione <kbd className="px-1 bg-bg-tertiary rounded font-mono">Escape</kbd> para cancelar
|
||||
{!isConfirmDisabled && (
|
||||
<> o <kbd className="px-1 bg-bg-tertiary rounded font-mono">Ctrl+Enter</kbd> para confirmar</>
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
});
|
||||
|
||||
ConfirmDialog.displayName = 'ConfirmDialog';
|
||||
|
||||
export default ConfirmDialog;
|
||||
@@ -1,2 +0,0 @@
|
||||
export { default as ConfirmDialog } from './ConfirmDialog';
|
||||
export type { ConfirmDialogProps } from './ConfirmDialog';
|
||||
@@ -1,684 +0,0 @@
|
||||
import React, { forwardRef, useMemo, useCallback, useState, useEffect } from 'react';
|
||||
import { clsx } from 'clsx';
|
||||
import { Button, Input, Select } from '../../ui';
|
||||
import { LoadingSpinner } from '../LoadingSpinner';
|
||||
import { EmptyState } from '../EmptyState';
|
||||
import type { SelectOption } from '../../ui';
|
||||
|
||||
export interface DataTableColumn<T = any> {
|
||||
/** ID único de la columna */
|
||||
id: string;
|
||||
/** Clave del campo en los datos */
|
||||
key: keyof T;
|
||||
/** Texto del header */
|
||||
header: string;
|
||||
/** Habilitar ordenamiento */
|
||||
sortable?: boolean;
|
||||
/** Habilitar filtrado */
|
||||
filterable?: boolean;
|
||||
/** Ancho de la columna */
|
||||
width?: string | number;
|
||||
/** Alineación del contenido */
|
||||
align?: 'left' | 'center' | 'right';
|
||||
/** Renderizado personalizado de celda */
|
||||
cell?: (value: any, row: T, index: number) => React.ReactNode;
|
||||
/** Renderizado personalizado de header */
|
||||
headerCell?: () => React.ReactNode;
|
||||
/** Función de formateo */
|
||||
format?: (value: any) => string;
|
||||
/** Tipo de columna para filtros */
|
||||
type?: 'text' | 'number' | 'date' | 'boolean' | 'select';
|
||||
/** Opciones para columnas de tipo select */
|
||||
selectOptions?: SelectOption[];
|
||||
/** Mínimo ancho de la columna */
|
||||
minWidth?: string | number;
|
||||
/** Máximo ancho de la columna */
|
||||
maxWidth?: string | number;
|
||||
/** Columna fija (sticky) */
|
||||
sticky?: 'left' | 'right';
|
||||
/** Ocultar columna en móviles */
|
||||
hideOnMobile?: boolean;
|
||||
}
|
||||
|
||||
export interface DataTableFilter {
|
||||
column: string;
|
||||
value: any;
|
||||
operator?: 'equals' | 'contains' | 'startsWith' | 'endsWith' | 'gt' | 'gte' | 'lt' | 'lte';
|
||||
}
|
||||
|
||||
export interface DataTableSort {
|
||||
column: string;
|
||||
direction: 'asc' | 'desc';
|
||||
}
|
||||
|
||||
export interface DataTablePagination {
|
||||
page: number;
|
||||
pageSize: number;
|
||||
total: number;
|
||||
}
|
||||
|
||||
export interface DataTableSelection<T = any> {
|
||||
mode: 'none' | 'single' | 'multiple';
|
||||
selectedRows: T[];
|
||||
onSelectionChange: (selectedRows: T[]) => void;
|
||||
getRowId?: (row: T) => string | number;
|
||||
}
|
||||
|
||||
export interface DataTableProps<T = any> {
|
||||
/** Datos de la tabla */
|
||||
data: T[];
|
||||
/** Definición de columnas */
|
||||
columns: DataTableColumn<T>[];
|
||||
/** Estado de carga */
|
||||
isLoading?: boolean;
|
||||
/** Error */
|
||||
error?: string | null;
|
||||
/** Configuración de paginación */
|
||||
pagination?: DataTablePagination;
|
||||
/** Callback para cambio de página */
|
||||
onPageChange?: (page: number, pageSize: number) => void;
|
||||
/** Configuración de ordenamiento */
|
||||
sorting?: DataTableSort;
|
||||
/** Callback para cambio de ordenamiento */
|
||||
onSortChange?: (sort: DataTableSort) => void;
|
||||
/** Filtros aplicados */
|
||||
filters?: DataTableFilter[];
|
||||
/** Callback para cambio de filtros */
|
||||
onFiltersChange?: (filters: DataTableFilter[]) => void;
|
||||
/** Configuración de selección */
|
||||
selection?: DataTableSelection<T>;
|
||||
/** Filas expandibles */
|
||||
expandable?: {
|
||||
renderExpandedRow: (row: T, index: number) => React.ReactNode;
|
||||
isExpanded?: (row: T) => boolean;
|
||||
onToggleExpanded?: (row: T) => void;
|
||||
};
|
||||
/** Habilitar exportación */
|
||||
enableExport?: boolean;
|
||||
/** Callback para exportación */
|
||||
onExport?: (format: 'csv' | 'xlsx') => void;
|
||||
/** Texto de búsqueda global */
|
||||
searchQuery?: string;
|
||||
/** Callback para búsqueda */
|
||||
onSearchChange?: (query: string) => void;
|
||||
/** Placeholder de búsqueda */
|
||||
searchPlaceholder?: string;
|
||||
/** Mensaje de estado vacío personalizado */
|
||||
emptyStateMessage?: string;
|
||||
/** Acción de estado vacío */
|
||||
emptyStateAction?: {
|
||||
label: string;
|
||||
onClick: () => void;
|
||||
};
|
||||
/** Altura fija de la tabla */
|
||||
height?: string | number;
|
||||
/** Habilitar scroll horizontal */
|
||||
horizontalScroll?: boolean;
|
||||
/** Densidad de filas */
|
||||
density?: 'compact' | 'normal' | 'comfortable';
|
||||
/** Clase CSS adicional */
|
||||
className?: string;
|
||||
/** Callback para click en fila */
|
||||
onRowClick?: (row: T, index: number) => void;
|
||||
/** Callback para doble click en fila */
|
||||
onRowDoubleClick?: (row: T, index: number) => void;
|
||||
/** Función para obtener clases CSS de fila */
|
||||
getRowClassName?: (row: T, index: number) => string;
|
||||
/** Mostrar números de fila */
|
||||
showRowNumbers?: boolean;
|
||||
/** Nombre de la tabla para accesibilidad */
|
||||
'aria-label'?: string;
|
||||
}
|
||||
|
||||
const DataTable = forwardRef<HTMLDivElement, DataTableProps>(({
|
||||
data,
|
||||
columns,
|
||||
isLoading = false,
|
||||
error = null,
|
||||
pagination,
|
||||
onPageChange,
|
||||
sorting,
|
||||
onSortChange,
|
||||
filters = [],
|
||||
onFiltersChange,
|
||||
selection,
|
||||
expandable,
|
||||
enableExport = false,
|
||||
onExport,
|
||||
searchQuery = '',
|
||||
onSearchChange,
|
||||
searchPlaceholder = 'Buscar...',
|
||||
emptyStateMessage,
|
||||
emptyStateAction,
|
||||
height,
|
||||
horizontalScroll = true,
|
||||
density = 'normal',
|
||||
className,
|
||||
onRowClick,
|
||||
onRowDoubleClick,
|
||||
getRowClassName,
|
||||
showRowNumbers = false,
|
||||
'aria-label': ariaLabel = 'Tabla de datos',
|
||||
...props
|
||||
}, ref) => {
|
||||
const [localFilters, setLocalFilters] = useState<Record<string, any>>({});
|
||||
const [expandedRows, setExpandedRows] = useState<Set<string | number>>(new Set());
|
||||
|
||||
// Densidad de filas
|
||||
const densityClasses = {
|
||||
compact: 'py-1 px-2 text-sm',
|
||||
normal: 'py-2 px-3 text-sm',
|
||||
comfortable: 'py-3 px-4 text-base'
|
||||
};
|
||||
|
||||
// Manejo de selección
|
||||
const handleSelectAll = useCallback(() => {
|
||||
if (!selection || selection.mode !== 'multiple') return;
|
||||
|
||||
const allSelected = data.every(row =>
|
||||
selection.selectedRows.some(selectedRow => {
|
||||
const getId = selection.getRowId || ((r: any) => r.id);
|
||||
return getId(row) === getId(selectedRow);
|
||||
})
|
||||
);
|
||||
|
||||
if (allSelected) {
|
||||
selection.onSelectionChange([]);
|
||||
} else {
|
||||
selection.onSelectionChange([...data]);
|
||||
}
|
||||
}, [data, selection]);
|
||||
|
||||
const handleRowSelection = useCallback((row: any, checked: boolean) => {
|
||||
if (!selection) return;
|
||||
|
||||
const getId = selection.getRowId || ((r: any) => r.id);
|
||||
const rowId = getId(row);
|
||||
|
||||
if (selection.mode === 'single') {
|
||||
selection.onSelectionChange(checked ? [row] : []);
|
||||
} else if (selection.mode === 'multiple') {
|
||||
const newSelection = checked
|
||||
? [...selection.selectedRows, row]
|
||||
: selection.selectedRows.filter(selectedRow => getId(selectedRow) !== rowId);
|
||||
selection.onSelectionChange(newSelection);
|
||||
}
|
||||
}, [selection]);
|
||||
|
||||
const isRowSelected = useCallback((row: any) => {
|
||||
if (!selection) return false;
|
||||
const getId = selection.getRowId || ((r: any) => r.id);
|
||||
return selection.selectedRows.some(selectedRow => getId(selectedRow) === getId(row));
|
||||
}, [selection]);
|
||||
|
||||
// Manejo de expansión
|
||||
const handleRowExpansion = useCallback((row: any) => {
|
||||
if (!expandable) return;
|
||||
|
||||
const getId = selection?.getRowId || ((r: any) => r.id);
|
||||
const rowId = getId(row);
|
||||
|
||||
if (expandable.onToggleExpanded) {
|
||||
expandable.onToggleExpanded(row);
|
||||
} else {
|
||||
setExpandedRows(prev => {
|
||||
const newExpanded = new Set(prev);
|
||||
if (newExpanded.has(rowId)) {
|
||||
newExpanded.delete(rowId);
|
||||
} else {
|
||||
newExpanded.add(rowId);
|
||||
}
|
||||
return newExpanded;
|
||||
});
|
||||
}
|
||||
}, [expandable, selection?.getRowId]);
|
||||
|
||||
const isRowExpanded = useCallback((row: any) => {
|
||||
if (!expandable) return false;
|
||||
|
||||
if (expandable.isExpanded) {
|
||||
return expandable.isExpanded(row);
|
||||
}
|
||||
|
||||
const getId = selection?.getRowId || ((r: any) => r.id);
|
||||
return expandedRows.has(getId(row));
|
||||
}, [expandable, expandedRows, selection?.getRowId]);
|
||||
|
||||
// Manejo de ordenamiento
|
||||
const handleSort = useCallback((columnId: string) => {
|
||||
if (!onSortChange) return;
|
||||
|
||||
const newDirection = sorting?.column === columnId && sorting.direction === 'asc' ? 'desc' : 'asc';
|
||||
onSortChange({ column: columnId, direction: newDirection });
|
||||
}, [sorting, onSortChange]);
|
||||
|
||||
// Renderizado de iconos de ordenamiento
|
||||
const renderSortIcon = (column: DataTableColumn) => {
|
||||
if (!column.sortable) return null;
|
||||
|
||||
const isActive = sorting?.column === column.id;
|
||||
const direction = sorting?.direction;
|
||||
|
||||
return (
|
||||
<span className="ml-1 inline-flex flex-col">
|
||||
<svg
|
||||
className={clsx('w-3 h-3', {
|
||||
'text-color-primary': isActive && direction === 'asc',
|
||||
'text-text-tertiary': !isActive || direction !== 'asc'
|
||||
})}
|
||||
fill="currentColor"
|
||||
viewBox="0 0 12 12"
|
||||
>
|
||||
<path d="M6 0L9 4H3L6 0Z"/>
|
||||
</svg>
|
||||
<svg
|
||||
className={clsx('w-3 h-3 -mt-1', {
|
||||
'text-color-primary': isActive && direction === 'desc',
|
||||
'text-text-tertiary': !isActive || direction !== 'desc'
|
||||
})}
|
||||
fill="currentColor"
|
||||
viewBox="0 0 12 12"
|
||||
>
|
||||
<path d="M6 12L3 8H9L6 12Z"/>
|
||||
</svg>
|
||||
</span>
|
||||
);
|
||||
};
|
||||
|
||||
// Estado de selección para checkbox "seleccionar todo"
|
||||
const allSelectedState = useMemo(() => {
|
||||
if (!selection || selection.mode !== 'multiple' || data.length === 0) {
|
||||
return { checked: false, indeterminate: false };
|
||||
}
|
||||
|
||||
const selectedCount = data.filter(row => isRowSelected(row)).length;
|
||||
|
||||
return {
|
||||
checked: selectedCount === data.length,
|
||||
indeterminate: selectedCount > 0 && selectedCount < data.length
|
||||
};
|
||||
}, [data, isRowSelected, selection]);
|
||||
|
||||
// Renderizado de celda
|
||||
const renderCell = (column: DataTableColumn, row: any, rowIndex: number) => {
|
||||
const value = row[column.key];
|
||||
|
||||
if (column.cell) {
|
||||
return column.cell(value, row, rowIndex);
|
||||
}
|
||||
|
||||
if (column.format) {
|
||||
return column.format(value);
|
||||
}
|
||||
|
||||
if (value == null) return '-';
|
||||
|
||||
return String(value);
|
||||
};
|
||||
|
||||
// Manejo de exportación
|
||||
const handleExport = (format: 'csv' | 'xlsx') => {
|
||||
if (onExport) {
|
||||
onExport(format);
|
||||
}
|
||||
};
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<EmptyState
|
||||
variant="error"
|
||||
title="Error al cargar datos"
|
||||
description={error}
|
||||
primaryAction={{
|
||||
label: 'Reintentar',
|
||||
onClick: () => window.location.reload()
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div ref={ref} className={clsx('flex flex-col', className)} {...props}>
|
||||
{/* Header con controles */}
|
||||
<div className="flex flex-col sm:flex-row gap-4 mb-4">
|
||||
{/* Búsqueda */}
|
||||
{onSearchChange && (
|
||||
<div className="flex-1">
|
||||
<Input
|
||||
type="text"
|
||||
placeholder={searchPlaceholder}
|
||||
value={searchQuery}
|
||||
onChange={(e) => onSearchChange(e.target.value)}
|
||||
leftIcon={
|
||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
|
||||
</svg>
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Controles de exportación */}
|
||||
{enableExport && onExport && (
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => handleExport('csv')}
|
||||
leftIcon={
|
||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 10v6m0 0l-3-3m3 3l3-3M3 17V7a2 2 0 012-2h6l2 2h6a2 2 0 012 2v10a2 2 0 01-2 2H5a2 2 0 01-2-2z" />
|
||||
</svg>
|
||||
}
|
||||
>
|
||||
Exportar CSV
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => handleExport('xlsx')}
|
||||
leftIcon={
|
||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 10v6m0 0l-3-3m3 3l3-3M3 17V7a2 2 0 012-2h6l2 2h6a2 2 0 012 2v10a2 2 0 01-2 2H5a2 2 0 01-2-2z" />
|
||||
</svg>
|
||||
}
|
||||
>
|
||||
Exportar Excel
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Información de selección */}
|
||||
{selection && selection.selectedRows.length > 0 && (
|
||||
<div className="bg-color-primary/10 border border-color-primary/20 rounded-lg p-3 mb-4">
|
||||
<span className="text-color-primary text-sm font-medium">
|
||||
{selection.selectedRows.length} {selection.selectedRows.length === 1 ? 'fila seleccionada' : 'filas seleccionadas'}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Contenedor de tabla */}
|
||||
<div
|
||||
className={clsx(
|
||||
'relative border border-border-primary rounded-lg overflow-hidden',
|
||||
{
|
||||
'overflow-x-auto': horizontalScroll
|
||||
}
|
||||
)}
|
||||
style={{ height }}
|
||||
>
|
||||
{/* Loading overlay */}
|
||||
{isLoading && (
|
||||
<div className="absolute inset-0 bg-bg-primary/80 backdrop-blur-sm z-10 flex items-center justify-center">
|
||||
<LoadingSpinner size="lg" text="Cargando datos..." />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Tabla */}
|
||||
<table
|
||||
className="w-full border-collapse bg-bg-primary"
|
||||
role="table"
|
||||
aria-label={ariaLabel}
|
||||
>
|
||||
{/* Header */}
|
||||
<thead className="bg-bg-secondary sticky top-0 z-20">
|
||||
<tr>
|
||||
{/* Checkbox de selección múltiple */}
|
||||
{selection && selection.mode === 'multiple' && (
|
||||
<th className={clsx('border-b border-border-primary', densityClasses[density])}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={allSelectedState.checked}
|
||||
ref={(input) => {
|
||||
if (input) input.indeterminate = allSelectedState.indeterminate;
|
||||
}}
|
||||
onChange={handleSelectAll}
|
||||
className="rounded border-border-secondary focus:ring-color-primary"
|
||||
aria-label="Seleccionar todas las filas"
|
||||
/>
|
||||
</th>
|
||||
)}
|
||||
|
||||
{/* Números de fila */}
|
||||
{showRowNumbers && (
|
||||
<th className={clsx('border-b border-border-primary text-left font-medium text-text-secondary', densityClasses[density])}>
|
||||
#
|
||||
</th>
|
||||
)}
|
||||
|
||||
{/* Columnas de expansión */}
|
||||
{expandable && (
|
||||
<th className={clsx('border-b border-border-primary', densityClasses[density])}>
|
||||
<span className="sr-only">Expandir</span>
|
||||
</th>
|
||||
)}
|
||||
|
||||
{/* Columnas de datos */}
|
||||
{columns.map((column) => {
|
||||
const alignClass = column.align === 'center' ? 'text-center' : column.align === 'right' ? 'text-right' : 'text-left';
|
||||
|
||||
return (
|
||||
<th
|
||||
key={column.id}
|
||||
className={clsx(
|
||||
'border-b border-border-primary font-medium text-text-secondary',
|
||||
densityClasses[density],
|
||||
alignClass,
|
||||
{
|
||||
'cursor-pointer hover:bg-bg-tertiary': column.sortable,
|
||||
'hidden sm:table-cell': column.hideOnMobile,
|
||||
'sticky bg-bg-secondary': column.sticky,
|
||||
'left-0': column.sticky === 'left',
|
||||
'right-0': column.sticky === 'right'
|
||||
}
|
||||
)}
|
||||
style={{
|
||||
width: column.width,
|
||||
minWidth: column.minWidth,
|
||||
maxWidth: column.maxWidth
|
||||
}}
|
||||
onClick={() => column.sortable && handleSort(column.id)}
|
||||
role={column.sortable ? 'button' : undefined}
|
||||
aria-sort={
|
||||
sorting?.column === column.id
|
||||
? sorting.direction === 'asc'
|
||||
? 'ascending'
|
||||
: 'descending'
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
{column.headerCell ? column.headerCell() : (
|
||||
<div className="flex items-center">
|
||||
{column.header}
|
||||
{renderSortIcon(column)}
|
||||
</div>
|
||||
)}
|
||||
</th>
|
||||
);
|
||||
})}
|
||||
</tr>
|
||||
</thead>
|
||||
|
||||
{/* Body */}
|
||||
<tbody className="bg-bg-primary">
|
||||
{data.length === 0 && !isLoading ? (
|
||||
<tr>
|
||||
<td colSpan={columns.length + (selection ? 1 : 0) + (showRowNumbers ? 1 : 0) + (expandable ? 1 : 0)} className="p-8 bg-bg-primary">
|
||||
<EmptyState
|
||||
variant="no-data"
|
||||
size="sm"
|
||||
title={emptyStateMessage || 'No hay datos'}
|
||||
description="No se encontraron registros para mostrar"
|
||||
primaryAction={emptyStateAction}
|
||||
/>
|
||||
</td>
|
||||
</tr>
|
||||
) : (
|
||||
data.map((row, rowIndex) => {
|
||||
const getId = selection?.getRowId || ((r: any) => r.id || rowIndex);
|
||||
const rowId = getId(row);
|
||||
const isSelected = isRowSelected(row);
|
||||
const isExpanded = isRowExpanded(row);
|
||||
|
||||
return (
|
||||
<React.Fragment key={rowId}>
|
||||
<tr
|
||||
className={clsx(
|
||||
'border-b border-border-primary hover:bg-bg-secondary transition-colors bg-bg-primary',
|
||||
{
|
||||
'bg-color-primary/5': isSelected,
|
||||
'cursor-pointer': onRowClick
|
||||
},
|
||||
getRowClassName?.(row, rowIndex)
|
||||
)}
|
||||
onClick={() => onRowClick?.(row, rowIndex)}
|
||||
onDoubleClick={() => onRowDoubleClick?.(row, rowIndex)}
|
||||
>
|
||||
{/* Checkbox de selección */}
|
||||
{selection && (
|
||||
<td className={clsx('border-r border-border-primary bg-bg-primary', densityClasses[density])}>
|
||||
<input
|
||||
type={selection.mode === 'single' ? 'radio' : 'checkbox'}
|
||||
checked={isSelected}
|
||||
onChange={(e) => handleRowSelection(row, e.target.checked)}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
className="rounded border-border-secondary focus:ring-color-primary"
|
||||
aria-label={`Seleccionar fila ${rowIndex + 1}`}
|
||||
/>
|
||||
</td>
|
||||
)}
|
||||
|
||||
{/* Número de fila */}
|
||||
{showRowNumbers && (
|
||||
<td className={clsx('border-r border-border-primary text-text-tertiary font-mono bg-bg-primary', densityClasses[density])}>
|
||||
{rowIndex + 1}
|
||||
</td>
|
||||
)}
|
||||
|
||||
{/* Botón de expansión */}
|
||||
{expandable && (
|
||||
<td className={clsx('border-r border-border-primary bg-bg-primary', densityClasses[density])}>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="xs"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleRowExpansion(row);
|
||||
}}
|
||||
aria-label={`${isExpanded ? 'Contraer' : 'Expandir'} fila`}
|
||||
>
|
||||
<svg
|
||||
className={clsx('w-4 h-4 transition-transform', {
|
||||
'rotate-90': isExpanded
|
||||
})}
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" />
|
||||
</svg>
|
||||
</Button>
|
||||
</td>
|
||||
)}
|
||||
|
||||
{/* Celdas de datos */}
|
||||
{columns.map((column) => {
|
||||
const alignClass = column.align === 'center' ? 'text-center' : column.align === 'right' ? 'text-right' : 'text-left';
|
||||
|
||||
return (
|
||||
<td
|
||||
key={column.id}
|
||||
className={clsx(
|
||||
densityClasses[density],
|
||||
alignClass,
|
||||
'bg-bg-primary',
|
||||
{
|
||||
'hidden sm:table-cell': column.hideOnMobile,
|
||||
'sticky bg-bg-primary': column.sticky,
|
||||
'left-0': column.sticky === 'left',
|
||||
'right-0': column.sticky === 'right'
|
||||
}
|
||||
)}
|
||||
style={{
|
||||
width: column.width,
|
||||
minWidth: column.minWidth,
|
||||
maxWidth: column.maxWidth
|
||||
}}
|
||||
>
|
||||
{renderCell(column, row, rowIndex)}
|
||||
</td>
|
||||
);
|
||||
})}
|
||||
</tr>
|
||||
|
||||
{/* Fila expandida */}
|
||||
{expandable && isExpanded && (
|
||||
<tr>
|
||||
<td
|
||||
colSpan={columns.length + (selection ? 1 : 0) + (showRowNumbers ? 1 : 0) + 1}
|
||||
className="p-0 border-b border-border-primary bg-bg-primary"
|
||||
>
|
||||
<div className="bg-bg-secondary p-4">
|
||||
{expandable.renderExpandedRow(row, rowIndex)}
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
</React.Fragment>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{/* Paginación */}
|
||||
{pagination && pagination.total > 0 && onPageChange && (
|
||||
<div className="flex items-center justify-between mt-4 pt-4 border-t border-border-primary">
|
||||
<div className="text-sm text-text-secondary">
|
||||
Mostrando {((pagination.page - 1) * pagination.pageSize) + 1} a {Math.min(pagination.page * pagination.pageSize, pagination.total)} de {pagination.total} resultados
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => onPageChange(pagination.page - 1, pagination.pageSize)}
|
||||
disabled={pagination.page <= 1}
|
||||
leftIcon={
|
||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 19l-7-7 7-7" />
|
||||
</svg>
|
||||
}
|
||||
>
|
||||
Anterior
|
||||
</Button>
|
||||
|
||||
<span className="px-3 py-1 text-sm">
|
||||
Página {pagination.page} de {Math.ceil(pagination.total / pagination.pageSize)}
|
||||
</span>
|
||||
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => onPageChange(pagination.page + 1, pagination.pageSize)}
|
||||
disabled={pagination.page >= Math.ceil(pagination.total / pagination.pageSize)}
|
||||
rightIcon={
|
||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" />
|
||||
</svg>
|
||||
}
|
||||
>
|
||||
Siguiente
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
DataTable.displayName = 'DataTable';
|
||||
|
||||
export default DataTable;
|
||||
@@ -1,9 +0,0 @@
|
||||
export { default as DataTable } from './DataTable';
|
||||
export type {
|
||||
DataTableProps,
|
||||
DataTableColumn,
|
||||
DataTableFilter,
|
||||
DataTableSort,
|
||||
DataTablePagination,
|
||||
DataTableSelection
|
||||
} from './DataTable';
|
||||
@@ -1,13 +0,0 @@
|
||||
// Shared Components - Reusable utilities
|
||||
export { default as LoadingSpinner } from './LoadingSpinner/LoadingSpinner';
|
||||
export { default as EmptyState } from './EmptyState/EmptyState';
|
||||
export { default as ErrorBoundary } from './ErrorBoundary/ErrorBoundary';
|
||||
export { default as ConfirmDialog } from './ConfirmDialog/ConfirmDialog';
|
||||
export { default as DataTable } from './DataTable/DataTable';
|
||||
|
||||
// Export types
|
||||
export type { LoadingSpinnerProps } from './LoadingSpinner/LoadingSpinner';
|
||||
export type { EmptyStateProps } from './EmptyState/EmptyState';
|
||||
export type { ErrorBoundaryProps } from './ErrorBoundary/ErrorBoundary';
|
||||
export type { ConfirmDialogProps } from './ConfirmDialog/ConfirmDialog';
|
||||
export type { DataTableProps, DataTableColumn, DataTableFilter, DataTableSort, DataTablePagination, DataTableSelection } from './DataTable/DataTable';
|
||||
713
frontend/src/components/ui/AddModal/AddModal.tsx
Normal file
713
frontend/src/components/ui/AddModal/AddModal.tsx
Normal file
@@ -0,0 +1,713 @@
|
||||
import React, { useState, useEffect, useRef } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { LucideIcon, Plus, Save, X, Trash2 } from 'lucide-react';
|
||||
import Modal, { ModalHeader, ModalBody, ModalFooter } from '../Modal/Modal';
|
||||
import { Button } from '../Button';
|
||||
import { Input } from '../Input';
|
||||
import { Select } from '../Select';
|
||||
import { StatusIndicatorConfig } from '../StatusCard';
|
||||
import { statusColors } from '../../../styles/colors';
|
||||
|
||||
// Constants to prevent re-creation on every render
|
||||
const EMPTY_VALIDATION_ERRORS = {};
|
||||
const EMPTY_INITIAL_DATA = {};
|
||||
|
||||
/**
|
||||
* ListFieldRenderer - Native component for managing lists of structured items
|
||||
*/
|
||||
interface ListFieldRendererProps {
|
||||
field: AddModalField;
|
||||
value: any[];
|
||||
onChange: (newValue: any[]) => void;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
const ListFieldRenderer: React.FC<ListFieldRendererProps> = ({ field, value, onChange, error }) => {
|
||||
const { t } = useTranslation(['common']);
|
||||
const listConfig = field.listConfig!;
|
||||
|
||||
const addItem = () => {
|
||||
const newItem: any = { id: `item-${Date.now()}-${Math.random().toString(36).substr(2, 9)}` };
|
||||
|
||||
// Initialize with default values
|
||||
listConfig.itemFields.forEach(itemField => {
|
||||
newItem[itemField.name] = itemField.defaultValue ?? (itemField.type === 'number' || itemField.type === 'currency' ? 0 : '');
|
||||
});
|
||||
|
||||
onChange([...value, newItem]);
|
||||
};
|
||||
|
||||
const removeItem = (index: number) => {
|
||||
onChange(value.filter((_, i) => i !== index));
|
||||
};
|
||||
|
||||
const updateItem = (index: number, fieldName: string, newValue: any) => {
|
||||
const updated = value.map((item, i) =>
|
||||
i === index ? { ...item, [fieldName]: newValue } : item
|
||||
);
|
||||
onChange(updated);
|
||||
};
|
||||
|
||||
const calculateSubtotal = (item: any) => {
|
||||
if (!listConfig.showSubtotals || !listConfig.subtotalFields) return 0;
|
||||
|
||||
const quantity = item[listConfig.subtotalFields.quantity] || 0;
|
||||
const price = item[listConfig.subtotalFields.price] || 0;
|
||||
return quantity * price;
|
||||
};
|
||||
|
||||
const calculateTotal = () => {
|
||||
if (!listConfig.showSubtotals) return 0;
|
||||
return value.reduce((total, item) => total + calculateSubtotal(item), 0);
|
||||
};
|
||||
|
||||
const renderItemField = (item: any, itemIndex: number, fieldConfig: any) => {
|
||||
const fieldValue = item[fieldConfig.name] ?? '';
|
||||
|
||||
switch (fieldConfig.type) {
|
||||
case 'select':
|
||||
return (
|
||||
<select
|
||||
value={fieldValue}
|
||||
onChange={(e) => updateItem(itemIndex, fieldConfig.name, e.target.value)}
|
||||
className="w-full px-3 py-2 border border-[var(--border-secondary)] rounded focus:outline-none focus:ring-2 focus:ring-[var(--color-primary)] bg-[var(--bg-primary)] text-[var(--text-primary)] text-sm"
|
||||
required={fieldConfig.required}
|
||||
>
|
||||
<option value="">Seleccionar...</option>
|
||||
{fieldConfig.options?.map((option: any) => (
|
||||
<option key={option.value} value={option.value}>{option.label}</option>
|
||||
))}
|
||||
</select>
|
||||
);
|
||||
|
||||
case 'number':
|
||||
case 'currency':
|
||||
return (
|
||||
<input
|
||||
type="number"
|
||||
value={fieldValue}
|
||||
onChange={(e) => updateItem(itemIndex, fieldConfig.name, parseFloat(e.target.value) || 0)}
|
||||
className="w-full px-3 py-2 border border-[var(--border-secondary)] rounded focus:outline-none focus:ring-2 focus:ring-[var(--color-primary)] bg-[var(--bg-primary)] text-[var(--text-primary)] text-sm"
|
||||
min="0"
|
||||
step={fieldConfig.type === 'currency' ? '0.01' : '0.1'}
|
||||
placeholder={fieldConfig.placeholder}
|
||||
required={fieldConfig.required}
|
||||
/>
|
||||
);
|
||||
|
||||
default: // text
|
||||
return (
|
||||
<input
|
||||
type="text"
|
||||
value={fieldValue}
|
||||
onChange={(e) => updateItem(itemIndex, fieldConfig.name, e.target.value)}
|
||||
className="w-full px-3 py-2 border border-[var(--border-secondary)] rounded focus:outline-none focus:ring-2 focus:ring-[var(--color-primary)] bg-[var(--bg-primary)] text-[var(--text-primary)] text-sm"
|
||||
placeholder={fieldConfig.placeholder}
|
||||
required={fieldConfig.required}
|
||||
/>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<h4 className="text-sm font-medium text-[var(--text-primary)]">{field.label}</h4>
|
||||
<button
|
||||
type="button"
|
||||
onClick={addItem}
|
||||
className="flex items-center gap-2 px-3 py-1.5 text-sm bg-[var(--color-primary)] text-white rounded-md hover:bg-[var(--color-primary)]/90 transition-colors"
|
||||
>
|
||||
<Plus className="w-4 h-4" />
|
||||
{listConfig.addButtonLabel || t('common:modals.actions.add', 'Agregar')}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{value.length === 0 ? (
|
||||
<div className="text-center py-8 text-[var(--text-secondary)] border-2 border-dashed border-[var(--border-secondary)] rounded-lg">
|
||||
<div className="w-8 h-8 mx-auto mb-2 opacity-50">
|
||||
<Plus className="w-full h-full" />
|
||||
</div>
|
||||
<p>{listConfig.emptyStateText || 'No hay elementos agregados'}</p>
|
||||
<p className="text-sm">Haz clic en "{listConfig.addButtonLabel || 'Agregar'}" para comenzar</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-3 max-h-96 overflow-y-auto">
|
||||
{value.map((item, itemIndex) => (
|
||||
<div key={item.id || itemIndex} className="p-4 border border-[var(--border-secondary)] rounded-lg bg-[var(--bg-secondary)]/50 space-y-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-sm font-medium text-[var(--text-primary)]">Elemento #{itemIndex + 1}</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => removeItem(itemIndex)}
|
||||
className="p-1 text-red-500 hover:text-red-700 transition-colors"
|
||||
>
|
||||
<Trash2 className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-3">
|
||||
{listConfig.itemFields.map((fieldConfig) => (
|
||||
<div key={fieldConfig.name}>
|
||||
<label className="block text-xs font-medium text-[var(--text-secondary)] mb-1">
|
||||
{fieldConfig.label}
|
||||
{fieldConfig.required && <span className="text-red-500 ml-1">*</span>}
|
||||
</label>
|
||||
{renderItemField(item, itemIndex, fieldConfig)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{listConfig.showSubtotals && (
|
||||
<div className="pt-2 border-t border-[var(--border-primary)] text-sm text-[var(--text-secondary)]">
|
||||
<span className="font-medium">Subtotal: €{calculateSubtotal(item).toFixed(2)}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{listConfig.showSubtotals && value.length > 0 && (
|
||||
<div className="pt-3 border-t border-[var(--border-primary)] text-right">
|
||||
<span className="text-lg font-semibold text-[var(--text-primary)]">
|
||||
Total: €{calculateTotal().toFixed(2)}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export interface AddModalField {
|
||||
label: string;
|
||||
name: string;
|
||||
type?: 'text' | 'email' | 'tel' | 'number' | 'currency' | 'date' | 'select' | 'textarea' | 'component' | 'list';
|
||||
required?: boolean;
|
||||
placeholder?: string;
|
||||
options?: Array<{label: string; value: string | number}>;
|
||||
validation?: (value: string | number | any) => string | null;
|
||||
helpText?: string;
|
||||
span?: 1 | 2; // For grid layout
|
||||
defaultValue?: string | number | any;
|
||||
component?: React.ComponentType<any>; // For custom components
|
||||
componentProps?: Record<string, any>; // Props for custom components
|
||||
|
||||
// List field configuration
|
||||
listConfig?: {
|
||||
itemFields: Array<{
|
||||
name: string;
|
||||
label: string;
|
||||
type: 'text' | 'number' | 'select' | 'currency';
|
||||
required?: boolean;
|
||||
placeholder?: string;
|
||||
options?: Array<{label: string; value: string | number}>;
|
||||
defaultValue?: any;
|
||||
validation?: (value: any) => string | null;
|
||||
}>;
|
||||
addButtonLabel?: string;
|
||||
removeButtonLabel?: string;
|
||||
emptyStateText?: string;
|
||||
showSubtotals?: boolean; // For calculating item totals
|
||||
subtotalFields?: { quantity: string; price: string }; // Field names for calculation
|
||||
};
|
||||
}
|
||||
|
||||
export interface AddModalSection {
|
||||
title: string;
|
||||
icon?: LucideIcon;
|
||||
fields: AddModalField[];
|
||||
columns?: 1 | 2; // Grid columns for this section
|
||||
}
|
||||
|
||||
export interface AddModalProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
|
||||
// Content
|
||||
title: string;
|
||||
subtitle?: string;
|
||||
statusIndicator?: StatusIndicatorConfig;
|
||||
sections: AddModalSection[];
|
||||
|
||||
// Actions
|
||||
onSave: (formData: Record<string, any>) => Promise<void>;
|
||||
onCancel?: () => void;
|
||||
|
||||
// Layout
|
||||
size?: 'sm' | 'md' | 'lg' | 'xl' | '2xl';
|
||||
loading?: boolean;
|
||||
|
||||
// Initial form data
|
||||
initialData?: Record<string, any>;
|
||||
|
||||
// Validation
|
||||
validationErrors?: Record<string, string>;
|
||||
onValidationError?: (errors: Record<string, string>) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* AddModal - Specialized modal component for creating new items
|
||||
* Provides a simplified interface compared to EditViewModal, focused on creation workflows
|
||||
*
|
||||
* Features:
|
||||
* - Form-based interface optimized for adding new items
|
||||
* - Built-in form state management
|
||||
* - Validation support with error display
|
||||
* - Responsive grid layout for fields
|
||||
* - Loading states and action buttons
|
||||
* - Support for various field types
|
||||
*/
|
||||
export const AddModal: React.FC<AddModalProps> = ({
|
||||
isOpen,
|
||||
onClose,
|
||||
title,
|
||||
subtitle,
|
||||
statusIndicator,
|
||||
sections,
|
||||
onSave,
|
||||
onCancel,
|
||||
size = 'lg',
|
||||
loading = false,
|
||||
initialData = EMPTY_INITIAL_DATA,
|
||||
validationErrors = EMPTY_VALIDATION_ERRORS,
|
||||
onValidationError,
|
||||
}) => {
|
||||
const [formData, setFormData] = useState<Record<string, any>>({});
|
||||
const [fieldErrors, setFieldErrors] = useState<Record<string, string>>({});
|
||||
const { t } = useTranslation(['common']);
|
||||
|
||||
// Track if we've initialized the form data for this modal session
|
||||
const initializedRef = useRef(false);
|
||||
|
||||
// Initialize form data when modal opens
|
||||
useEffect(() => {
|
||||
if (isOpen && !initializedRef.current) {
|
||||
const defaultFormData: Record<string, any> = {};
|
||||
|
||||
// Populate with default values from sections
|
||||
sections.forEach(section => {
|
||||
section.fields.forEach(field => {
|
||||
if (field.defaultValue !== undefined) {
|
||||
defaultFormData[field.name] = field.defaultValue;
|
||||
} else {
|
||||
defaultFormData[field.name] = field.type === 'number' || field.type === 'currency' ? 0 : '';
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// Merge with initialData
|
||||
setFormData({ ...defaultFormData, ...initialData });
|
||||
setFieldErrors({});
|
||||
initializedRef.current = true;
|
||||
} else if (!isOpen) {
|
||||
// Reset initialization flag when modal closes
|
||||
initializedRef.current = false;
|
||||
}
|
||||
}, [isOpen, initialData]);
|
||||
|
||||
// Update field errors when validation errors change
|
||||
useEffect(() => {
|
||||
setFieldErrors(validationErrors);
|
||||
}, [validationErrors]);
|
||||
|
||||
const defaultStatusIndicator: StatusIndicatorConfig = statusIndicator || {
|
||||
color: statusColors.inProgress.primary,
|
||||
text: 'Nuevo',
|
||||
icon: Plus,
|
||||
isCritical: false,
|
||||
isHighlight: true
|
||||
};
|
||||
|
||||
const handleFieldChange = (fieldName: string, value: string | number) => {
|
||||
setFormData(prev => ({
|
||||
...prev,
|
||||
[fieldName]: value
|
||||
}));
|
||||
|
||||
// Clear field error if it exists
|
||||
if (fieldErrors[fieldName]) {
|
||||
const newErrors = { ...fieldErrors };
|
||||
delete newErrors[fieldName];
|
||||
setFieldErrors(newErrors);
|
||||
onValidationError?.(newErrors);
|
||||
}
|
||||
|
||||
// Run field validation if provided
|
||||
const field = findFieldByName(fieldName);
|
||||
if (field?.validation) {
|
||||
const error = field.validation(value);
|
||||
if (error) {
|
||||
const newErrors = { ...fieldErrors, [fieldName]: error };
|
||||
setFieldErrors(newErrors);
|
||||
onValidationError?.(newErrors);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const findFieldByName = (fieldName: string): AddModalField | undefined => {
|
||||
for (const section of sections) {
|
||||
const field = section.fields.find(f => f.name === fieldName);
|
||||
if (field) return field;
|
||||
}
|
||||
return undefined;
|
||||
};
|
||||
|
||||
const validateForm = (): boolean => {
|
||||
const errors: Record<string, string> = {};
|
||||
|
||||
sections.forEach(section => {
|
||||
section.fields.forEach(field => {
|
||||
const value = formData[field.name];
|
||||
|
||||
// Check required fields
|
||||
if (field.required && (!value || String(value).trim() === '')) {
|
||||
errors[field.name] = `${field.label} ${t('common:modals.validation.required_field', 'es requerido')}`;
|
||||
}
|
||||
|
||||
// Run custom validation
|
||||
if (value && field.validation) {
|
||||
const error = field.validation(value);
|
||||
if (error) {
|
||||
errors[field.name] = error;
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
setFieldErrors(errors);
|
||||
onValidationError?.(errors);
|
||||
return Object.keys(errors).length === 0;
|
||||
};
|
||||
|
||||
const handleSave = async () => {
|
||||
if (!validateForm()) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await onSave(formData);
|
||||
onClose();
|
||||
} catch (error) {
|
||||
console.error('Error saving form:', error);
|
||||
// Don't close modal on error - let the parent handle error display
|
||||
}
|
||||
};
|
||||
|
||||
const handleCancel = () => {
|
||||
if (onCancel) {
|
||||
onCancel();
|
||||
}
|
||||
onClose();
|
||||
};
|
||||
|
||||
const renderField = (field: AddModalField): React.ReactNode => {
|
||||
const value = formData[field.name] ?? '';
|
||||
const error = fieldErrors[field.name];
|
||||
|
||||
const handleChange = (e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement>) => {
|
||||
const newValue = field.type === 'number' || field.type === 'currency'
|
||||
? Number(e.target.value)
|
||||
: e.target.value;
|
||||
handleFieldChange(field.name, newValue);
|
||||
};
|
||||
|
||||
const inputValue = field.type === 'currency'
|
||||
? Number(String(value).replace(/[^0-9.-]+/g, ''))
|
||||
: value;
|
||||
|
||||
switch (field.type) {
|
||||
case 'select':
|
||||
return (
|
||||
<div className="w-full">
|
||||
<Select
|
||||
value={String(value)}
|
||||
onChange={(newValue) => handleFieldChange(field.name, newValue)}
|
||||
options={field.options || []}
|
||||
placeholder={field.placeholder}
|
||||
isRequired={field.required}
|
||||
variant="outline"
|
||||
size="md"
|
||||
/>
|
||||
{error && (
|
||||
<p className="mt-1 text-sm text-red-600">{error}</p>
|
||||
)}
|
||||
{field.helpText && !error && (
|
||||
<p className="mt-1 text-sm text-[var(--text-tertiary)]">{field.helpText}</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
case 'textarea':
|
||||
return (
|
||||
<div className="w-full">
|
||||
<textarea
|
||||
value={String(value)}
|
||||
onChange={handleChange}
|
||||
placeholder={field.placeholder}
|
||||
required={field.required}
|
||||
rows={4}
|
||||
className={`w-full px-3 py-2 border rounded-md focus:outline-none focus:ring-2 focus:border-transparent ${
|
||||
error
|
||||
? 'border-red-500 focus:ring-red-500'
|
||||
: 'border-[var(--border-secondary)] focus:ring-[var(--color-primary)]'
|
||||
}`}
|
||||
/>
|
||||
{error && (
|
||||
<p className="mt-1 text-sm text-red-600">{error}</p>
|
||||
)}
|
||||
{field.helpText && !error && (
|
||||
<p className="mt-1 text-sm text-[var(--text-tertiary)]">{field.helpText}</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
case 'date':
|
||||
const dateValue = value ? new Date(String(value)).toISOString().split('T')[0] : '';
|
||||
return (
|
||||
<div className="w-full">
|
||||
<Input
|
||||
type="date"
|
||||
value={dateValue}
|
||||
onChange={handleChange}
|
||||
required={field.required}
|
||||
className={`w-full ${error ? 'border-red-500 focus:ring-red-500' : ''}`}
|
||||
/>
|
||||
{error && (
|
||||
<p className="mt-1 text-sm text-red-600">{error}</p>
|
||||
)}
|
||||
{field.helpText && !error && (
|
||||
<p className="mt-1 text-sm text-[var(--text-tertiary)]">{field.helpText}</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
case 'component':
|
||||
if (field.component) {
|
||||
const Component = field.component;
|
||||
return (
|
||||
<div className="w-full">
|
||||
<Component
|
||||
value={value}
|
||||
onChange={(newValue: any) => handleFieldChange(field.name, newValue)}
|
||||
{...field.componentProps}
|
||||
/>
|
||||
{error && (
|
||||
<p className="mt-1 text-sm text-red-600">{error}</p>
|
||||
)}
|
||||
{field.helpText && !error && (
|
||||
<p className="mt-1 text-sm text-[var(--text-tertiary)]">{field.helpText}</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return null;
|
||||
|
||||
case 'list':
|
||||
return (
|
||||
<div className="w-full">
|
||||
<ListFieldRenderer
|
||||
field={field}
|
||||
value={Array.isArray(value) ? value : []}
|
||||
onChange={(newValue: any[]) => handleFieldChange(field.name, newValue)}
|
||||
error={error}
|
||||
/>
|
||||
{error && (
|
||||
<p className="mt-1 text-sm text-red-600">{error}</p>
|
||||
)}
|
||||
{field.helpText && !error && (
|
||||
<p className="mt-1 text-sm text-[var(--text-tertiary)]">{field.helpText}</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
case 'number':
|
||||
case 'currency':
|
||||
return (
|
||||
<div className="w-full">
|
||||
<Input
|
||||
type="number"
|
||||
value={String(inputValue)}
|
||||
onChange={handleChange}
|
||||
placeholder={field.placeholder}
|
||||
required={field.required}
|
||||
step={field.type === 'currency' ? '0.01' : '1'}
|
||||
className={`w-full ${error ? 'border-red-500 focus:ring-red-500' : ''}`}
|
||||
/>
|
||||
{error && (
|
||||
<p className="mt-1 text-sm text-red-600">{error}</p>
|
||||
)}
|
||||
{field.helpText && !error && (
|
||||
<p className="mt-1 text-sm text-[var(--text-tertiary)]">{field.helpText}</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
default:
|
||||
return (
|
||||
<div className="w-full">
|
||||
<Input
|
||||
type={field.type || 'text'}
|
||||
value={String(inputValue)}
|
||||
onChange={handleChange}
|
||||
placeholder={field.placeholder}
|
||||
required={field.required}
|
||||
className={`w-full ${error ? 'border-red-500 focus:ring-red-500' : ''}`}
|
||||
/>
|
||||
{error && (
|
||||
<p className="mt-1 text-sm text-red-600">{error}</p>
|
||||
)}
|
||||
{field.helpText && !error && (
|
||||
<p className="mt-1 text-sm text-[var(--text-tertiary)]">{field.helpText}</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
const StatusIcon = defaultStatusIndicator.icon;
|
||||
|
||||
return (
|
||||
<Modal
|
||||
isOpen={isOpen}
|
||||
onClose={onClose}
|
||||
size={size}
|
||||
closeOnOverlayClick={!loading}
|
||||
closeOnEscape={!loading}
|
||||
showCloseButton={false}
|
||||
>
|
||||
<ModalHeader
|
||||
title={
|
||||
<div className="flex items-center gap-3">
|
||||
{/* Status indicator */}
|
||||
<div
|
||||
className="flex-shrink-0 p-2 rounded-lg"
|
||||
style={{ backgroundColor: `${defaultStatusIndicator.color}15` }}
|
||||
>
|
||||
{StatusIcon && (
|
||||
<StatusIcon
|
||||
className="w-5 h-5"
|
||||
style={{ color: defaultStatusIndicator.color }}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Title and status */}
|
||||
<div>
|
||||
<h2 className="text-xl font-semibold text-[var(--text-primary)]">
|
||||
{title}
|
||||
</h2>
|
||||
<div
|
||||
className="text-sm font-medium mt-1"
|
||||
style={{ color: defaultStatusIndicator.color }}
|
||||
>
|
||||
{defaultStatusIndicator.text}
|
||||
{defaultStatusIndicator.isCritical && (
|
||||
<span className="ml-2 text-xs">⚠️</span>
|
||||
)}
|
||||
{defaultStatusIndicator.isHighlight && (
|
||||
<span className="ml-2 text-xs">⭐</span>
|
||||
)}
|
||||
</div>
|
||||
{subtitle && (
|
||||
<p className="text-sm text-[var(--text-secondary)] mt-1">
|
||||
{subtitle}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
showCloseButton={true}
|
||||
onClose={onClose}
|
||||
/>
|
||||
|
||||
<ModalBody>
|
||||
{loading && (
|
||||
<div className="absolute inset-0 bg-[var(--bg-primary)]/80 backdrop-blur-sm flex items-center justify-center z-10">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="animate-spin rounded-full h-6 w-6 border-b-2 border-[var(--color-primary)]"></div>
|
||||
<span className="text-[var(--text-secondary)]">{t('common:modals.saving', 'Guardando...')}</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="space-y-6">
|
||||
{sections.map((section, sectionIndex) => {
|
||||
const sectionColumns = section.columns || 2;
|
||||
|
||||
const getGridClasses = () => {
|
||||
return sectionColumns === 1
|
||||
? 'grid grid-cols-1 gap-4'
|
||||
: 'grid grid-cols-1 md:grid-cols-2 gap-4';
|
||||
};
|
||||
|
||||
return (
|
||||
<div key={sectionIndex} className="space-y-4">
|
||||
<div className="flex items-start gap-3 pb-3 border-b border-[var(--border-primary)]">
|
||||
{section.icon && (
|
||||
<section.icon className="w-5 h-5 text-[var(--text-secondary)] flex-shrink-0 mt-0.5" />
|
||||
)}
|
||||
<div className="flex-1">
|
||||
<h3 className="font-medium text-[var(--text-primary)] leading-6">
|
||||
{section.title}
|
||||
</h3>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className={getGridClasses()}>
|
||||
{section.fields.map((field, fieldIndex) => (
|
||||
<div
|
||||
key={fieldIndex}
|
||||
className={`space-y-2 ${
|
||||
field.span === 2 ? 'md:col-span-2' : ''
|
||||
}`}
|
||||
>
|
||||
<label className="text-sm font-medium text-[var(--text-secondary)]">
|
||||
{field.label}
|
||||
{field.required && (
|
||||
<span className="text-red-500 ml-1">*</span>
|
||||
)}
|
||||
</label>
|
||||
{renderField(field)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</ModalBody>
|
||||
|
||||
<ModalFooter justify="end">
|
||||
<div className="flex gap-3">
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={handleCancel}
|
||||
disabled={loading}
|
||||
className="min-w-[80px]"
|
||||
>
|
||||
<X className="w-4 h-4 mr-2" />
|
||||
{t('common:modals.actions.cancel', 'Cancelar')}
|
||||
</Button>
|
||||
<Button
|
||||
variant="primary"
|
||||
onClick={handleSave}
|
||||
disabled={loading}
|
||||
className="min-w-[80px]"
|
||||
>
|
||||
{loading ? (
|
||||
<div className="animate-spin rounded-full h-4 w-4 border-b-2 border-current"></div>
|
||||
) : (
|
||||
<>
|
||||
<Save className="w-4 h-4 mr-2" />
|
||||
{t('common:modals.actions.save', 'Guardar')}
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</ModalFooter>
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
|
||||
export default AddModal;
|
||||
6
frontend/src/components/ui/AddModal/index.ts
Normal file
6
frontend/src/components/ui/AddModal/index.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
export { AddModal, default } from './AddModal';
|
||||
export type {
|
||||
AddModalProps,
|
||||
AddModalField,
|
||||
AddModalSection
|
||||
} from './AddModal';
|
||||
321
frontend/src/components/ui/DialogModal/DialogModal.tsx
Normal file
321
frontend/src/components/ui/DialogModal/DialogModal.tsx
Normal file
@@ -0,0 +1,321 @@
|
||||
import React from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { LucideIcon, AlertTriangle, CheckCircle, XCircle, Info, X } from 'lucide-react';
|
||||
import Modal, { ModalHeader, ModalBody, ModalFooter } from '../Modal/Modal';
|
||||
import { Button } from '../Button';
|
||||
|
||||
export interface DialogModalAction {
|
||||
label: string;
|
||||
variant?: 'primary' | 'secondary' | 'outline' | 'danger';
|
||||
onClick: () => void | Promise<void>;
|
||||
disabled?: boolean;
|
||||
loading?: boolean;
|
||||
}
|
||||
|
||||
export interface DialogModalProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
|
||||
// Content
|
||||
title: string;
|
||||
message: string | React.ReactNode;
|
||||
type?: 'info' | 'warning' | 'error' | 'success' | 'confirm' | 'custom';
|
||||
icon?: LucideIcon;
|
||||
|
||||
// Actions
|
||||
actions?: DialogModalAction[];
|
||||
showCloseButton?: boolean;
|
||||
|
||||
// Convenience props for common dialogs
|
||||
onConfirm?: () => void | Promise<void>;
|
||||
onCancel?: () => void;
|
||||
confirmLabel?: string;
|
||||
cancelLabel?: string;
|
||||
|
||||
// Layout
|
||||
size?: 'xs' | 'sm' | 'md';
|
||||
loading?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* DialogModal - Standardized component for simple confirmation dialogs and alerts
|
||||
*
|
||||
* Features:
|
||||
* - Predefined dialog types with appropriate icons and styling
|
||||
* - Support for custom actions or convenient confirm/cancel pattern
|
||||
* - Responsive design optimized for mobile
|
||||
* - Built-in loading states
|
||||
* - Accessibility features
|
||||
*/
|
||||
export const DialogModal: React.FC<DialogModalProps> = ({
|
||||
isOpen,
|
||||
onClose,
|
||||
title,
|
||||
message,
|
||||
type = 'info',
|
||||
icon,
|
||||
actions,
|
||||
showCloseButton = true,
|
||||
onConfirm,
|
||||
onCancel,
|
||||
confirmLabel,
|
||||
cancelLabel,
|
||||
size = 'sm',
|
||||
loading = false,
|
||||
}) => {
|
||||
const { t } = useTranslation(['common']);
|
||||
|
||||
// Default labels with translation fallbacks
|
||||
const defaultConfirmLabel = confirmLabel || t('common:modals.actions.confirm', 'Confirmar');
|
||||
const defaultCancelLabel = cancelLabel || t('common:modals.actions.cancel', 'Cancelar');
|
||||
|
||||
// Get icon and colors based on dialog type
|
||||
const getDialogConfig = () => {
|
||||
if (icon) {
|
||||
return { icon, color: 'var(--text-primary)' };
|
||||
}
|
||||
|
||||
switch (type) {
|
||||
case 'warning':
|
||||
return { icon: AlertTriangle, color: '#f59e0b' }; // yellow-500
|
||||
case 'error':
|
||||
return { icon: XCircle, color: '#ef4444' }; // red-500
|
||||
case 'success':
|
||||
return { icon: CheckCircle, color: '#10b981' }; // emerald-500
|
||||
case 'confirm':
|
||||
return { icon: AlertTriangle, color: '#f59e0b' }; // yellow-500
|
||||
case 'info':
|
||||
default:
|
||||
return { icon: Info, color: '#3b82f6' }; // blue-500
|
||||
}
|
||||
};
|
||||
|
||||
const { icon: DialogIcon, color } = getDialogConfig();
|
||||
|
||||
const handleConfirm = async () => {
|
||||
if (onConfirm) {
|
||||
await onConfirm();
|
||||
}
|
||||
onClose();
|
||||
};
|
||||
|
||||
const handleCancel = () => {
|
||||
if (onCancel) {
|
||||
onCancel();
|
||||
}
|
||||
onClose();
|
||||
};
|
||||
|
||||
// Generate actions based on type and props
|
||||
const getActions = (): DialogModalAction[] => {
|
||||
if (actions) {
|
||||
return actions;
|
||||
}
|
||||
|
||||
// For simple info/success/error dialogs, just show OK button
|
||||
if (type === 'info' || type === 'success' || type === 'error') {
|
||||
return [
|
||||
{
|
||||
label: t('common:modals.actions.ok', 'OK'),
|
||||
variant: 'primary',
|
||||
onClick: onClose,
|
||||
disabled: loading,
|
||||
}
|
||||
];
|
||||
}
|
||||
|
||||
// For confirm/warning dialogs, show cancel and confirm buttons
|
||||
if (type === 'confirm' || type === 'warning') {
|
||||
return [
|
||||
{
|
||||
label: defaultCancelLabel,
|
||||
variant: 'outline',
|
||||
onClick: handleCancel,
|
||||
disabled: loading,
|
||||
},
|
||||
{
|
||||
label: defaultConfirmLabel,
|
||||
variant: type === 'warning' ? 'danger' : 'primary',
|
||||
onClick: handleConfirm,
|
||||
disabled: loading,
|
||||
loading: loading,
|
||||
}
|
||||
];
|
||||
}
|
||||
|
||||
// Default: just OK button
|
||||
return [
|
||||
{
|
||||
label: 'OK',
|
||||
variant: 'primary',
|
||||
onClick: onClose,
|
||||
disabled: loading,
|
||||
}
|
||||
];
|
||||
};
|
||||
|
||||
const dialogActions = getActions();
|
||||
|
||||
return (
|
||||
<Modal
|
||||
isOpen={isOpen}
|
||||
onClose={onClose}
|
||||
size={size}
|
||||
closeOnOverlayClick={!loading}
|
||||
closeOnEscape={!loading}
|
||||
showCloseButton={false}
|
||||
>
|
||||
<ModalHeader
|
||||
title={
|
||||
<div className="flex items-center gap-3">
|
||||
{/* Dialog icon */}
|
||||
<div
|
||||
className="flex-shrink-0 p-2 rounded-lg"
|
||||
style={{ backgroundColor: `${color}15` }}
|
||||
>
|
||||
<DialogIcon
|
||||
className="w-6 h-6"
|
||||
style={{ color }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Title */}
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold text-[var(--text-primary)]">
|
||||
{title}
|
||||
</h2>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
showCloseButton={showCloseButton && !loading}
|
||||
onClose={onClose}
|
||||
/>
|
||||
|
||||
<ModalBody padding="lg">
|
||||
{loading && (
|
||||
<div className="absolute inset-0 bg-[var(--bg-primary)]/80 backdrop-blur-sm flex items-center justify-center z-10">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="animate-spin rounded-full h-6 w-6 border-b-2 border-[var(--color-primary)]"></div>
|
||||
<span className="text-[var(--text-secondary)]">{t('common:modals.processing', 'Procesando...')}</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="text-[var(--text-primary)]">
|
||||
{typeof message === 'string' ? (
|
||||
<p className="leading-relaxed">{message}</p>
|
||||
) : (
|
||||
message
|
||||
)}
|
||||
</div>
|
||||
</ModalBody>
|
||||
|
||||
{dialogActions.length > 0 && (
|
||||
<ModalFooter justify="end">
|
||||
<div className="flex gap-3 w-full sm:w-auto">
|
||||
{dialogActions.map((action, index) => (
|
||||
<Button
|
||||
key={index}
|
||||
variant={action.variant || 'outline'}
|
||||
onClick={action.onClick}
|
||||
disabled={action.disabled || loading}
|
||||
className={`${
|
||||
dialogActions.length > 1 && size === 'xs' ? 'flex-1 sm:flex-none' : ''
|
||||
} min-w-[80px]`}
|
||||
>
|
||||
{action.loading ? (
|
||||
<div className="animate-spin rounded-full h-4 w-4 border-b-2 border-current"></div>
|
||||
) : (
|
||||
action.label
|
||||
)}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
</ModalFooter>
|
||||
)}
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
|
||||
// Convenience functions for common dialog types
|
||||
export const showInfoDialog = (
|
||||
title: string,
|
||||
message: string,
|
||||
onClose: () => void
|
||||
): React.ReactElement => (
|
||||
<DialogModal
|
||||
isOpen={true}
|
||||
onClose={onClose}
|
||||
title={title}
|
||||
message={message}
|
||||
type="info"
|
||||
/>
|
||||
);
|
||||
|
||||
export const showWarningDialog = (
|
||||
title: string,
|
||||
message: string,
|
||||
onConfirm: () => void,
|
||||
onCancel?: () => void
|
||||
): React.ReactElement => (
|
||||
<DialogModal
|
||||
isOpen={true}
|
||||
onClose={onCancel || (() => {})}
|
||||
title={title}
|
||||
message={message}
|
||||
type="warning"
|
||||
onConfirm={onConfirm}
|
||||
onCancel={onCancel}
|
||||
/>
|
||||
);
|
||||
|
||||
export const showErrorDialog = (
|
||||
title: string,
|
||||
message: string,
|
||||
onClose: () => void
|
||||
): React.ReactElement => (
|
||||
<DialogModal
|
||||
isOpen={true}
|
||||
onClose={onClose}
|
||||
title={title}
|
||||
message={message}
|
||||
type="error"
|
||||
/>
|
||||
);
|
||||
|
||||
export const showSuccessDialog = (
|
||||
title: string,
|
||||
message: string,
|
||||
onClose: () => void
|
||||
): React.ReactElement => (
|
||||
<DialogModal
|
||||
isOpen={true}
|
||||
onClose={onClose}
|
||||
title={title}
|
||||
message={message}
|
||||
type="success"
|
||||
/>
|
||||
);
|
||||
|
||||
export const showConfirmDialog = (
|
||||
title: string,
|
||||
message: string,
|
||||
onConfirm: () => void,
|
||||
onCancel?: () => void,
|
||||
confirmLabel?: string,
|
||||
cancelLabel?: string
|
||||
): React.ReactElement => (
|
||||
<DialogModal
|
||||
isOpen={true}
|
||||
onClose={onCancel || (() => {})}
|
||||
title={title}
|
||||
message={message}
|
||||
type="confirm"
|
||||
onConfirm={onConfirm}
|
||||
onCancel={onCancel}
|
||||
confirmLabel={confirmLabel}
|
||||
cancelLabel={cancelLabel}
|
||||
/>
|
||||
);
|
||||
|
||||
export default DialogModal;
|
||||
14
frontend/src/components/ui/DialogModal/index.ts
Normal file
14
frontend/src/components/ui/DialogModal/index.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
export {
|
||||
DialogModal,
|
||||
default,
|
||||
showInfoDialog,
|
||||
showWarningDialog,
|
||||
showErrorDialog,
|
||||
showSuccessDialog,
|
||||
showConfirmDialog
|
||||
} from './DialogModal';
|
||||
|
||||
export type {
|
||||
DialogModalProps,
|
||||
DialogModalAction
|
||||
} from './DialogModal';
|
||||
698
frontend/src/components/ui/EditViewModal/EditViewModal.tsx
Normal file
698
frontend/src/components/ui/EditViewModal/EditViewModal.tsx
Normal file
@@ -0,0 +1,698 @@
|
||||
import React from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { LucideIcon, Edit } from 'lucide-react';
|
||||
import Modal, { ModalHeader, ModalBody, ModalFooter } from '../Modal/Modal';
|
||||
import { Button } from '../Button';
|
||||
import { Input } from '../Input';
|
||||
import { Select } from '../Select';
|
||||
import { StatusIndicatorConfig, getStatusColor } from '../StatusCard';
|
||||
import { formatters } from '../Stats/StatsPresets';
|
||||
|
||||
export interface EditViewModalField {
|
||||
label: string;
|
||||
value: string | number | React.ReactNode;
|
||||
type?: 'text' | 'currency' | 'date' | 'datetime' | 'percentage' | 'list' | 'status' | 'image' | 'email' | 'tel' | 'number' | 'select' | 'textarea' | 'component';
|
||||
highlight?: boolean;
|
||||
span?: 1 | 2 | 3; // For grid layout - added 3 for full width on larger screens
|
||||
editable?: boolean; // Whether this field can be edited
|
||||
required?: boolean; // Whether this field is required
|
||||
placeholder?: string; // Placeholder text for inputs
|
||||
options?: Array<{label: string; value: string | number}>; // For select fields
|
||||
validation?: (value: string | number) => string | null; // Custom validation function
|
||||
helpText?: string; // Help text displayed below the field
|
||||
component?: React.ComponentType<any>; // For custom components
|
||||
componentProps?: Record<string, any>; // Props for custom components
|
||||
}
|
||||
|
||||
export interface EditViewModalSection {
|
||||
title: string;
|
||||
icon?: LucideIcon;
|
||||
fields: EditViewModalField[];
|
||||
collapsible?: boolean; // Whether section can be collapsed
|
||||
collapsed?: boolean; // Initial collapsed state
|
||||
description?: string; // Section description
|
||||
columns?: 1 | 2 | 3; // Override grid columns for this section
|
||||
}
|
||||
|
||||
export interface EditViewModalAction {
|
||||
label: string;
|
||||
icon?: LucideIcon;
|
||||
variant?: 'primary' | 'secondary' | 'outline' | 'danger';
|
||||
onClick: () => void | Promise<void>;
|
||||
disabled?: boolean;
|
||||
loading?: boolean;
|
||||
}
|
||||
|
||||
export interface EditViewModalProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
mode: 'view' | 'edit';
|
||||
onModeChange?: (mode: 'view' | 'edit') => void;
|
||||
|
||||
// Content
|
||||
title: string;
|
||||
subtitle?: string;
|
||||
statusIndicator?: StatusIndicatorConfig;
|
||||
image?: string;
|
||||
sections: EditViewModalSection[];
|
||||
|
||||
// Actions
|
||||
actions?: EditViewModalAction[];
|
||||
showDefaultActions?: boolean;
|
||||
actionsPosition?: 'header' | 'footer'; // New prop for positioning actions
|
||||
onEdit?: () => void;
|
||||
onSave?: () => Promise<void>;
|
||||
onCancel?: () => void;
|
||||
onFieldChange?: (sectionIndex: number, fieldIndex: number, value: string | number) => void;
|
||||
|
||||
// Layout
|
||||
size?: 'sm' | 'md' | 'lg' | 'xl' | '2xl';
|
||||
loading?: boolean;
|
||||
|
||||
// Enhanced features
|
||||
mobileOptimized?: boolean; // Enable mobile-first responsive design
|
||||
showStepIndicator?: boolean; // Show step indicator for multi-step workflows
|
||||
currentStep?: number; // Current step in workflow
|
||||
totalSteps?: number; // Total steps in workflow
|
||||
validationErrors?: Record<string, string>; // Field validation errors
|
||||
onValidationError?: (errors: Record<string, string>) => void; // Validation error handler
|
||||
}
|
||||
|
||||
/**
|
||||
* Format field value based on type
|
||||
*/
|
||||
const formatFieldValue = (value: string | number | React.ReactNode, type: EditViewModalField['type'] = 'text'): React.ReactNode => {
|
||||
if (React.isValidElement(value)) return value;
|
||||
|
||||
switch (type) {
|
||||
case 'currency':
|
||||
return formatters.currency(Number(value));
|
||||
case 'date':
|
||||
return new Date(String(value)).toLocaleDateString('es-ES');
|
||||
case 'datetime':
|
||||
return new Date(String(value)).toLocaleString('es-ES');
|
||||
case 'percentage':
|
||||
return `${value}%`;
|
||||
case 'list':
|
||||
if (Array.isArray(value)) {
|
||||
return (
|
||||
<ul className="list-disc list-inside space-y-1">
|
||||
{value.map((item, index) => (
|
||||
<li key={index} className="text-sm">{String(item)}</li>
|
||||
))}
|
||||
</ul>
|
||||
);
|
||||
}
|
||||
return String(value);
|
||||
case 'status':
|
||||
return (
|
||||
<span
|
||||
className="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium"
|
||||
style={{
|
||||
backgroundColor: `${getStatusColor(String(value))}20`,
|
||||
color: getStatusColor(String(value))
|
||||
}}
|
||||
>
|
||||
{String(value)}
|
||||
</span>
|
||||
);
|
||||
case 'image':
|
||||
return (
|
||||
<img
|
||||
src={String(value)}
|
||||
alt=""
|
||||
className="w-16 h-16 rounded-lg object-cover bg-[var(--bg-secondary)]"
|
||||
/>
|
||||
);
|
||||
default:
|
||||
return String(value);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Render editable field based on type and mode
|
||||
*/
|
||||
const renderEditableField = (
|
||||
field: EditViewModalField,
|
||||
isEditMode: boolean,
|
||||
onChange?: (value: string | number) => void,
|
||||
validationError?: string
|
||||
): React.ReactNode => {
|
||||
if (!isEditMode || !field.editable) {
|
||||
return formatFieldValue(field.value, field.type);
|
||||
}
|
||||
|
||||
// Handle custom components
|
||||
if (field.type === 'component' && field.component) {
|
||||
const Component = field.component;
|
||||
return (
|
||||
<Component
|
||||
value={field.value}
|
||||
onChange={onChange}
|
||||
{...field.componentProps}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const value = field.type === 'number' || field.type === 'currency' ? Number(e.target.value) : e.target.value;
|
||||
onChange?.(value);
|
||||
};
|
||||
|
||||
const inputValue = field.type === 'currency' ? Number(String(field.value).replace(/[^0-9.-]+/g, '')) : field.value;
|
||||
|
||||
switch (field.type) {
|
||||
case 'email':
|
||||
return (
|
||||
<Input
|
||||
type="email"
|
||||
value={String(inputValue)}
|
||||
onChange={handleChange}
|
||||
placeholder={field.placeholder}
|
||||
required={field.required}
|
||||
className="w-full"
|
||||
/>
|
||||
);
|
||||
case 'tel':
|
||||
return (
|
||||
<Input
|
||||
type="tel"
|
||||
value={String(inputValue)}
|
||||
onChange={handleChange}
|
||||
placeholder={field.placeholder}
|
||||
required={field.required}
|
||||
className="w-full"
|
||||
/>
|
||||
);
|
||||
case 'number':
|
||||
case 'currency':
|
||||
return (
|
||||
<Input
|
||||
type="number"
|
||||
value={String(inputValue)}
|
||||
onChange={handleChange}
|
||||
placeholder={field.placeholder}
|
||||
required={field.required}
|
||||
step={field.type === 'currency' ? '0.01' : '1'}
|
||||
className="w-full"
|
||||
/>
|
||||
);
|
||||
case 'date':
|
||||
const dateValue = field.value ? new Date(String(field.value)).toISOString().split('T')[0] : '';
|
||||
return (
|
||||
<Input
|
||||
type="date"
|
||||
value={dateValue}
|
||||
onChange={handleChange}
|
||||
required={field.required}
|
||||
className="w-full"
|
||||
/>
|
||||
);
|
||||
case 'list':
|
||||
return (
|
||||
<div className="w-full">
|
||||
<textarea
|
||||
value={Array.isArray(field.value) ? field.value.join('\n') : String(field.value)}
|
||||
onChange={(e) => {
|
||||
const stringArray = e.target.value.split('\n');
|
||||
// For list type, we'll pass the joined string instead of array to maintain compatibility
|
||||
onChange?.(stringArray.join('\n'));
|
||||
}}
|
||||
placeholder={field.placeholder || 'Una opción por línea'}
|
||||
required={field.required}
|
||||
rows={4}
|
||||
className={`w-full px-3 py-2 border rounded-md focus:outline-none focus:ring-2 focus:border-transparent ${
|
||||
validationError
|
||||
? 'border-red-500 focus:ring-red-500'
|
||||
: 'border-[var(--border-secondary)] focus:ring-[var(--color-primary)]'
|
||||
}`}
|
||||
/>
|
||||
{validationError && (
|
||||
<p className="mt-1 text-sm text-red-600">{validationError}</p>
|
||||
)}
|
||||
{field.helpText && !validationError && (
|
||||
<p className="mt-1 text-sm text-[var(--text-tertiary)]">{field.helpText}</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
case 'textarea':
|
||||
return (
|
||||
<div className="w-full">
|
||||
<textarea
|
||||
value={String(field.value)}
|
||||
onChange={(e) => onChange?.(e.target.value)}
|
||||
placeholder={field.placeholder}
|
||||
required={field.required}
|
||||
rows={4}
|
||||
className={`w-full px-3 py-2 border rounded-md focus:outline-none focus:ring-2 focus:border-transparent ${
|
||||
validationError
|
||||
? 'border-red-500 focus:ring-red-500'
|
||||
: 'border-[var(--border-secondary)] focus:ring-[var(--color-primary)]'
|
||||
}`}
|
||||
/>
|
||||
{validationError && (
|
||||
<p className="mt-1 text-sm text-red-600">{validationError}</p>
|
||||
)}
|
||||
{field.helpText && !validationError && (
|
||||
<p className="mt-1 text-sm text-[var(--text-tertiary)]">{field.helpText}</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
case 'select':
|
||||
return (
|
||||
<div className="w-full">
|
||||
<Select
|
||||
value={String(field.value)}
|
||||
onChange={(value) => onChange?.(typeof value === 'string' ? value : String(value))}
|
||||
options={field.options || []}
|
||||
placeholder={field.placeholder}
|
||||
isRequired={field.required}
|
||||
variant="outline"
|
||||
size="md"
|
||||
/>
|
||||
{validationError && (
|
||||
<p className="mt-1 text-sm text-red-600">{validationError}</p>
|
||||
)}
|
||||
{field.helpText && !validationError && (
|
||||
<p className="mt-1 text-sm text-[var(--text-tertiary)]">{field.helpText}</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
default:
|
||||
return (
|
||||
<div className="w-full">
|
||||
<Input
|
||||
type="text"
|
||||
value={String(inputValue)}
|
||||
onChange={handleChange}
|
||||
placeholder={field.placeholder}
|
||||
required={field.required}
|
||||
className={`w-full ${
|
||||
validationError ? 'border-red-500 focus:ring-red-500' : ''
|
||||
}`}
|
||||
/>
|
||||
{validationError && (
|
||||
<p className="mt-1 text-sm text-red-600">{validationError}</p>
|
||||
)}
|
||||
{field.helpText && !validationError && (
|
||||
<p className="mt-1 text-sm text-[var(--text-tertiary)]">{field.helpText}</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* EditViewModal - Unified modal component for viewing/editing data details
|
||||
* Follows UX best practices for modal dialogs (2024)
|
||||
*
|
||||
* Features:
|
||||
* - Supports actions in header (tab-style navigation) or footer (default)
|
||||
* - Tab-style navigation in header improves discoverability for multi-view modals
|
||||
* - Maintains backward compatibility - existing modals continue working unchanged
|
||||
* - Responsive design with horizontal scroll for many tabs on mobile
|
||||
* - Active state indicated by disabled=true for navigation actions
|
||||
*/
|
||||
export const EditViewModal: React.FC<EditViewModalProps> = ({
|
||||
isOpen,
|
||||
onClose,
|
||||
mode,
|
||||
onModeChange,
|
||||
title,
|
||||
subtitle,
|
||||
statusIndicator,
|
||||
image,
|
||||
sections,
|
||||
actions = [],
|
||||
showDefaultActions = true,
|
||||
actionsPosition = 'footer',
|
||||
onEdit,
|
||||
onSave,
|
||||
onCancel,
|
||||
onFieldChange,
|
||||
size = 'lg',
|
||||
loading = false,
|
||||
// New enhanced features
|
||||
mobileOptimized = false,
|
||||
showStepIndicator = false,
|
||||
currentStep,
|
||||
totalSteps,
|
||||
validationErrors = {},
|
||||
onValidationError,
|
||||
}) => {
|
||||
const { t } = useTranslation(['common']);
|
||||
const StatusIcon = statusIndicator?.icon;
|
||||
|
||||
const handleEdit = () => {
|
||||
if (onModeChange) {
|
||||
onModeChange('edit');
|
||||
} else if (onEdit) {
|
||||
onEdit();
|
||||
}
|
||||
};
|
||||
|
||||
const handleSave = async () => {
|
||||
if (onSave) {
|
||||
await onSave();
|
||||
}
|
||||
if (onModeChange) {
|
||||
onModeChange('view');
|
||||
}
|
||||
};
|
||||
|
||||
const handleCancel = () => {
|
||||
if (onCancel) {
|
||||
onCancel();
|
||||
}
|
||||
if (onModeChange) {
|
||||
onModeChange('view');
|
||||
}
|
||||
};
|
||||
|
||||
// Default actions based on mode
|
||||
const defaultActions: EditViewModalAction[] = [];
|
||||
|
||||
if (showDefaultActions) {
|
||||
if (mode === 'view') {
|
||||
defaultActions.push({
|
||||
label: t('common:modals.actions.edit', 'Editar'),
|
||||
icon: Edit,
|
||||
variant: 'primary',
|
||||
onClick: handleEdit,
|
||||
disabled: loading,
|
||||
});
|
||||
} else {
|
||||
defaultActions.push(
|
||||
{
|
||||
label: t('common:modals.actions.cancel', 'Cancelar'),
|
||||
variant: 'outline',
|
||||
onClick: handleCancel,
|
||||
disabled: loading,
|
||||
},
|
||||
{
|
||||
label: t('common:modals.actions.save', 'Guardar'),
|
||||
variant: 'primary',
|
||||
onClick: handleSave,
|
||||
disabled: loading,
|
||||
loading: loading,
|
||||
}
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const allActions = [...actions, ...defaultActions];
|
||||
|
||||
// Step indicator component
|
||||
const renderStepIndicator = () => {
|
||||
if (!showStepIndicator || !currentStep || !totalSteps) return null;
|
||||
|
||||
return (
|
||||
<div className="px-6 py-3 border-b border-[var(--border-primary)] bg-[var(--bg-secondary)]/50">
|
||||
<div className="flex items-center gap-2 text-sm text-[var(--text-secondary)]">
|
||||
<span>{t('common:modals.step_indicator.step', 'Paso')} {currentStep} {t('common:modals.step_indicator.of', 'de')} {totalSteps}</span>
|
||||
<div className="flex-1 bg-[var(--bg-tertiary)] rounded-full h-2 mx-3">
|
||||
<div
|
||||
className="bg-[var(--color-primary)] h-2 rounded-full transition-all duration-300"
|
||||
style={{ width: `${(currentStep / totalSteps) * 100}%` }}
|
||||
/>
|
||||
</div>
|
||||
<span>{Math.round((currentStep / totalSteps) * 100)}%</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// Render top navigation actions (tab-like style)
|
||||
const renderTopActions = () => {
|
||||
if (actionsPosition !== 'header' || allActions.length === 0) return null;
|
||||
|
||||
return (
|
||||
<div className="border-b border-[var(--border-primary)] bg-[var(--bg-primary)]">
|
||||
<div className="px-6 py-1">
|
||||
<div className="flex gap-0 overflow-x-auto scrollbar-hide" style={{scrollbarWidth: 'none', msOverflowStyle: 'none'}}>
|
||||
{allActions.map((action, index) => (
|
||||
<button
|
||||
key={index}
|
||||
onClick={action.disabled || loading ? undefined : action.onClick}
|
||||
disabled={loading}
|
||||
className={`
|
||||
relative flex items-center gap-2 px-4 py-3 text-sm font-medium transition-all duration-200
|
||||
min-w-fit whitespace-nowrap border-b-2 -mb-px
|
||||
${loading
|
||||
? 'opacity-50 cursor-not-allowed text-[var(--text-tertiary)] border-transparent'
|
||||
: action.disabled
|
||||
? 'text-[var(--color-primary)] border-[var(--color-primary)] bg-[var(--color-primary)]/8 cursor-default'
|
||||
: action.variant === 'danger'
|
||||
? 'text-red-600 border-transparent hover:border-red-300 hover:bg-red-50'
|
||||
: 'text-[var(--text-secondary)] border-transparent hover:text-[var(--text-primary)] hover:border-[var(--border-primary)] hover:bg-[var(--bg-secondary)]'
|
||||
}
|
||||
`}
|
||||
>
|
||||
{action.loading ? (
|
||||
<div className="animate-spin rounded-full h-4 w-4 border-b-2 border-current"></div>
|
||||
) : (
|
||||
<>
|
||||
{action.icon && <action.icon className="w-4 h-4" />}
|
||||
<span>{action.label}</span>
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal
|
||||
isOpen={isOpen}
|
||||
onClose={onClose}
|
||||
size={size}
|
||||
closeOnOverlayClick={!loading}
|
||||
closeOnEscape={!loading}
|
||||
showCloseButton={false}
|
||||
>
|
||||
<ModalHeader
|
||||
title={
|
||||
<div className="flex items-center gap-3">
|
||||
{/* Status indicator */}
|
||||
{statusIndicator && (
|
||||
<div
|
||||
className="flex-shrink-0 p-2 rounded-lg"
|
||||
style={{ backgroundColor: `${statusIndicator.color}15` }}
|
||||
>
|
||||
{StatusIcon && (
|
||||
<StatusIcon
|
||||
className="w-5 h-5"
|
||||
style={{ color: statusIndicator.color }}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Title and status */}
|
||||
<div>
|
||||
<h2 className="text-xl font-semibold text-[var(--text-primary)]">
|
||||
{title}
|
||||
</h2>
|
||||
{statusIndicator && (
|
||||
<div
|
||||
className="text-sm font-medium mt-1"
|
||||
style={{ color: statusIndicator.color }}
|
||||
>
|
||||
{statusIndicator.text}
|
||||
{statusIndicator.isCritical && (
|
||||
<span className="ml-2 text-xs">⚠️</span>
|
||||
)}
|
||||
{statusIndicator.isHighlight && (
|
||||
<span className="ml-2 text-xs">⭐</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{subtitle && (
|
||||
<p className="text-sm text-[var(--text-secondary)] mt-1">
|
||||
{subtitle}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
showCloseButton={true}
|
||||
onClose={onClose}
|
||||
/>
|
||||
|
||||
{/* Step Indicator */}
|
||||
{renderStepIndicator()}
|
||||
|
||||
{/* Top Navigation Actions */}
|
||||
{renderTopActions()}
|
||||
|
||||
<ModalBody>
|
||||
{loading && (
|
||||
<div className="absolute inset-0 bg-[var(--bg-primary)]/80 backdrop-blur-sm flex items-center justify-center z-10">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="animate-spin rounded-full h-6 w-6 border-b-2 border-[var(--color-primary)]"></div>
|
||||
<span className="text-[var(--text-secondary)]">{t('common:modals.loading', 'Cargando...')}</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{image && (
|
||||
<div className="mb-6">
|
||||
<img
|
||||
src={image}
|
||||
alt={title}
|
||||
className="w-full h-48 object-cover rounded-lg bg-[var(--bg-secondary)]"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="space-y-6">
|
||||
{sections.map((section, sectionIndex) => {
|
||||
const [isCollapsed, setIsCollapsed] = React.useState(section.collapsed || false);
|
||||
const sectionColumns = section.columns || (mobileOptimized ? 1 : 2);
|
||||
|
||||
// Determine grid classes based on mobile optimization and section columns
|
||||
const getGridClasses = () => {
|
||||
if (mobileOptimized) {
|
||||
return sectionColumns === 1
|
||||
? 'grid grid-cols-1 gap-4'
|
||||
: sectionColumns === 2
|
||||
? 'grid grid-cols-1 sm:grid-cols-2 gap-4'
|
||||
: 'grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4';
|
||||
} else {
|
||||
return sectionColumns === 1
|
||||
? 'grid grid-cols-1 gap-4'
|
||||
: sectionColumns === 3
|
||||
? 'grid grid-cols-1 md:grid-cols-3 gap-4'
|
||||
: 'grid grid-cols-1 md:grid-cols-2 gap-4';
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div key={sectionIndex} className="space-y-4">
|
||||
<div
|
||||
className={`flex items-start gap-3 pb-3 border-b border-[var(--border-primary)] ${
|
||||
section.collapsible ? 'cursor-pointer' : ''
|
||||
}`}
|
||||
onClick={section.collapsible ? () => setIsCollapsed(!isCollapsed) : undefined}
|
||||
>
|
||||
{section.icon && (
|
||||
<section.icon className="w-5 h-5 text-[var(--text-secondary)] flex-shrink-0 mt-0.5" />
|
||||
)}
|
||||
<div className="flex-1">
|
||||
<h3 className="font-medium text-[var(--text-primary)] leading-6">
|
||||
{section.title}
|
||||
</h3>
|
||||
{section.description && (
|
||||
<p className="text-xs text-[var(--text-tertiary)] mt-1">
|
||||
{section.description}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
{section.collapsible && (
|
||||
<svg
|
||||
className={`w-5 h-5 text-[var(--text-secondary)] transition-transform ${
|
||||
isCollapsed ? 'rotate-0' : 'rotate-180'
|
||||
}`}
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
|
||||
</svg>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{(!section.collapsible || !isCollapsed) && (
|
||||
<div className={getGridClasses()}>
|
||||
{section.fields.map((field, fieldIndex) => {
|
||||
const fieldKey = `${sectionIndex}-${fieldIndex}`;
|
||||
const validationError = validationErrors[fieldKey];
|
||||
|
||||
return (
|
||||
<div
|
||||
key={fieldIndex}
|
||||
className={`space-y-2 ${
|
||||
field.span === 2 ?
|
||||
(mobileOptimized ? 'sm:col-span-2' : 'md:col-span-2') :
|
||||
field.span === 3 ?
|
||||
(mobileOptimized ? 'sm:col-span-2 lg:col-span-3' : 'md:col-span-3') :
|
||||
''
|
||||
}`}
|
||||
>
|
||||
<dt className="text-sm font-medium text-[var(--text-secondary)]">
|
||||
{field.label}
|
||||
{field.required && (
|
||||
<span className="text-red-500 ml-1">*</span>
|
||||
)}
|
||||
</dt>
|
||||
<dd className={`text-sm ${
|
||||
field.highlight
|
||||
? 'font-semibold text-[var(--text-primary)]'
|
||||
: 'text-[var(--text-primary)]'
|
||||
}`}>
|
||||
{renderEditableField(
|
||||
field,
|
||||
mode === 'edit',
|
||||
(value: string | number) => {
|
||||
// Run validation if provided
|
||||
if (field.validation) {
|
||||
const error = field.validation(value);
|
||||
if (error && onValidationError) {
|
||||
onValidationError({
|
||||
...validationErrors,
|
||||
[fieldKey]: error
|
||||
});
|
||||
} else if (!error && validationErrors[fieldKey]) {
|
||||
const newErrors = { ...validationErrors };
|
||||
delete newErrors[fieldKey];
|
||||
onValidationError?.(newErrors);
|
||||
}
|
||||
}
|
||||
onFieldChange?.(sectionIndex, fieldIndex, value);
|
||||
},
|
||||
validationError
|
||||
)}
|
||||
</dd>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</ModalBody>
|
||||
|
||||
{allActions.length > 0 && actionsPosition === 'footer' && (
|
||||
<ModalFooter justify="end">
|
||||
<div className="flex gap-3">
|
||||
{allActions.map((action, index) => (
|
||||
<Button
|
||||
key={index}
|
||||
variant={action.variant || 'outline'}
|
||||
onClick={action.onClick}
|
||||
disabled={action.disabled || loading}
|
||||
className="min-w-[80px]"
|
||||
>
|
||||
{action.loading ? (
|
||||
<div className="animate-spin rounded-full h-4 w-4 border-b-2 border-current"></div>
|
||||
) : (
|
||||
<>
|
||||
{action.icon && <action.icon className="w-4 h-4 mr-2" />}
|
||||
{action.label}
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
</ModalFooter>
|
||||
)}
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
|
||||
export default EditViewModal;
|
||||
7
frontend/src/components/ui/EditViewModal/index.ts
Normal file
7
frontend/src/components/ui/EditViewModal/index.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
export { EditViewModal, default } from './EditViewModal';
|
||||
export type {
|
||||
EditViewModalProps,
|
||||
EditViewModalField,
|
||||
EditViewModalSection,
|
||||
EditViewModalAction
|
||||
} from './EditViewModal';
|
||||
@@ -39,6 +39,8 @@ export interface SelectProps extends Omit<React.SelectHTMLAttributes<HTMLSelectE
|
||||
noOptionsMessage?: string;
|
||||
loadingMessage?: string;
|
||||
createLabel?: string;
|
||||
leftIcon?: React.ReactNode;
|
||||
rightIcon?: React.ReactNode;
|
||||
}
|
||||
|
||||
const Select = forwardRef<HTMLDivElement, SelectProps>(({
|
||||
@@ -70,12 +72,48 @@ const Select = forwardRef<HTMLDivElement, SelectProps>(({
|
||||
noOptionsMessage = 'No hay opciones disponibles',
|
||||
loadingMessage = 'Cargando...',
|
||||
createLabel = 'Crear',
|
||||
leftIcon,
|
||||
rightIcon,
|
||||
className,
|
||||
id,
|
||||
disabled,
|
||||
...props
|
||||
}, ref) => {
|
||||
const selectId = id || `select-${Math.random().toString(36).substr(2, 9)}`;
|
||||
|
||||
// Filter out non-DOM props to avoid React warnings
|
||||
const {
|
||||
// Remove Select-specific props that shouldn't be passed to DOM
|
||||
label: _label,
|
||||
error: _error,
|
||||
helperText: _helperText,
|
||||
placeholder: _placeholder,
|
||||
size: _size,
|
||||
variant: _variant,
|
||||
options: _options,
|
||||
value: _value,
|
||||
defaultValue: _defaultValue,
|
||||
multiple: _multiple,
|
||||
searchable: _searchable,
|
||||
clearable: _clearable,
|
||||
loading: _loading,
|
||||
isRequired: _isRequired,
|
||||
isInvalid: _isInvalid,
|
||||
maxHeight: _maxHeight,
|
||||
dropdownPosition: _dropdownPosition,
|
||||
createable: _createable,
|
||||
onCreate: _onCreate,
|
||||
onSearch: _onSearch,
|
||||
onChange: _onChange,
|
||||
renderOption: _renderOption,
|
||||
renderValue: _renderValue,
|
||||
noOptionsMessage: _noOptionsMessage,
|
||||
loadingMessage: _loadingMessage,
|
||||
createLabel: _createLabel,
|
||||
leftIcon: _leftIcon,
|
||||
rightIcon: _rightIcon,
|
||||
...domProps
|
||||
} = props as any;
|
||||
const [internalValue, setInternalValue] = useState<string | number | Array<string | number>>(
|
||||
value !== undefined ? value : defaultValue || (multiple ? [] : '')
|
||||
);
|
||||
@@ -515,7 +553,7 @@ const Select = forwardRef<HTMLDivElement, SelectProps>(({
|
||||
tabIndex={disabled ? -1 : 0}
|
||||
onFocus={onFocus}
|
||||
onBlur={onBlur}
|
||||
{...props}
|
||||
{...domProps}
|
||||
>
|
||||
<div
|
||||
className={clsx(triggerClasses, sizeClasses[size])}
|
||||
|
||||
@@ -18,9 +18,13 @@ export { StatusIndicator } from './StatusIndicator';
|
||||
export { ListItem } from './ListItem';
|
||||
export { StatsCard, StatsGrid } from './Stats';
|
||||
export { StatusCard, getStatusColor } from './StatusCard';
|
||||
export { StatusModal } from './StatusModal';
|
||||
export { EditViewModal } from './EditViewModal';
|
||||
export { AddModal } from './AddModal';
|
||||
export { DialogModal, showInfoDialog, showWarningDialog, showErrorDialog, showSuccessDialog, showConfirmDialog } from './DialogModal';
|
||||
export { TenantSwitcher } from './TenantSwitcher';
|
||||
export { LanguageSelector, CompactLanguageSelector } from './LanguageSelector';
|
||||
export { LoadingSpinner } from './LoadingSpinner';
|
||||
export { EmptyState } from './EmptyState';
|
||||
|
||||
// Export types
|
||||
export type { ButtonProps } from './Button';
|
||||
@@ -42,4 +46,8 @@ export type { StatusIndicatorProps } from './StatusIndicator';
|
||||
export type { ListItemProps } from './ListItem';
|
||||
export type { StatsCardProps, StatsCardVariant, StatsCardSize, StatsGridProps } from './Stats';
|
||||
export type { StatusCardProps, StatusIndicatorConfig } from './StatusCard';
|
||||
export type { StatusModalProps, StatusModalField, StatusModalSection, StatusModalAction } from './StatusModal';
|
||||
export type { EditViewModalProps, EditViewModalField, EditViewModalSection, EditViewModalAction } from './EditViewModal';
|
||||
export type { AddModalProps, AddModalField, AddModalSection } from './AddModal';
|
||||
export type { DialogModalProps, DialogModalAction } from './DialogModal';
|
||||
export type { LoadingSpinnerProps } from './LoadingSpinner';
|
||||
export type { EmptyStateProps } from './EmptyState';
|
||||
@@ -1,542 +0,0 @@
|
||||
/**
|
||||
* Example usage of the restructured recipes API
|
||||
* Demonstrates tenant-dependent routing and React Query hooks
|
||||
*/
|
||||
|
||||
import React, { useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import {
|
||||
useRecipes,
|
||||
useRecipe,
|
||||
useCreateRecipe,
|
||||
useUpdateRecipe,
|
||||
useDeleteRecipe,
|
||||
useDuplicateRecipe,
|
||||
useActivateRecipe,
|
||||
useRecipeStatistics,
|
||||
useRecipeCategories,
|
||||
useRecipeFeasibility,
|
||||
type RecipeResponse,
|
||||
type RecipeCreate,
|
||||
type RecipeSearchParams,
|
||||
MeasurementUnit,
|
||||
} from '../api';
|
||||
import { useCurrentTenant } from '../stores/tenant.store';
|
||||
|
||||
/**
|
||||
* Example: Recipe List Component
|
||||
* Shows how to use the tenant-dependent useRecipes hook
|
||||
*/
|
||||
export const RecipesList: React.FC = () => {
|
||||
const { t } = useTranslation('recipes');
|
||||
const currentTenant = useCurrentTenant();
|
||||
const [filters, setFilters] = useState<RecipeSearchParams>({
|
||||
limit: 20,
|
||||
offset: 0,
|
||||
});
|
||||
|
||||
// Use tenant-dependent recipes hook
|
||||
const {
|
||||
data: recipes,
|
||||
isLoading,
|
||||
error,
|
||||
refetch,
|
||||
} = useRecipes(currentTenant?.id || '', filters, {
|
||||
enabled: !!currentTenant?.id,
|
||||
});
|
||||
|
||||
if (!currentTenant) {
|
||||
return <div>{t('messages.no_tenant_selected')}</div>;
|
||||
}
|
||||
|
||||
if (isLoading) {
|
||||
return <div>{t('messages.loading_recipes')}</div>;
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return <div>Error: {error.message}</div>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="recipes-list">
|
||||
<h2>{t('title')}</h2>
|
||||
|
||||
{/* Search and filters */}
|
||||
<div className="filters">
|
||||
<input
|
||||
type="text"
|
||||
placeholder={t('filters.search_placeholder')}
|
||||
value={filters.search_term || ''}
|
||||
onChange={(e) => setFilters({ ...filters, search_term: e.target.value })}
|
||||
/>
|
||||
<select
|
||||
value={filters.status || ''}
|
||||
onChange={(e) => setFilters({ ...filters, status: e.target.value })}
|
||||
>
|
||||
<option value="">{t('filters.all')}</option>
|
||||
<option value="active">{t('status.active')}</option>
|
||||
<option value="draft">{t('status.draft')}</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* Recipe cards */}
|
||||
<div className="recipe-grid">
|
||||
{recipes?.map((recipe) => (
|
||||
<RecipeCard key={recipe.id} recipe={recipe} />
|
||||
))}
|
||||
</div>
|
||||
|
||||
{(!recipes || recipes.length === 0) && (
|
||||
<div className="no-results">{t('messages.no_recipes_found')}</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* Example: Individual Recipe Card
|
||||
*/
|
||||
interface RecipeCardProps {
|
||||
recipe: RecipeResponse;
|
||||
}
|
||||
|
||||
const RecipeCard: React.FC<RecipeCardProps> = ({ recipe }) => {
|
||||
const { t } = useTranslation('recipes');
|
||||
const currentTenant = useCurrentTenant();
|
||||
|
||||
// Mutation hooks for recipe actions
|
||||
const duplicateRecipe = useDuplicateRecipe(currentTenant?.id || '');
|
||||
const activateRecipe = useActivateRecipe(currentTenant?.id || '');
|
||||
const deleteRecipe = useDeleteRecipe(currentTenant?.id || '');
|
||||
|
||||
const handleDuplicate = () => {
|
||||
if (!currentTenant) return;
|
||||
|
||||
duplicateRecipe.mutate({
|
||||
id: recipe.id,
|
||||
data: { new_name: `${recipe.name} (Copy)` }
|
||||
}, {
|
||||
onSuccess: () => {
|
||||
alert(t('messages.recipe_duplicated'));
|
||||
},
|
||||
onError: (error) => {
|
||||
alert(error.message);
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
const handleActivate = () => {
|
||||
if (!currentTenant) return;
|
||||
|
||||
activateRecipe.mutate(recipe.id, {
|
||||
onSuccess: () => {
|
||||
alert(t('messages.recipe_activated'));
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
const handleDelete = () => {
|
||||
if (!currentTenant) return;
|
||||
|
||||
if (confirm(t('messages.confirm_delete'))) {
|
||||
deleteRecipe.mutate(recipe.id, {
|
||||
onSuccess: () => {
|
||||
alert(t('messages.recipe_deleted'));
|
||||
}
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="recipe-card">
|
||||
<h3>{recipe.name}</h3>
|
||||
<p>{recipe.description}</p>
|
||||
|
||||
<div className="recipe-meta">
|
||||
<span className={`status status-${recipe.status}`}>
|
||||
{t(`status.${recipe.status}`)}
|
||||
</span>
|
||||
<span className="category">{recipe.category}</span>
|
||||
<span className="difficulty">
|
||||
{t(`difficulty.${recipe.difficulty_level}`)}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="recipe-actions">
|
||||
<button onClick={handleDuplicate} disabled={duplicateRecipe.isPending}>
|
||||
{t('actions.duplicate_recipe')}
|
||||
</button>
|
||||
|
||||
{recipe.status === 'draft' && (
|
||||
<button onClick={handleActivate} disabled={activateRecipe.isPending}>
|
||||
{t('actions.activate_recipe')}
|
||||
</button>
|
||||
)}
|
||||
|
||||
<button onClick={handleDelete} disabled={deleteRecipe.isPending}>
|
||||
{t('actions.delete_recipe')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* Example: Recipe Detail View
|
||||
*/
|
||||
interface RecipeDetailProps {
|
||||
recipeId: string;
|
||||
}
|
||||
|
||||
export const RecipeDetail: React.FC<RecipeDetailProps> = ({ recipeId }) => {
|
||||
const { t } = useTranslation('recipes');
|
||||
const currentTenant = useCurrentTenant();
|
||||
|
||||
// Get individual recipe with tenant context
|
||||
const {
|
||||
data: recipe,
|
||||
isLoading,
|
||||
error,
|
||||
} = useRecipe(currentTenant?.id || '', recipeId, {
|
||||
enabled: !!(currentTenant?.id && recipeId),
|
||||
});
|
||||
|
||||
// Check feasibility
|
||||
const {
|
||||
data: feasibility,
|
||||
} = useRecipeFeasibility(
|
||||
currentTenant?.id || '',
|
||||
recipeId,
|
||||
1.0, // batch multiplier
|
||||
{
|
||||
enabled: !!(currentTenant?.id && recipeId),
|
||||
}
|
||||
);
|
||||
|
||||
if (!currentTenant) {
|
||||
return <div>{t('messages.no_tenant_selected')}</div>;
|
||||
}
|
||||
|
||||
if (isLoading) {
|
||||
return <div>{t('messages.loading_recipe')}</div>;
|
||||
}
|
||||
|
||||
if (error || !recipe) {
|
||||
return <div>Recipe not found</div>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="recipe-detail">
|
||||
<header className="recipe-header">
|
||||
<h1>{recipe.name}</h1>
|
||||
<p>{recipe.description}</p>
|
||||
|
||||
<div className="recipe-info">
|
||||
<span>Yield: {recipe.yield_quantity} {recipe.yield_unit}</span>
|
||||
<span>Prep: {recipe.prep_time_minutes}min</span>
|
||||
<span>Cook: {recipe.cook_time_minutes}min</span>
|
||||
<span>Total: {recipe.total_time_minutes}min</span>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{/* Feasibility check */}
|
||||
{feasibility && (
|
||||
<div className={`feasibility ${feasibility.feasible ? 'feasible' : 'not-feasible'}`}>
|
||||
<h3>{t('feasibility.title')}</h3>
|
||||
<p>
|
||||
{feasibility.feasible
|
||||
? t('feasibility.feasible')
|
||||
: t('feasibility.not_feasible')
|
||||
}
|
||||
</p>
|
||||
|
||||
{feasibility.missing_ingredients.length > 0 && (
|
||||
<div>
|
||||
<h4>{t('feasibility.missing_ingredients')}</h4>
|
||||
<ul>
|
||||
{feasibility.missing_ingredients.map((ingredient, index) => (
|
||||
<li key={index}>{JSON.stringify(ingredient)}</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Ingredients */}
|
||||
<section className="ingredients">
|
||||
<h2>{t('ingredients.title')}</h2>
|
||||
<ul>
|
||||
{recipe.ingredients?.map((ingredient) => (
|
||||
<li key={ingredient.id}>
|
||||
{ingredient.quantity} {ingredient.unit} - {ingredient.ingredient_id}
|
||||
{ingredient.preparation_method && (
|
||||
<span className="prep-method"> ({ingredient.preparation_method})</span>
|
||||
)}
|
||||
{ingredient.is_optional && (
|
||||
<span className="optional"> ({t('ingredients.is_optional')})</span>
|
||||
)}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
{/* Instructions */}
|
||||
{recipe.instructions && (
|
||||
<section className="instructions">
|
||||
<h2>{t('fields.instructions')}</h2>
|
||||
<div>{JSON.stringify(recipe.instructions, null, 2)}</div>
|
||||
</section>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* Example: Recipe Creation Form
|
||||
*/
|
||||
export const CreateRecipeForm: React.FC = () => {
|
||||
const { t } = useTranslation('recipes');
|
||||
const currentTenant = useCurrentTenant();
|
||||
|
||||
const [formData, setFormData] = useState<RecipeCreate>({
|
||||
name: '',
|
||||
finished_product_id: '',
|
||||
yield_quantity: 1,
|
||||
yield_unit: MeasurementUnit.UNITS,
|
||||
difficulty_level: 1,
|
||||
batch_size_multiplier: 1.0,
|
||||
is_seasonal: false,
|
||||
is_signature_item: false,
|
||||
ingredients: [],
|
||||
});
|
||||
|
||||
const createRecipe = useCreateRecipe(currentTenant?.id || '', {
|
||||
onSuccess: (data) => {
|
||||
alert(t('messages.recipe_created'));
|
||||
// Reset form or redirect
|
||||
setFormData({
|
||||
name: '',
|
||||
finished_product_id: '',
|
||||
yield_quantity: 1,
|
||||
yield_unit: MeasurementUnit.UNITS,
|
||||
difficulty_level: 1,
|
||||
batch_size_multiplier: 1.0,
|
||||
is_seasonal: false,
|
||||
is_signature_item: false,
|
||||
ingredients: [],
|
||||
});
|
||||
},
|
||||
onError: (error) => {
|
||||
alert(error.message);
|
||||
}
|
||||
});
|
||||
|
||||
const handleSubmit = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
|
||||
if (!currentTenant) {
|
||||
alert('No tenant selected');
|
||||
return;
|
||||
}
|
||||
|
||||
if (!formData.name.trim()) {
|
||||
alert(t('messages.recipe_name_required'));
|
||||
return;
|
||||
}
|
||||
|
||||
if (formData.ingredients.length === 0) {
|
||||
alert(t('messages.at_least_one_ingredient'));
|
||||
return;
|
||||
}
|
||||
|
||||
createRecipe.mutate(formData);
|
||||
};
|
||||
|
||||
if (!currentTenant) {
|
||||
return <div>{t('messages.no_tenant_selected')}</div>;
|
||||
}
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit} className="create-recipe-form">
|
||||
<h2>{t('actions.create_recipe')}</h2>
|
||||
|
||||
<div className="form-group">
|
||||
<label htmlFor="name">{t('fields.name')}</label>
|
||||
<input
|
||||
id="name"
|
||||
type="text"
|
||||
value={formData.name}
|
||||
onChange={(e) => setFormData({ ...formData, name: e.target.value })}
|
||||
placeholder={t('placeholders.recipe_name')}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="form-group">
|
||||
<label htmlFor="description">{t('fields.description')}</label>
|
||||
<textarea
|
||||
id="description"
|
||||
value={formData.description || ''}
|
||||
onChange={(e) => setFormData({ ...formData, description: e.target.value })}
|
||||
placeholder={t('placeholders.description')}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="form-row">
|
||||
<div className="form-group">
|
||||
<label htmlFor="yield_quantity">{t('fields.yield_quantity')}</label>
|
||||
<input
|
||||
id="yield_quantity"
|
||||
type="number"
|
||||
min="0.1"
|
||||
step="0.1"
|
||||
value={formData.yield_quantity}
|
||||
onChange={(e) => setFormData({ ...formData, yield_quantity: parseFloat(e.target.value) })}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="form-group">
|
||||
<label htmlFor="yield_unit">{t('fields.yield_unit')}</label>
|
||||
<select
|
||||
id="yield_unit"
|
||||
value={formData.yield_unit}
|
||||
onChange={(e) => setFormData({ ...formData, yield_unit: e.target.value as MeasurementUnit })}
|
||||
required
|
||||
>
|
||||
{Object.values(MeasurementUnit).map((unit) => (
|
||||
<option key={unit} value={unit}>
|
||||
{t(`units.${unit}`)}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="form-group">
|
||||
<label htmlFor="difficulty_level">{t('fields.difficulty_level')}</label>
|
||||
<select
|
||||
id="difficulty_level"
|
||||
value={formData.difficulty_level}
|
||||
onChange={(e) => setFormData({ ...formData, difficulty_level: parseInt(e.target.value) })}
|
||||
>
|
||||
{[1, 2, 3, 4, 5].map((level) => (
|
||||
<option key={level} value={level}>
|
||||
{t(`difficulty.${level}`)}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div className="form-checkboxes">
|
||||
<label>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={formData.is_signature_item}
|
||||
onChange={(e) => setFormData({ ...formData, is_signature_item: e.target.checked })}
|
||||
/>
|
||||
{t('fields.is_signature')}
|
||||
</label>
|
||||
|
||||
<label>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={formData.is_seasonal}
|
||||
onChange={(e) => setFormData({ ...formData, is_seasonal: e.target.checked })}
|
||||
/>
|
||||
{t('fields.is_seasonal')}
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{/* Ingredients section would go here */}
|
||||
<div className="ingredients-section">
|
||||
<h3>{t('ingredients.title')}</h3>
|
||||
<p>{t('messages.no_ingredients')}</p>
|
||||
{/* Add ingredient form components here */}
|
||||
</div>
|
||||
|
||||
<div className="form-actions">
|
||||
<button
|
||||
type="submit"
|
||||
disabled={createRecipe.isPending}
|
||||
className="btn-primary"
|
||||
>
|
||||
{createRecipe.isPending ? 'Creating...' : t('actions.create_recipe')}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* Example: Recipe Statistics Dashboard
|
||||
*/
|
||||
export const RecipeStatistics: React.FC = () => {
|
||||
const { t } = useTranslation('recipes');
|
||||
const currentTenant = useCurrentTenant();
|
||||
|
||||
const { data: stats, isLoading } = useRecipeStatistics(currentTenant?.id || '', {
|
||||
enabled: !!currentTenant?.id,
|
||||
});
|
||||
|
||||
const { data: categories } = useRecipeCategories(currentTenant?.id || '', {
|
||||
enabled: !!currentTenant?.id,
|
||||
});
|
||||
|
||||
if (!currentTenant) {
|
||||
return <div>{t('messages.no_tenant_selected')}</div>;
|
||||
}
|
||||
|
||||
if (isLoading) {
|
||||
return <div>Loading statistics...</div>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="recipe-statistics">
|
||||
<h2>{t('statistics.title')}</h2>
|
||||
|
||||
{stats && (
|
||||
<div className="stats-grid">
|
||||
<div className="stat-card">
|
||||
<h3>{t('statistics.total_recipes')}</h3>
|
||||
<span className="stat-number">{stats.total_recipes}</span>
|
||||
</div>
|
||||
|
||||
<div className="stat-card">
|
||||
<h3>{t('statistics.active_recipes')}</h3>
|
||||
<span className="stat-number">{stats.active_recipes}</span>
|
||||
</div>
|
||||
|
||||
<div className="stat-card">
|
||||
<h3>{t('statistics.signature_recipes')}</h3>
|
||||
<span className="stat-number">{stats.signature_recipes}</span>
|
||||
</div>
|
||||
|
||||
<div className="stat-card">
|
||||
<h3>{t('statistics.seasonal_recipes')}</h3>
|
||||
<span className="stat-number">{stats.seasonal_recipes}</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{categories && (
|
||||
<div className="categories-list">
|
||||
<h3>Categories</h3>
|
||||
<ul>
|
||||
{categories.categories.map((category) => (
|
||||
<li key={category}>{category}</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default {
|
||||
RecipesList,
|
||||
RecipeDetail,
|
||||
CreateRecipeForm,
|
||||
RecipeStatistics,
|
||||
};
|
||||
@@ -290,5 +290,49 @@
|
||||
"breadcrumbs": {
|
||||
"home": "Home",
|
||||
"truncation": "..."
|
||||
},
|
||||
"modals": {
|
||||
"loading": "Loading...",
|
||||
"saving": "Saving...",
|
||||
"processing": "Processing...",
|
||||
"close_modal": "Close modal",
|
||||
"actions": {
|
||||
"save": "Save",
|
||||
"cancel": "Cancel",
|
||||
"confirm": "Confirm",
|
||||
"ok": "OK",
|
||||
"yes": "Yes",
|
||||
"no": "No",
|
||||
"edit": "Edit",
|
||||
"create": "Create"
|
||||
},
|
||||
"types": {
|
||||
"info": "Information",
|
||||
"warning": "Warning",
|
||||
"error": "Error",
|
||||
"success": "Success",
|
||||
"confirm": "Confirmation"
|
||||
},
|
||||
"status_indicators": {
|
||||
"new": "New",
|
||||
"editing": "Editing",
|
||||
"viewing": "Viewing",
|
||||
"creating": "Creating"
|
||||
},
|
||||
"step_indicator": {
|
||||
"step": "Step",
|
||||
"of": "of",
|
||||
"progress": "Progress"
|
||||
},
|
||||
"validation": {
|
||||
"required_field": "This field is required",
|
||||
"invalid_email": "Please enter a valid email",
|
||||
"invalid_number": "Please enter a valid number",
|
||||
"invalid_date": "Please enter a valid date",
|
||||
"min_length": "Must be at least {count} characters",
|
||||
"max_length": "Cannot be more than {count} characters",
|
||||
"positive_number": "Must be a positive number",
|
||||
"negative_not_allowed": "Negative numbers are not allowed"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1 +1,49 @@
|
||||
{}
|
||||
{
|
||||
"product_type": {
|
||||
"raw_material": "Raw Material",
|
||||
"intermediate": "Intermediate Product",
|
||||
"finished_product": "Finished Product",
|
||||
"packaging": "Packaging"
|
||||
},
|
||||
"production_stage": {
|
||||
"raw": "Raw",
|
||||
"in_process": "In Process",
|
||||
"finished": "Finished",
|
||||
"packaged": "Packaged"
|
||||
},
|
||||
"unit_of_measure": {
|
||||
"kg": "Kilograms",
|
||||
"g": "Grams",
|
||||
"l": "Liters",
|
||||
"ml": "Milliliters",
|
||||
"pieces": "Pieces",
|
||||
"units": "Units",
|
||||
"portions": "Portions"
|
||||
},
|
||||
"ingredient_category": {
|
||||
"flour": "Flour",
|
||||
"dairy": "Dairy",
|
||||
"eggs": "Eggs",
|
||||
"fats": "Fats",
|
||||
"sugar": "Sugar",
|
||||
"yeast": "Yeast",
|
||||
"spices": "Spices",
|
||||
"other": "Other"
|
||||
},
|
||||
"product_category": {
|
||||
"bread": "Bread",
|
||||
"pastry": "Pastry",
|
||||
"cake": "Cake",
|
||||
"cookie": "Cookie",
|
||||
"salted": "Salted",
|
||||
"other": "Other"
|
||||
},
|
||||
"stock_movement_type": {
|
||||
"purchase": "Purchase",
|
||||
"production": "Production",
|
||||
"sale": "Sale",
|
||||
"adjustment": "Adjustment",
|
||||
"waste": "Waste",
|
||||
"transfer": "Transfer"
|
||||
}
|
||||
}
|
||||
@@ -1 +1,106 @@
|
||||
{}
|
||||
{
|
||||
"customer_types": {
|
||||
"individual": "Individual",
|
||||
"business": "Business",
|
||||
"central_bakery": "Central Bakery"
|
||||
},
|
||||
"delivery_methods": {
|
||||
"pickup": "Pickup",
|
||||
"delivery": "Home Delivery"
|
||||
},
|
||||
"payment_terms": {
|
||||
"immediate": "Immediate",
|
||||
"net_30": "Net 30 Days",
|
||||
"net_60": "Net 60 Days"
|
||||
},
|
||||
"payment_methods": {
|
||||
"cash": "Cash",
|
||||
"card": "Card",
|
||||
"bank_transfer": "Bank Transfer",
|
||||
"account": "Account"
|
||||
},
|
||||
"payment_status": {
|
||||
"pending": "Pending",
|
||||
"partial": "Partial",
|
||||
"paid": "Paid",
|
||||
"failed": "Failed",
|
||||
"refunded": "Refunded"
|
||||
},
|
||||
"customer_segments": {
|
||||
"regular": "Regular",
|
||||
"vip": "VIP",
|
||||
"wholesale": "Wholesale"
|
||||
},
|
||||
"priority_levels": {
|
||||
"low": "Low",
|
||||
"normal": "Normal",
|
||||
"high": "High"
|
||||
},
|
||||
"order_types": {
|
||||
"standard": "Standard",
|
||||
"rush": "Rush",
|
||||
"recurring": "Recurring",
|
||||
"special": "Special"
|
||||
},
|
||||
"order_status": {
|
||||
"pending": "Pending",
|
||||
"confirmed": "Confirmed",
|
||||
"in_production": "In Production",
|
||||
"ready": "Ready",
|
||||
"out_for_delivery": "Out for Delivery",
|
||||
"delivered": "Delivered",
|
||||
"cancelled": "Cancelled",
|
||||
"failed": "Failed"
|
||||
},
|
||||
"order_sources": {
|
||||
"manual": "Manual",
|
||||
"online": "Online",
|
||||
"phone": "Phone",
|
||||
"app": "App",
|
||||
"api": "API"
|
||||
},
|
||||
"sales_channels": {
|
||||
"direct": "Direct",
|
||||
"wholesale": "Wholesale",
|
||||
"retail": "Retail"
|
||||
},
|
||||
"labels": {
|
||||
"name": "Name",
|
||||
"business_name": "Business Name",
|
||||
"customer_code": "Customer Code",
|
||||
"email": "Email",
|
||||
"phone": "Phone",
|
||||
"address": "Address",
|
||||
"city": "City",
|
||||
"state": "State/Province",
|
||||
"postal_code": "Postal Code",
|
||||
"country": "Country",
|
||||
"customer_type": "Customer Type",
|
||||
"delivery_method": "Delivery Method",
|
||||
"payment_terms": "Payment Terms",
|
||||
"payment_method": "Payment Method",
|
||||
"payment_status": "Payment Status",
|
||||
"customer_segment": "Customer Segment",
|
||||
"priority_level": "Priority Level",
|
||||
"order_type": "Order Type",
|
||||
"order_status": "Order Status",
|
||||
"order_source": "Order Source",
|
||||
"sales_channel": "Sales Channel",
|
||||
"order_number": "Order Number",
|
||||
"order_date": "Order Date",
|
||||
"delivery_date": "Delivery Date",
|
||||
"total_amount": "Total Amount",
|
||||
"subtotal": "Subtotal",
|
||||
"discount": "Discount",
|
||||
"tax": "Tax",
|
||||
"shipping": "Shipping",
|
||||
"special_instructions": "Special Instructions"
|
||||
},
|
||||
"descriptions": {
|
||||
"customer_type": "Customer type to determine pricing and commercial terms",
|
||||
"delivery_method": "Preferred method for order delivery",
|
||||
"payment_terms": "Agreed payment terms and conditions",
|
||||
"customer_segment": "Customer segmentation for personalized offers",
|
||||
"priority_level": "Priority level for order processing"
|
||||
}
|
||||
}
|
||||
@@ -1 +1,84 @@
|
||||
{}
|
||||
{
|
||||
"types": {
|
||||
"ingredients": "Raw Materials",
|
||||
"packaging": "Packaging",
|
||||
"equipment": "Equipment",
|
||||
"services": "Services",
|
||||
"utilities": "Utilities",
|
||||
"multi": "Multiple Categories"
|
||||
},
|
||||
"status": {
|
||||
"active": "Active",
|
||||
"inactive": "Inactive",
|
||||
"pending_approval": "Pending Approval",
|
||||
"suspended": "Suspended",
|
||||
"blacklisted": "Blacklisted"
|
||||
},
|
||||
"payment_terms": {
|
||||
"cod": "Cash on Delivery",
|
||||
"net_15": "Net 15 Days",
|
||||
"net_30": "Net 30 Days",
|
||||
"net_45": "Net 45 Days",
|
||||
"net_60": "Net 60 Days",
|
||||
"prepaid": "Prepaid",
|
||||
"credit_terms": "Credit Terms"
|
||||
},
|
||||
"purchase_order_status": {
|
||||
"draft": "Draft",
|
||||
"pending_approval": "Pending Approval",
|
||||
"approved": "Approved",
|
||||
"sent_to_supplier": "Sent to Supplier",
|
||||
"confirmed": "Confirmed",
|
||||
"partially_received": "Partially Received",
|
||||
"completed": "Completed",
|
||||
"cancelled": "Cancelled",
|
||||
"disputed": "Disputed"
|
||||
},
|
||||
"delivery_status": {
|
||||
"scheduled": "Scheduled",
|
||||
"in_transit": "In Transit",
|
||||
"out_for_delivery": "Out for Delivery",
|
||||
"delivered": "Delivered",
|
||||
"partially_delivered": "Partially Delivered",
|
||||
"failed_delivery": "Failed Delivery",
|
||||
"returned": "Returned"
|
||||
},
|
||||
"quality_rating": {
|
||||
"5": "Excellent",
|
||||
"4": "Good",
|
||||
"3": "Average",
|
||||
"2": "Poor",
|
||||
"1": "Very Poor"
|
||||
},
|
||||
"delivery_rating": {
|
||||
"5": "Excellent",
|
||||
"4": "Good",
|
||||
"3": "Average",
|
||||
"2": "Poor",
|
||||
"1": "Very Poor"
|
||||
},
|
||||
"invoice_status": {
|
||||
"pending": "Pending",
|
||||
"approved": "Approved",
|
||||
"paid": "Paid",
|
||||
"overdue": "Overdue",
|
||||
"disputed": "Disputed",
|
||||
"cancelled": "Cancelled"
|
||||
},
|
||||
"labels": {
|
||||
"supplier_type": "Supplier Type",
|
||||
"supplier_status": "Supplier Status",
|
||||
"payment_terms": "Payment Terms",
|
||||
"purchase_order_status": "Purchase Order Status",
|
||||
"delivery_status": "Delivery Status",
|
||||
"quality_rating": "Quality Rating",
|
||||
"delivery_rating": "Delivery Rating",
|
||||
"invoice_status": "Invoice Status"
|
||||
},
|
||||
"descriptions": {
|
||||
"supplier_type": "Select the type of products or services this supplier offers",
|
||||
"payment_terms": "Payment terms agreed with the supplier",
|
||||
"quality_rating": "1 to 5 star rating based on product quality",
|
||||
"delivery_rating": "1 to 5 star rating based on delivery punctuality and condition"
|
||||
}
|
||||
}
|
||||
@@ -290,5 +290,49 @@
|
||||
"breadcrumbs": {
|
||||
"home": "Inicio",
|
||||
"truncation": "..."
|
||||
},
|
||||
"modals": {
|
||||
"loading": "Cargando...",
|
||||
"saving": "Guardando...",
|
||||
"processing": "Procesando...",
|
||||
"close_modal": "Cerrar modal",
|
||||
"actions": {
|
||||
"save": "Guardar",
|
||||
"cancel": "Cancelar",
|
||||
"confirm": "Confirmar",
|
||||
"ok": "OK",
|
||||
"yes": "Sí",
|
||||
"no": "No",
|
||||
"edit": "Editar",
|
||||
"create": "Crear"
|
||||
},
|
||||
"types": {
|
||||
"info": "Información",
|
||||
"warning": "Advertencia",
|
||||
"error": "Error",
|
||||
"success": "Éxito",
|
||||
"confirm": "Confirmación"
|
||||
},
|
||||
"status_indicators": {
|
||||
"new": "Nuevo",
|
||||
"editing": "Editando",
|
||||
"viewing": "Viendo",
|
||||
"creating": "Creando"
|
||||
},
|
||||
"step_indicator": {
|
||||
"step": "Paso",
|
||||
"of": "de",
|
||||
"progress": "Progreso"
|
||||
},
|
||||
"validation": {
|
||||
"required_field": "Este campo es requerido",
|
||||
"invalid_email": "Por favor ingresa un email válido",
|
||||
"invalid_number": "Por favor ingresa un número válido",
|
||||
"invalid_date": "Por favor ingresa una fecha válida",
|
||||
"min_length": "Debe tener al menos {count} caracteres",
|
||||
"max_length": "No puede tener más de {count} caracteres",
|
||||
"positive_number": "Debe ser un número positivo",
|
||||
"negative_not_allowed": "Los números negativos no están permitidos"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -290,5 +290,49 @@
|
||||
"breadcrumbs": {
|
||||
"home": "Hasiera",
|
||||
"truncation": "..."
|
||||
},
|
||||
"modals": {
|
||||
"loading": "Kargatzen...",
|
||||
"saving": "Gordetzen...",
|
||||
"processing": "Prozesatzen...",
|
||||
"close_modal": "Modala itxi",
|
||||
"actions": {
|
||||
"save": "Gorde",
|
||||
"cancel": "Utzi",
|
||||
"confirm": "Berretsi",
|
||||
"ok": "Ados",
|
||||
"yes": "Bai",
|
||||
"no": "Ez",
|
||||
"edit": "Editatu",
|
||||
"create": "Sortu"
|
||||
},
|
||||
"types": {
|
||||
"info": "Informazioa",
|
||||
"warning": "Abisua",
|
||||
"error": "Errorea",
|
||||
"success": "Arrakasta",
|
||||
"confirm": "Berrespena"
|
||||
},
|
||||
"status_indicators": {
|
||||
"new": "Berria",
|
||||
"editing": "Editatzen",
|
||||
"viewing": "Ikusten",
|
||||
"creating": "Sortzen"
|
||||
},
|
||||
"step_indicator": {
|
||||
"step": "Urratsa",
|
||||
"of": "hortik",
|
||||
"progress": "Aurrerapena"
|
||||
},
|
||||
"validation": {
|
||||
"required_field": "Eremua beharrezkoa da",
|
||||
"invalid_email": "Sartu baliozko email bat",
|
||||
"invalid_number": "Sartu baliozko zenbaki bat",
|
||||
"invalid_date": "Sartu baliozko data bat",
|
||||
"min_length": "Gutxienez {count} karaktere izan behar ditu",
|
||||
"max_length": "Ezin ditu {count} karaktere baino gehiago izan",
|
||||
"positive_number": "Zenbaki positiboa izan behar da",
|
||||
"negative_not_allowed": "Zenbaki negatiboak ez dira onartzen"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -34,7 +34,7 @@ import {
|
||||
EquipmentEfficiencyWidget,
|
||||
AIInsightsWidget,
|
||||
PredictiveMaintenanceWidget
|
||||
} from '../../../components/analytics/production/widgets';
|
||||
} from '../../../components/domain/production/analytics/widgets';
|
||||
|
||||
const ProductionAnalyticsPage: React.FC = () => {
|
||||
const { t } = useTranslation('production');
|
||||
|
||||
@@ -2,7 +2,7 @@ import React, { useState, useMemo } from 'react';
|
||||
import { Calendar, TrendingUp, AlertTriangle, BarChart3, Settings, Loader, Zap, Brain, Target, Activity } from 'lucide-react';
|
||||
import { Button, Card, Badge, StatsGrid, StatusCard, getStatusColor } from '../../../../components/ui';
|
||||
import { PageHeader } from '../../../../components/layout';
|
||||
import { LoadingSpinner } from '../../../../components/shared';
|
||||
import { LoadingSpinner } from '../../../../components/ui';
|
||||
import { DemandChart } from '../../../../components/domain/forecasting';
|
||||
import { useTenantForecasts, useCreateSingleForecast } from '../../../../api/hooks/forecasting';
|
||||
import { useIngredients } from '../../../../api/hooks/inventory';
|
||||
|
||||
@@ -2,7 +2,7 @@ import React, { useState, useMemo } from 'react';
|
||||
import { Calendar, TrendingUp, Euro, ShoppingCart, Download, Filter, Eye, Users, Package, CreditCard, BarChart3, AlertTriangle, Clock } from 'lucide-react';
|
||||
import { Button, Card, Badge, StatsGrid, StatusCard, getStatusColor } from '../../../../components/ui';
|
||||
import { PageHeader } from '../../../../components/layout';
|
||||
import { LoadingSpinner } from '../../../../components/shared';
|
||||
import { LoadingSpinner } from '../../../../components/ui';
|
||||
import { formatters } from '../../../../components/ui/Stats/StatsPresets';
|
||||
import { useSalesAnalytics, useSalesRecords, useProductCategories } from '../../../../api/hooks/sales';
|
||||
import { useTenantId } from '../../../../hooks/useTenantId';
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import React, { useState, useMemo } from 'react';
|
||||
import { Plus, AlertTriangle, Package, CheckCircle, Eye, Clock, Euro, ArrowRight, Minus, Edit, Trash2, Archive, TrendingUp, History } from 'lucide-react';
|
||||
import { Button, Input, Card, StatsGrid, StatusCard, getStatusColor } from '../../../../components/ui';
|
||||
import { LoadingSpinner } from '../../../../components/shared';
|
||||
import { LoadingSpinner } from '../../../../components/ui';
|
||||
import { formatters } from '../../../../components/ui/Stats/StatsPresets';
|
||||
import { PageHeader } from '../../../../components/layout';
|
||||
import {
|
||||
|
||||
@@ -3,10 +3,10 @@ import { useTranslation } from 'react-i18next';
|
||||
import { Plus, AlertTriangle, Settings, CheckCircle, Eye, Wrench, Thermometer, Activity, Search, Filter, Bell, History, Calendar, Edit, Trash2 } from 'lucide-react';
|
||||
import { Button, Input, Card, StatsGrid, StatusCard, getStatusColor } from '../../../../components/ui';
|
||||
import { Badge } from '../../../../components/ui/Badge';
|
||||
import { LoadingSpinner } from '../../../../components/shared';
|
||||
import { LoadingSpinner } from '../../../../components/ui';
|
||||
import { PageHeader } from '../../../../components/layout';
|
||||
import { useCurrentTenant } from '../../../../stores/tenant.store';
|
||||
import { Equipment } from '../../../../types/equipment';
|
||||
import { Equipment } from '../../../../api/types/equipment';
|
||||
import { EquipmentModal } from '../../../../components/domain/equipment/EquipmentModal';
|
||||
|
||||
const MOCK_EQUIPMENT: Equipment[] = [
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import React, { useState } from 'react';
|
||||
import { Plus, Clock, Package, Eye, Edit, CheckCircle, AlertCircle, Timer, Users, Loader, Euro } from 'lucide-react';
|
||||
import { Button, Input, Card, Badge, StatsGrid, StatusCard, getStatusColor, StatusModal, Tabs } from '../../../../components/ui';
|
||||
import { Button, Input, Card, Badge, StatsGrid, StatusCard, getStatusColor, EditViewModal, Tabs } from '../../../../components/ui';
|
||||
import { formatters } from '../../../../components/ui/Stats/StatsPresets';
|
||||
import { PageHeader } from '../../../../components/layout';
|
||||
import {
|
||||
@@ -23,7 +23,7 @@ import { useOrders, useCustomers, useOrdersDashboard, useCreateOrder, useCreateC
|
||||
import { useCurrentTenant } from '../../../../stores/tenant.store';
|
||||
import { useAuthUser } from '../../../../stores/auth.store';
|
||||
import { OrderFormModal } from '../../../../components/domain/orders';
|
||||
import { useOrderEnums } from '../../../../utils/enumHelpers';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
const OrdersPage: React.FC = () => {
|
||||
const [activeTab, setActiveTab] = useState<'orders' | 'customers'>('orders');
|
||||
@@ -40,7 +40,7 @@ const OrdersPage: React.FC = () => {
|
||||
const currentTenant = useCurrentTenant();
|
||||
const user = useAuthUser();
|
||||
const tenantId = currentTenant?.id || user?.tenant_id || '';
|
||||
const orderEnums = useOrderEnums();
|
||||
const { t } = useTranslation(['orders', 'common']);
|
||||
|
||||
// API hooks for orders
|
||||
const {
|
||||
@@ -364,7 +364,7 @@ const OrdersPage: React.FC = () => {
|
||||
`Entrega: ${order.requested_delivery_date ?
|
||||
new Date(order.requested_delivery_date).toLocaleDateString('es-ES', { day: '2-digit', month: '2-digit' }) :
|
||||
'Sin fecha'}`,
|
||||
`${orderEnums.getDeliveryMethodLabel(order.delivery_method)}`,
|
||||
`${t(`orders:delivery_methods.${order.delivery_method.toLowerCase()}`)}`,
|
||||
...(paymentNote ? [paymentNote] : [])
|
||||
]}
|
||||
actions={[
|
||||
@@ -405,7 +405,7 @@ const OrdersPage: React.FC = () => {
|
||||
id={customer.id}
|
||||
statusIndicator={statusConfig}
|
||||
title={customer.name}
|
||||
subtitle={orderEnums.getCustomerTypeLabel(customer.customer_type)}
|
||||
subtitle={t(`orders:customer_types.${customer.customer_type.toLowerCase()}`)}
|
||||
primaryValue={customer.total_orders}
|
||||
primaryValueLabel="pedidos"
|
||||
secondaryInfo={{
|
||||
@@ -519,21 +519,30 @@ const OrdersPage: React.FC = () => {
|
||||
value: selectedOrder.order_type || OrderType.STANDARD,
|
||||
type: 'select',
|
||||
editable: true,
|
||||
options: orderEnums.getOrderTypeOptions()
|
||||
options: Object.values(OrderType).map(value => ({
|
||||
value,
|
||||
label: t(`orders:order_types.${value.toLowerCase()}`)
|
||||
}))
|
||||
},
|
||||
{
|
||||
label: 'Prioridad',
|
||||
value: selectedOrder.priority || PriorityLevel.NORMAL,
|
||||
type: 'select',
|
||||
editable: true,
|
||||
options: orderEnums.getPriorityLevelOptions()
|
||||
options: Object.values(PriorityLevel).map(value => ({
|
||||
value,
|
||||
label: t(`orders:priority_levels.${value.toLowerCase()}`)
|
||||
}))
|
||||
},
|
||||
{
|
||||
label: 'Método de Entrega',
|
||||
value: selectedOrder.delivery_method || DeliveryMethod.PICKUP,
|
||||
type: 'select',
|
||||
editable: true,
|
||||
options: orderEnums.getDeliveryMethodOptions()
|
||||
options: Object.values(DeliveryMethod).map(value => ({
|
||||
value,
|
||||
label: t(`orders:delivery_methods.${value.toLowerCase()}`)
|
||||
}))
|
||||
},
|
||||
{
|
||||
label: 'Fecha del Pedido',
|
||||
@@ -590,7 +599,10 @@ const OrdersPage: React.FC = () => {
|
||||
value: selectedOrder.payment_status || PaymentStatus.PENDING,
|
||||
type: 'select',
|
||||
editable: true,
|
||||
options: orderEnums.getPaymentStatusOptions()
|
||||
options: Object.values(PaymentStatus).map(value => ({
|
||||
value,
|
||||
label: t(`orders:payment_status.${value.toLowerCase()}`)
|
||||
}))
|
||||
}
|
||||
]
|
||||
},
|
||||
@@ -609,7 +621,7 @@ const OrdersPage: React.FC = () => {
|
||||
];
|
||||
|
||||
return (
|
||||
<StatusModal
|
||||
<EditViewModal
|
||||
isOpen={showForm}
|
||||
onClose={() => {
|
||||
setShowForm(false);
|
||||
@@ -680,7 +692,10 @@ const OrdersPage: React.FC = () => {
|
||||
value: selectedCustomer.customer_type || CustomerType.INDIVIDUAL,
|
||||
type: 'select',
|
||||
editable: true,
|
||||
options: orderEnums.getCustomerTypeOptions()
|
||||
options: Object.values(CustomerType).map(value => ({
|
||||
value,
|
||||
label: t(`orders:customer_types.${value.toLowerCase()}`)
|
||||
}))
|
||||
},
|
||||
{
|
||||
label: 'Email',
|
||||
@@ -718,14 +733,20 @@ const OrdersPage: React.FC = () => {
|
||||
value: selectedCustomer.preferred_delivery_method || DeliveryMethod.PICKUP,
|
||||
type: 'select',
|
||||
editable: true,
|
||||
options: orderEnums.getDeliveryMethodOptions()
|
||||
options: Object.values(DeliveryMethod).map(value => ({
|
||||
value,
|
||||
label: t(`orders:delivery_methods.${value.toLowerCase()}`)
|
||||
}))
|
||||
},
|
||||
{
|
||||
label: 'Términos de Pago',
|
||||
value: selectedCustomer.payment_terms || PaymentTerms.IMMEDIATE,
|
||||
type: 'select',
|
||||
editable: true,
|
||||
options: orderEnums.getPaymentTermsOptions()
|
||||
options: Object.values(PaymentTerms).map(value => ({
|
||||
value,
|
||||
label: t(`orders:payment_terms.${value.toLowerCase()}`)
|
||||
}))
|
||||
},
|
||||
{
|
||||
label: 'Descuento (%)',
|
||||
@@ -738,7 +759,10 @@ const OrdersPage: React.FC = () => {
|
||||
value: selectedCustomer.customer_segment || CustomerSegment.REGULAR,
|
||||
type: 'select',
|
||||
editable: true,
|
||||
options: orderEnums.getCustomerSegmentOptions()
|
||||
options: Object.values(CustomerSegment).map(value => ({
|
||||
value,
|
||||
label: t(`orders:customer_segments.${value.toLowerCase()}`)
|
||||
}))
|
||||
},
|
||||
{
|
||||
label: 'Estado',
|
||||
@@ -763,7 +787,7 @@ const OrdersPage: React.FC = () => {
|
||||
];
|
||||
|
||||
return (
|
||||
<StatusModal
|
||||
<EditViewModal
|
||||
isOpen={showForm}
|
||||
onClose={() => {
|
||||
setShowForm(false);
|
||||
|
||||
@@ -2,7 +2,7 @@ import React, { useState, useMemo } from 'react';
|
||||
import { Plus, Minus, ShoppingCart, CreditCard, Banknote, Calculator, User, Receipt, Package, Euro, TrendingUp, Clock, ToggleLeft, ToggleRight, Settings, Zap, Wifi, WifiOff, AlertCircle, CheckCircle, Loader, Eye, EyeOff, Info, Trash2 } from 'lucide-react';
|
||||
import { Button, Input, Card, Badge, StatsGrid, StatusCard, getStatusColor, Tabs, Modal, Select } from '../../../../components/ui';
|
||||
import { PageHeader } from '../../../../components/layout';
|
||||
import { LoadingSpinner } from '../../../../components/shared';
|
||||
import { LoadingSpinner } from '../../../../components/ui';
|
||||
import { formatters } from '../../../../components/ui/Stats/StatsPresets';
|
||||
import { useIngredients } from '../../../../api/hooks/inventory';
|
||||
import { useTenantId } from '../../../../hooks/useTenantId';
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import React, { useState } from 'react';
|
||||
import { Plus, ShoppingCart, Truck, Euro, Calendar, Clock, CheckCircle, AlertCircle, Package, Eye, Loader, Edit, ArrowRight, X, Save, Building2, Play, Zap, User } from 'lucide-react';
|
||||
import { Button, Input, Card, StatsGrid, StatusCard, getStatusColor, StatusModal } from '../../../../components/ui';
|
||||
import { Button, Input, Card, StatsGrid, StatusCard, getStatusColor, EditViewModal } from '../../../../components/ui';
|
||||
import { formatters } from '../../../../components/ui/Stats/StatsPresets';
|
||||
import { PageHeader } from '../../../../components/layout';
|
||||
import { CreatePurchaseOrderModal } from '../../../../components/domain/procurement/CreatePurchaseOrderModal';
|
||||
@@ -722,7 +722,7 @@ const ProcurementPage: React.FC = () => {
|
||||
|
||||
{/* Procurement Plan Modal */}
|
||||
{showForm && selectedPlan && (
|
||||
<StatusModal
|
||||
<EditViewModal
|
||||
isOpen={showForm}
|
||||
onClose={() => {
|
||||
setShowForm(false);
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import React, { useState, useMemo } from 'react';
|
||||
import { Plus, Clock, AlertCircle, CheckCircle, Timer, ChefHat, Eye, Edit, Package, Zap, User, PlusCircle } from 'lucide-react';
|
||||
import { Button, Input, Card, StatsGrid, StatusModal, Toggle } from '../../../../components/ui';
|
||||
import { Button, Input, Card, StatsGrid, EditViewModal, Toggle } from '../../../../components/ui';
|
||||
import { statusColors } from '../../../../styles/colors';
|
||||
import { formatters } from '../../../../components/ui/Stats/StatsPresets';
|
||||
import { LoadingSpinner } from '../../../../components/shared';
|
||||
import { LoadingSpinner } from '../../../../components/ui';
|
||||
import { PageHeader } from '../../../../components/layout';
|
||||
import { ProductionSchedule, CreateProductionBatchModal, ProductionStatusCard, QualityCheckModal, CompactProcessStageTracker } from '../../../../components/domain/production';
|
||||
import { useCurrentTenant } from '../../../../stores/tenant.store';
|
||||
@@ -23,7 +23,7 @@ import {
|
||||
ProductionStatusEnum,
|
||||
ProductionPriorityEnum
|
||||
} from '../../../../api';
|
||||
import { useProductionEnums } from '../../../../utils/enumHelpers';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { ProcessStage } from '../../../../api/types/qualityTemplates';
|
||||
|
||||
const ProductionPage: React.FC = () => {
|
||||
@@ -37,7 +37,7 @@ const ProductionPage: React.FC = () => {
|
||||
|
||||
const currentTenant = useCurrentTenant();
|
||||
const tenantId = currentTenant?.id || '';
|
||||
const productionEnums = useProductionEnums();
|
||||
const { t } = useTranslation(['production', 'common']);
|
||||
|
||||
// API Data
|
||||
const {
|
||||
@@ -471,7 +471,7 @@ const ProductionPage: React.FC = () => {
|
||||
|
||||
{/* Production Batch Modal */}
|
||||
{showBatchModal && selectedBatch && (
|
||||
<StatusModal
|
||||
<EditViewModal
|
||||
isOpen={showBatchModal}
|
||||
onClose={() => {
|
||||
setShowBatchModal(false);
|
||||
@@ -484,7 +484,7 @@ const ProductionPage: React.FC = () => {
|
||||
subtitle={`Lote de Producción #${selectedBatch.batch_number}`}
|
||||
statusIndicator={{
|
||||
color: statusColors.inProgress.primary,
|
||||
text: productionEnums.getProductionStatusLabel(selectedBatch.status),
|
||||
text: t(`production:status.${selectedBatch.status.toLowerCase()}`),
|
||||
icon: Package
|
||||
}}
|
||||
size="lg"
|
||||
@@ -511,14 +511,20 @@ const ProductionPage: React.FC = () => {
|
||||
value: selectedBatch.priority,
|
||||
type: 'select',
|
||||
editable: modalMode === 'edit',
|
||||
options: productionEnums.getProductionPriorityOptions()
|
||||
options: Object.values(ProductionPriorityEnum).map(value => ({
|
||||
value,
|
||||
label: t(`production:priority.${value.toLowerCase()}`)
|
||||
}))
|
||||
},
|
||||
{
|
||||
label: 'Estado',
|
||||
value: selectedBatch.status,
|
||||
type: 'select',
|
||||
editable: modalMode === 'edit',
|
||||
options: productionEnums.getProductionStatusOptions()
|
||||
options: Object.values(ProductionStatusEnum).map(value => ({
|
||||
value,
|
||||
label: t(`production:status.${value.toLowerCase()}`)
|
||||
}))
|
||||
},
|
||||
{
|
||||
label: 'Personal Asignado',
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import React, { useState, useMemo } from 'react';
|
||||
import { Plus, Star, Clock, Euro, Package, Eye, Edit, ChefHat, Timer, CheckCircle } from 'lucide-react';
|
||||
import { Button, Input, Card, StatsGrid, StatusCard, getStatusColor, StatusModal } from '../../../../components/ui';
|
||||
import { LoadingSpinner } from '../../../../components/shared';
|
||||
import { Button, Input, Card, StatsGrid, StatusCard, getStatusColor, EditViewModal } from '../../../../components/ui';
|
||||
import { LoadingSpinner } from '../../../../components/ui';
|
||||
import { formatters } from '../../../../components/ui/Stats/StatsPresets';
|
||||
import { PageHeader } from '../../../../components/layout';
|
||||
import { useRecipes, useRecipeStatistics, useCreateRecipe, useUpdateRecipe, useDeleteRecipe } from '../../../../api/hooks/recipes';
|
||||
@@ -549,7 +549,7 @@ const RecipesPage: React.FC = () => {
|
||||
|
||||
{/* Recipe Details Modal */}
|
||||
{showForm && selectedRecipe && (
|
||||
<StatusModal
|
||||
<EditViewModal
|
||||
isOpen={showForm}
|
||||
onClose={() => {
|
||||
setShowForm(false);
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
import React, { useState } from 'react';
|
||||
import { Plus, Building2, Phone, Mail, Eye, Edit, CheckCircle, AlertCircle, Timer, Users, Euro, Loader } from 'lucide-react';
|
||||
import { Button, Input, Card, Badge, StatsGrid, StatusCard, getStatusColor, StatusModal } from '../../../../components/ui';
|
||||
import { Button, Input, Card, Badge, StatsGrid, StatusCard, getStatusColor, EditViewModal } 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';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
const SuppliersPage: React.FC = () => {
|
||||
const [activeTab] = useState('all');
|
||||
@@ -40,15 +40,15 @@ const SuppliersPage: React.FC = () => {
|
||||
} = useSupplierStatistics(tenantId);
|
||||
|
||||
const suppliers = suppliersData || [];
|
||||
const supplierEnums = useSupplierEnums();
|
||||
const { t } = useTranslation(['suppliers', 'common']);
|
||||
|
||||
const getSupplierStatusConfig = (status: SupplierStatus) => {
|
||||
const statusConfig = {
|
||||
[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 },
|
||||
[SupplierStatus.ACTIVE]: { text: t(`suppliers:status.${status.toLowerCase()}`), icon: CheckCircle },
|
||||
[SupplierStatus.INACTIVE]: { text: t(`suppliers:status.${status.toLowerCase()}`), icon: Timer },
|
||||
[SupplierStatus.PENDING_APPROVAL]: { text: t(`suppliers:status.${status.toLowerCase()}`), icon: AlertCircle },
|
||||
[SupplierStatus.SUSPENDED]: { text: t(`suppliers:status.${status.toLowerCase()}`), icon: AlertCircle },
|
||||
[SupplierStatus.BLACKLISTED]: { text: t(`suppliers:status.${status.toLowerCase()}`), icon: AlertCircle },
|
||||
};
|
||||
|
||||
const config = statusConfig[status];
|
||||
@@ -65,11 +65,11 @@ const SuppliersPage: React.FC = () => {
|
||||
};
|
||||
|
||||
const getSupplierTypeText = (type: SupplierType): string => {
|
||||
return supplierEnums.getSupplierTypeLabel(type);
|
||||
return t(`suppliers:types.${type.toLowerCase()}`);
|
||||
};
|
||||
|
||||
const getPaymentTermsText = (terms: PaymentTerms): string => {
|
||||
return supplierEnums.getPaymentTermsLabel(terms);
|
||||
return t(`suppliers:payment_terms.${terms.toLowerCase()}`);
|
||||
};
|
||||
|
||||
// Filtering is now handled by the API query parameters
|
||||
@@ -348,14 +348,20 @@ const SuppliersPage: React.FC = () => {
|
||||
value: selectedSupplier.supplier_type || SupplierType.INGREDIENTS,
|
||||
type: 'select',
|
||||
editable: true,
|
||||
options: supplierEnums.getSupplierTypeOptions()
|
||||
options: Object.values(SupplierType).map(value => ({
|
||||
value,
|
||||
label: t(`suppliers:types.${value.toLowerCase()}`)
|
||||
}))
|
||||
},
|
||||
{
|
||||
label: 'Condiciones de Pago',
|
||||
value: selectedSupplier.payment_terms || PaymentTerms.NET_30,
|
||||
type: 'select',
|
||||
editable: true,
|
||||
options: supplierEnums.getPaymentTermsOptions()
|
||||
options: Object.values(PaymentTerms).map(value => ({
|
||||
value,
|
||||
label: t(`suppliers:payment_terms.${value.toLowerCase()}`)
|
||||
}))
|
||||
},
|
||||
{
|
||||
label: 'Tiempo de Entrega (días)',
|
||||
@@ -420,7 +426,7 @@ const SuppliersPage: React.FC = () => {
|
||||
];
|
||||
|
||||
return (
|
||||
<StatusModal
|
||||
<EditViewModal
|
||||
isOpen={showForm}
|
||||
onClose={() => {
|
||||
setShowForm(false);
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import React, { Suspense } from 'react';
|
||||
import { Routes, Route, Navigate } from 'react-router-dom';
|
||||
import { ProtectedRoute } from './ProtectedRoute';
|
||||
import { LoadingSpinner } from '../components/shared/LoadingSpinner';
|
||||
import { LoadingSpinner } from '../components/ui';
|
||||
import { AppShell } from '../components/layout';
|
||||
|
||||
// Lazy load the pages we actually have
|
||||
|
||||
@@ -1,353 +0,0 @@
|
||||
// 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
|
||||
} from '../api/types/suppliers';
|
||||
|
||||
import {
|
||||
CustomerType,
|
||||
DeliveryMethod,
|
||||
PaymentTerms as OrderPaymentTerms,
|
||||
PaymentMethod,
|
||||
PaymentStatus,
|
||||
CustomerSegment,
|
||||
PriorityLevel,
|
||||
OrderType,
|
||||
OrderStatus,
|
||||
OrderSource,
|
||||
SalesChannel
|
||||
} from '../api/types/orders';
|
||||
|
||||
import {
|
||||
ProductionStatusEnum,
|
||||
ProductionPriorityEnum,
|
||||
ProductionBatchStatus,
|
||||
QualityCheckStatus
|
||||
} from '../api/types/production';
|
||||
|
||||
/**
|
||||
* 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 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 t('common:status.undefined', 'Type not defined');
|
||||
return t(`types.${type}`, type);
|
||||
},
|
||||
|
||||
// Supplier Status
|
||||
getSupplierStatusOptions: (): SelectOption[] =>
|
||||
enumToSelectOptions(SupplierStatus, 'status', t),
|
||||
|
||||
getSupplierStatusLabel: (status: SupplierStatus): string => {
|
||||
if (!status) return t('common:status.undefined', 'Status not defined');
|
||||
return t(`status.${status}`, status);
|
||||
},
|
||||
|
||||
// Payment Terms
|
||||
getPaymentTermsOptions: (): SelectOption[] =>
|
||||
enumToSelectOptions(PaymentTerms, 'payment_terms', t, {
|
||||
includeDescription: true,
|
||||
descriptionKey: 'descriptions'
|
||||
}),
|
||||
|
||||
getPaymentTermsLabel: (terms: PaymentTerms): string => {
|
||||
if (!terms) return t('common:forms.no_terms', 'No terms defined');
|
||||
return t(`payment_terms.${terms}`, terms);
|
||||
},
|
||||
|
||||
// Purchase Order Status
|
||||
getPurchaseOrderStatusOptions: (): SelectOption[] =>
|
||||
enumToSelectOptions(PurchaseOrderStatus, 'purchase_order_status', t),
|
||||
|
||||
getPurchaseOrderStatusLabel: (status: PurchaseOrderStatus): string => {
|
||||
if (!status) return t('common:status.undefined', 'Status not defined');
|
||||
return t(`purchase_order_status.${status}`, status);
|
||||
},
|
||||
|
||||
// Delivery Status
|
||||
getDeliveryStatusOptions: (): SelectOption[] =>
|
||||
enumToSelectOptions(DeliveryStatus, 'delivery_status', t),
|
||||
|
||||
getDeliveryStatusLabel: (status: DeliveryStatus): string => {
|
||||
if (!status) return t('common:status.undefined', 'Status not defined');
|
||||
return t(`delivery_status.${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 t('common:status.no_rating', 'No rating');
|
||||
return t(`quality_rating.${rating}`, rating.toString());
|
||||
},
|
||||
|
||||
// Delivery Rating
|
||||
getDeliveryRatingOptions: (): SelectOption[] =>
|
||||
enumToSelectOptions(DeliveryRating, 'delivery_rating', t, {
|
||||
includeDescription: true,
|
||||
descriptionKey: 'descriptions'
|
||||
}),
|
||||
|
||||
getDeliveryRatingLabel: (rating: DeliveryRating): string => {
|
||||
if (rating === undefined || rating === null) return t('common:status.no_rating', 'No rating');
|
||||
return t(`delivery_rating.${rating}`, rating.toString());
|
||||
},
|
||||
|
||||
// Invoice Status
|
||||
getInvoiceStatusOptions: (): SelectOption[] =>
|
||||
enumToSelectOptions(InvoiceStatus, 'invoice_status', t),
|
||||
|
||||
getInvoiceStatusLabel: (status: InvoiceStatus): string => {
|
||||
if (!status) return t('common:status.undefined', 'Status not defined');
|
||||
return t(`invoice_status.${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);
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook for orders enum utilities
|
||||
*/
|
||||
export function useOrderEnums() {
|
||||
const { t } = useTranslation('orders');
|
||||
|
||||
return {
|
||||
// Customer Type
|
||||
getCustomerTypeOptions: (): SelectOption[] =>
|
||||
enumToSelectOptions(CustomerType, 'customer_types', t),
|
||||
|
||||
getCustomerTypeLabel: (type: CustomerType): string => {
|
||||
if (!type) return t('common:status.undefined', 'Type not defined');
|
||||
return t(`customer_types.${type}`, type.charAt(0).toUpperCase() + type.slice(1));
|
||||
},
|
||||
|
||||
// Delivery Method
|
||||
getDeliveryMethodOptions: (): SelectOption[] =>
|
||||
enumToSelectOptions(DeliveryMethod, 'delivery_methods', t),
|
||||
|
||||
getDeliveryMethodLabel: (method: DeliveryMethod): string => {
|
||||
if (!method) return t('common:status.undefined', 'Method not defined');
|
||||
return t(`delivery_methods.${method}`, method.charAt(0).toUpperCase() + method.slice(1));
|
||||
},
|
||||
|
||||
// Payment Terms
|
||||
getPaymentTermsOptions: (): SelectOption[] =>
|
||||
enumToSelectOptions(OrderPaymentTerms, 'payment_terms', t),
|
||||
|
||||
getPaymentTermsLabel: (terms: OrderPaymentTerms): string => {
|
||||
if (!terms) return t('common:forms.no_terms', 'Terms not defined');
|
||||
return t(`payment_terms.${terms}`, terms);
|
||||
},
|
||||
|
||||
// Payment Method
|
||||
getPaymentMethodOptions: (): SelectOption[] =>
|
||||
enumToSelectOptions(PaymentMethod, 'payment_methods', t),
|
||||
|
||||
getPaymentMethodLabel: (method: PaymentMethod): string => {
|
||||
if (!method) return t('common:status.undefined', 'Method not defined');
|
||||
return t(`payment_methods.${method}`, method);
|
||||
},
|
||||
|
||||
// Payment Status
|
||||
getPaymentStatusOptions: (): SelectOption[] =>
|
||||
enumToSelectOptions(PaymentStatus, 'payment_status', t),
|
||||
|
||||
getPaymentStatusLabel: (status: PaymentStatus): string => {
|
||||
if (!status) return t('common:status.undefined', 'Status not defined');
|
||||
return t(`payment_status.${status}`, status);
|
||||
},
|
||||
|
||||
// Customer Segment
|
||||
getCustomerSegmentOptions: (): SelectOption[] =>
|
||||
enumToSelectOptions(CustomerSegment, 'customer_segments', t),
|
||||
|
||||
getCustomerSegmentLabel: (segment: CustomerSegment): string => {
|
||||
if (!segment) return t('common:status.undefined', 'Segment not defined');
|
||||
return t(`customer_segments.${segment}`, segment);
|
||||
},
|
||||
|
||||
// Priority Level
|
||||
getPriorityLevelOptions: (): SelectOption[] =>
|
||||
enumToSelectOptions(PriorityLevel, 'priority_levels', t),
|
||||
|
||||
getPriorityLevelLabel: (level: PriorityLevel): string => {
|
||||
if (!level) return t('common:priority.undefined', 'Priority not defined');
|
||||
return t(`priority_levels.${level}`, level);
|
||||
},
|
||||
|
||||
// Order Type
|
||||
getOrderTypeOptions: (): SelectOption[] =>
|
||||
enumToSelectOptions(OrderType, 'order_types', t),
|
||||
|
||||
getOrderTypeLabel: (type: OrderType): string => {
|
||||
if (!type) return t('common:status.undefined', 'Type not defined');
|
||||
return t(`order_types.${type}`, type.charAt(0).toUpperCase() + type.slice(1));
|
||||
},
|
||||
|
||||
// Order Status
|
||||
getOrderStatusOptions: (): SelectOption[] =>
|
||||
enumToSelectOptions(OrderStatus, 'order_status', t),
|
||||
|
||||
getOrderStatusLabel: (status: OrderStatus): string => {
|
||||
if (!status) return t('common:status.undefined', 'Status not defined');
|
||||
return t(`order_status.${status}`, status);
|
||||
},
|
||||
|
||||
// Order Source
|
||||
getOrderSourceOptions: (): SelectOption[] =>
|
||||
enumToSelectOptions(OrderSource, 'order_sources', t),
|
||||
|
||||
getOrderSourceLabel: (source: OrderSource): string => {
|
||||
if (!source) return t('common:status.undefined', 'Source not defined');
|
||||
return t(`order_sources.${source}`, source);
|
||||
},
|
||||
|
||||
// Sales Channel
|
||||
getSalesChannelOptions: (): SelectOption[] =>
|
||||
enumToSelectOptions(SalesChannel, 'sales_channels', t),
|
||||
|
||||
getSalesChannelLabel: (channel: SalesChannel): string => {
|
||||
if (!channel) return t('common:status.undefined', 'Channel not defined');
|
||||
return t(`sales_channels.${channel}`, channel);
|
||||
},
|
||||
|
||||
// Field Labels
|
||||
getFieldLabel: (field: string): string =>
|
||||
t(`labels.${field}`),
|
||||
|
||||
getFieldDescription: (field: string): string =>
|
||||
t(`descriptions.${field}`)
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook for production enum utilities
|
||||
*/
|
||||
export function useProductionEnums() {
|
||||
const { t } = useTranslation('production');
|
||||
|
||||
return {
|
||||
// Production Status
|
||||
getProductionStatusOptions: (): SelectOption[] =>
|
||||
enumToSelectOptions(ProductionStatusEnum, 'production_status', t),
|
||||
|
||||
getProductionStatusLabel: (status: ProductionStatusEnum): string => {
|
||||
if (!status) return t('common:status.undefined', 'Status not defined');
|
||||
return t(`production_status.${status}`, status);
|
||||
},
|
||||
|
||||
// Production Priority
|
||||
getProductionPriorityOptions: (): SelectOption[] =>
|
||||
enumToSelectOptions(ProductionPriorityEnum, 'production_priority', t),
|
||||
|
||||
getProductionPriorityLabel: (priority: ProductionPriorityEnum): string => {
|
||||
if (!priority) return t('common:priority.undefined', 'Priority not defined');
|
||||
return t(`production_priority.${priority}`, priority);
|
||||
},
|
||||
|
||||
// Production Batch Status
|
||||
getProductionBatchStatusOptions: (): SelectOption[] =>
|
||||
enumToSelectOptions(ProductionBatchStatus, 'batch_status', t),
|
||||
|
||||
getProductionBatchStatusLabel: (status: ProductionBatchStatus): string => {
|
||||
if (!status) return t('common:status.undefined', 'Status not defined');
|
||||
return t(`batch_status.${status}`, status);
|
||||
},
|
||||
|
||||
// Quality Check Status
|
||||
getQualityCheckStatusOptions: (): SelectOption[] =>
|
||||
enumToSelectOptions(QualityCheckStatus, 'quality_check_status', t),
|
||||
|
||||
getQualityCheckStatusLabel: (status: QualityCheckStatus): string => {
|
||||
if (!status) return t('common:status.undefined', 'Status not defined');
|
||||
return t(`quality_check_status.${status}`, status);
|
||||
},
|
||||
|
||||
// Field Labels
|
||||
getFieldLabel: (field: string): string =>
|
||||
t(`labels.${field}`),
|
||||
|
||||
getFieldDescription: (field: string): string =>
|
||||
t(`descriptions.${field}`)
|
||||
};
|
||||
}
|
||||
@@ -1,109 +0,0 @@
|
||||
// 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);
|
||||
}
|
||||
@@ -1,140 +0,0 @@
|
||||
// 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