Improve the frontend modals

This commit is contained in:
Urtzi Alfaro
2025-10-27 16:33:26 +01:00
parent 61376b7a9f
commit 858d985c92
143 changed files with 9289 additions and 2306 deletions

View File

@@ -1,6 +1,7 @@
import React, { useState, useMemo } from 'react';
import { useTranslation } from 'react-i18next';
import { Plus, AlertTriangle, Package, CheckCircle, Eye, Clock, Euro, ArrowRight, Minus, Edit, Trash2, Archive, TrendingUp, History } from 'lucide-react';
import { Button, StatsGrid, StatusCard, getStatusColor, SearchAndFilter, type FilterConfig, Card } from '../../../../components/ui';
import { Button, StatsGrid, StatusCard, getStatusColor, SearchAndFilter, type FilterConfig, Card, EmptyState } from '../../../../components/ui';
import { LoadingSpinner } from '../../../../components/ui';
import { formatters } from '../../../../components/ui/Stats/StatsPresets';
import { PageHeader } from '../../../../components/layout';
@@ -14,12 +15,14 @@ import {
// Import AddStockModal separately since we need it for adding batches
import AddStockModal from '../../../../components/domain/inventory/AddStockModal';
import { useIngredients, useStockAnalytics, useStockMovements, useStockByIngredient, useCreateIngredient, useSoftDeleteIngredient, useHardDeleteIngredient, useAddStock, useConsumeStock, useUpdateIngredient, useTransformationsByIngredient } from '../../../../api/hooks/inventory';
import { useIngredients, useStockAnalytics, useStockMovements, useStockByIngredient, useCreateIngredient, useSoftDeleteIngredient, useHardDeleteIngredient, useAddStock, useConsumeStock, useUpdateIngredient, useUpdateStock, useTransformationsByIngredient } from '../../../../api/hooks/inventory';
import { useTenantId } from '../../../../hooks/useTenantId';
import { IngredientResponse, StockCreate, StockMovementCreate, IngredientCreate } from '../../../../api/types/inventory';
import { subscriptionService } from '../../../../api/services/subscription';
import { useQueryClient } from '@tanstack/react-query';
const InventoryPage: React.FC = () => {
const { t } = useTranslation(['inventory', 'common']);
const [searchTerm, setSearchTerm] = useState('');
const [statusFilter, setStatusFilter] = useState('');
const [categoryFilter, setCategoryFilter] = useState('');
@@ -34,6 +37,7 @@ const InventoryPage: React.FC = () => {
const [showAddBatch, setShowAddBatch] = useState(false);
const tenantId = useTenantId();
const queryClient = useQueryClient();
// Debug tenant ID
console.log('🔍 [InventoryPage] Tenant ID from hook:', tenantId);
@@ -47,12 +51,14 @@ const InventoryPage: React.FC = () => {
const addStockMutation = useAddStock();
const consumeStockMutation = useConsumeStock();
const updateIngredientMutation = useUpdateIngredient();
const updateStockMutation = useUpdateStock();
// API Data
const {
data: ingredientsData,
isLoading: ingredientsLoading,
error: ingredientsError
error: ingredientsError,
isRefetching: isRefetchingIngredients
} = useIngredients(tenantId, { search: searchTerm || undefined });
@@ -85,7 +91,8 @@ const InventoryPage: React.FC = () => {
const {
data: stockLotsData,
isLoading: stockLotsLoading,
error: stockLotsError
error: stockLotsError,
isRefetching: isRefetchingBatches
} = useStockByIngredient(
tenantId,
selectedItem?.id || '',
@@ -283,12 +290,30 @@ const InventoryPage: React.FC = () => {
});
}, [ingredients, searchTerm, statusFilter, categoryFilter]);
// Helper function to get category display name
// Helper function to get translated category display name
const getCategoryDisplayName = (category?: string): string => {
if (!category) return 'Sin categoría';
if (!category) return t('inventory:categories.all', 'Sin categoría');
// Try ingredient category translation first
const ingredientTranslation = t(`inventory:enums.ingredient_category.${category}`, { defaultValue: '' });
if (ingredientTranslation) return ingredientTranslation;
// Try product category translation
const productTranslation = t(`inventory:enums.product_category.${category}`, { defaultValue: '' });
if (productTranslation) return productTranslation;
// Fallback to raw category if no translation found
return category;
};
// Helper function to get translated unit display name
const getUnitDisplayName = (unit?: string): string => {
if (!unit) return '';
// Translate unit of measure
return t(`inventory:enums.unit_of_measure.${unit}`, { defaultValue: unit });
};
// Focused action handlers
const handleShowInfo = (ingredient: IngredientResponse) => {
setSelectedItem(ingredient);
@@ -325,7 +350,7 @@ const InventoryPage: React.FC = () => {
try {
// Check subscription limits before creating
const usageCheck = await subscriptionService.checkUsageLimit(tenantId, 'inventory_items', 1);
const usageCheck = await subscriptionService.checkQuotaLimit(tenantId, 'inventory_items', 1);
if (!usageCheck.allowed) {
throw new Error(
@@ -397,6 +422,22 @@ const InventoryPage: React.FC = () => {
});
};
// Refetch callbacks for wait-for-refetch pattern
const handleIngredientSaveComplete = async () => {
if (!tenantId) return;
// Invalidate ingredients query to trigger refetch
await queryClient.invalidateQueries(['ingredients', tenantId]);
};
const handleBatchSaveComplete = async () => {
if (!tenantId || !selectedItem?.id) return;
// Invalidate both ingredients (for updated stock totals) and stock lots queries
await Promise.all([
queryClient.invalidateQueries(['ingredients', tenantId]),
queryClient.invalidateQueries(['stock', 'by-ingredient', tenantId, selectedItem.id])
]);
};
const inventoryStats = useMemo(() => {
@@ -516,7 +557,7 @@ const InventoryPage: React.FC = () => {
<div className="space-y-6">
<PageHeader
title="Gestión de Inventario"
description="Controla el stock de ingredientes y materias primas"
description="Gestiona stock, costos, lotes y alertas de ingredientes"
actions={[
{
id: "add-new-item",
@@ -598,7 +639,7 @@ const InventoryPage: React.FC = () => {
title={ingredient.name}
subtitle={getCategoryDisplayName(ingredient.category)}
primaryValue={currentStock}
primaryValueLabel={ingredient.unit_of_measure}
primaryValueLabel={getUnitDisplayName(ingredient.unit_of_measure)}
secondaryInfo={{
label: 'Valor',
value: formatters.currency(totalValue)
@@ -610,7 +651,7 @@ const InventoryPage: React.FC = () => {
} : undefined}
onClick={() => handleShowInfo(ingredient)}
actions={[
// Primary action - View item details
// Primary action - View item details (left side)
{
label: 'Ver Detalles',
icon: Eye,
@@ -618,27 +659,27 @@ const InventoryPage: React.FC = () => {
priority: 'primary',
onClick: () => handleShowInfo(ingredient)
},
// Stock history action - Icon button
// Delete action - Icon button (right side)
{
label: 'Eliminar',
icon: Trash2,
priority: 'secondary',
onClick: () => handleDelete(ingredient)
},
// Stock history action - Icon button (right side)
{
label: 'Historial',
icon: History,
priority: 'secondary',
onClick: () => handleShowStockHistory(ingredient)
},
// Batch management action
// View stock batches - Highlighted icon button (right side)
{
label: 'Ver Lotes',
icon: Package,
priority: 'secondary',
highlighted: true,
onClick: () => handleShowBatches(ingredient)
},
// Destructive action
{
label: 'Eliminar',
icon: Trash2,
priority: 'secondary',
destructive: true,
onClick: () => handleDelete(ingredient)
}
]}
/>
@@ -648,24 +689,14 @@ const InventoryPage: React.FC = () => {
{/* Empty State */}
{filteredItems.length === 0 && (
<div className="text-center py-12">
<Package className="mx-auto h-12 w-12 text-[var(--text-tertiary)] mb-4" />
<h3 className="text-lg font-medium text-[var(--text-primary)] mb-2">
No se encontraron artículos
</h3>
<p className="text-[var(--text-secondary)] mb-4">
Intenta ajustar la búsqueda o agregar un nuevo artículo al inventario
</p>
<Button
onClick={handleNewItem}
variant="primary"
size="md"
className="font-medium px-6 py-3 shadow-sm hover:shadow-md transition-all duration-200"
>
<Plus className="w-4 h-4 mr-2 flex-shrink-0" />
<span>Nuevo Artículo</span>
</Button>
</div>
<EmptyState
icon={Package}
title="No se encontraron artículos"
description="Intenta ajustar la búsqueda o agregar un nuevo artículo al inventario"
actionLabel="Nuevo Artículo"
actionIcon={Plus}
onAction={handleNewItem}
/>
)}
{/* Focused Action Modals */}
@@ -691,12 +722,23 @@ const InventoryPage: React.FC = () => {
throw new Error('Missing tenant ID or selected item');
}
// Validate we have actual data to update
if (!updatedData || Object.keys(updatedData).length === 0) {
console.error('InventoryPage: No data provided for ingredient update');
throw new Error('No data provided for update');
}
console.log('InventoryPage: Updating ingredient with data:', updatedData);
return updateIngredientMutation.mutateAsync({
tenantId,
ingredientId: selectedItem.id,
updateData: updatedData
});
}}
waitForRefetch={true}
isRefetching={isRefetchingIngredients}
onSaveComplete={handleIngredientSaveComplete}
/>
<StockHistoryModal
@@ -719,17 +761,36 @@ const InventoryPage: React.FC = () => {
ingredient={selectedItem}
batches={stockLotsData || []}
loading={stockLotsLoading}
tenantId={tenantId}
onAddBatch={() => {
setShowAddBatch(true);
}}
onEditBatch={async (batchId, updateData) => {
// TODO: Implement edit batch functionality
console.log('Edit batch:', batchId, updateData);
if (!tenantId) {
throw new Error('No tenant ID available');
}
// Validate we have actual data to update
if (!updateData || Object.keys(updateData).length === 0) {
console.error('InventoryPage: No data provided for batch update');
throw new Error('No data provided for update');
}
console.log('InventoryPage: Updating batch with data:', updateData);
return updateStockMutation.mutateAsync({
tenantId,
stockId: batchId,
updateData
});
}}
onMarkAsWaste={async (batchId) => {
// TODO: Implement mark as waste functionality
console.log('Mark as waste:', batchId);
}}
waitForRefetch={true}
isRefetching={isRefetchingBatches}
onSaveComplete={handleBatchSaveComplete}
/>
<DeleteIngredientModal
@@ -751,6 +812,9 @@ const InventoryPage: React.FC = () => {
}}
ingredient={selectedItem}
onAddStock={handleAddStockSubmit}
waitForRefetch={true}
isRefetching={isRefetchingBatches || isRefetchingIngredients}
onSaveComplete={handleBatchSaveComplete}
/>
</>
)}
@@ -758,4 +822,4 @@ const InventoryPage: React.FC = () => {
);
};
export default InventoryPage;
export default InventoryPage;

View File

@@ -1,7 +1,7 @@
import React, { useState, useMemo } from 'react';
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, StatsGrid, StatusCard, getStatusColor, SearchAndFilter, type FilterConfig } from '../../../../components/ui';
import { Button, StatsGrid, StatusCard, getStatusColor, SearchAndFilter, type FilterConfig, EmptyState } from '../../../../components/ui';
import { Badge } from '../../../../components/ui/Badge';
import { LoadingSpinner } from '../../../../components/ui';
import { PageHeader } from '../../../../components/layout';
@@ -164,22 +164,39 @@ const MaquinariaPage: React.FC = () => {
{
title: t('labels.total_equipment'),
value: equipmentStats.total,
variant: 'default' as const,
icon: Settings,
variant: 'default' as const
},
{
title: t('labels.operational'),
value: equipmentStats.operational,
icon: CheckCircle,
variant: 'success' as const,
subtitle: `${((equipmentStats.operational / equipmentStats.total) * 100).toFixed(1)}%`
icon: CheckCircle,
},
{
title: t('labels.warning'),
value: equipmentStats.warning,
variant: 'warning' as const,
icon: AlertTriangle,
},
{
title: t('labels.maintenance_required'),
value: equipmentStats.maintenance,
variant: 'info' as const,
icon: Wrench,
},
{
title: t('labels.down'),
value: equipmentStats.down,
variant: 'error' as const,
icon: AlertTriangle,
},
{
title: t('labels.active_alerts'),
value: equipmentStats.totalAlerts,
variant: equipmentStats.totalAlerts === 0 ? 'success' as const : 'error' as const,
icon: Bell,
variant: equipmentStats.totalAlerts === 0 ? 'success' as const : 'error' as const
}
},
];
const handleShowMaintenanceDetails = (equipment: Equipment) => {
@@ -345,24 +362,14 @@ const MaquinariaPage: React.FC = () => {
{/* Empty State */}
{filteredEquipment.length === 0 && (
<div className="text-center py-12">
<Settings className="mx-auto h-12 w-12 text-[var(--text-tertiary)] mb-4" />
<h3 className="text-lg font-medium text-[var(--text-primary)] mb-2">
{t('common:forms.no_results')}
</h3>
<p className="text-[var(--text-secondary)] mb-4">
{t('common:forms.empty_state')}
</p>
<Button
onClick={handleCreateEquipment}
variant="primary"
size="md"
className="font-medium px-4 sm:px-6 py-2 sm:py-3 shadow-sm hover:shadow-md transition-all duration-200"
>
<Plus className="w-3 h-3 sm:w-4 sm:h-4 mr-1 sm:mr-2 flex-shrink-0" />
<span className="text-sm sm:text-base">{t('actions.add_equipment')}</span>
</Button>
</div>
<EmptyState
icon={Settings}
title={t('common:forms.no_results')}
description={t('common:forms.empty_state')}
actionLabel={t('actions.add_equipment')}
actionIcon={Plus}
onAction={handleCreateEquipment}
/>
)}
{/* Maintenance Details Modal */}
@@ -558,4 +565,4 @@ const MaquinariaPage: React.FC = () => {
);
};
export default MaquinariaPage;
export default MaquinariaPage;

View File

@@ -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, Badge, StatsGrid, StatusCard, getStatusColor, EditViewModal, Tabs, SearchAndFilter, type FilterConfig } from '../../../../components/ui';
import { Button, Badge, StatsGrid, StatusCard, getStatusColor, EditViewModal, Tabs, SearchAndFilter, type FilterConfig, EmptyState } from '../../../../components/ui';
import { formatters } from '../../../../components/ui/Stats/StatsPresets';
import { PageHeader } from '../../../../components/layout';
import {
@@ -8,6 +8,8 @@ import {
OrderResponse,
CustomerResponse,
OrderCreate,
CustomerCreate,
CustomerUpdate,
PaymentStatus,
DeliveryMethod,
PaymentMethod,
@@ -19,7 +21,7 @@ import {
CustomerType,
CustomerSegment
} from '../../../../api/types/orders';
import { useOrders, useCustomers, useOrdersDashboard, useCreateOrder, useCreateCustomer } from '../../../../api/hooks/orders';
import { useOrders, useCustomers, useOrdersDashboard, useCreateOrder, useCreateCustomer, useUpdateCustomer } from '../../../../api/hooks/orders';
import { useCurrentTenant } from '../../../../stores/tenant.store';
import { useAuthUser } from '../../../../stores/auth.store';
import { OrderFormModal } from '../../../../components/domain/orders';
@@ -75,6 +77,7 @@ const OrdersPage: React.FC = () => {
// Mutations
const createOrderMutation = useCreateOrder();
const createCustomerMutation = useCreateCustomer();
const updateCustomerMutation = useUpdateCustomer();
const orders = ordersData || [];
const customers = customersData || [];
@@ -206,12 +209,12 @@ const OrdersPage: React.FC = () => {
variant: 'success' as const,
icon: Users,
},
{
title: 'Tasa de Repetición',
value: `${(orderStats.repeat_customers_rate * 100).toFixed(1)}%`,
variant: 'info' as const,
icon: Users,
},
{
title: 'Tasa de Repetición',
value: `${Number(orderStats.repeat_customers_rate).toFixed(1)}%`,
variant: 'info' as const,
icon: Users,
},
{
title: 'Clientes Activos',
value: customers.filter(c => c.is_active).length,
@@ -398,17 +401,6 @@ const OrdersPage: React.FC = () => {
setModalMode('view');
setShowForm(true);
}
},
{
label: 'Editar',
icon: Edit,
priority: 'secondary',
onClick: () => {
setSelectedOrder(order);
setIsCreating(false);
setModalMode('edit');
setShowForm(true);
}
}
]}
/>
@@ -455,17 +447,6 @@ const OrdersPage: React.FC = () => {
setModalMode('view');
setShowForm(true);
}
},
{
label: 'Editar',
icon: Edit,
priority: 'secondary',
onClick: () => {
setSelectedCustomer(customer);
setIsCreating(false);
setModalMode('edit');
setShowForm(true);
}
}
]}
/>
@@ -476,31 +457,24 @@ const OrdersPage: React.FC = () => {
{/* Empty State */}
{activeTab === 'orders' && filteredOrders.length === 0 && (
<div className="text-center py-12">
<Package className="mx-auto h-12 w-12 text-[var(--text-tertiary)] mb-4" />
<h3 className="text-lg font-medium text-[var(--text-primary)] mb-2">
No se encontraron pedidos
</h3>
<p className="text-[var(--text-secondary)] mb-4">
Intenta ajustar la búsqueda o crear un nuevo pedido
</p>
<Button onClick={() => setShowNewOrderForm(true)}>
<Plus className="w-4 h-4 mr-2" />
Nuevo Pedido
</Button>
</div>
<EmptyState
icon={Package}
title="No se encontraron pedidos"
description="Intenta ajustar la búsqueda o crear un nuevo pedido"
actionLabel="Nuevo Pedido"
actionIcon={Plus}
onAction={() => setShowNewOrderForm(true)}
/>
)}
{activeTab === 'customers' && filteredCustomers.length === 0 && (
<div className="text-center py-12">
<Users className="mx-auto h-12 w-12 text-[var(--text-tertiary)] mb-4" />
<h3 className="text-lg font-medium text-[var(--text-primary)] mb-2">
No se encontraron clientes
</h3>
<p className="text-[var(--text-secondary)] mb-4">
Intenta ajustar la búsqueda o crear un nuevo cliente
</p>
<Button onClick={() => {
<EmptyState
icon={Users}
title="No se encontraron clientes"
description="Intenta ajustar la búsqueda o crear un nuevo cliente"
actionLabel="Nuevo Cliente"
actionIcon={Plus}
onAction={() => {
setSelectedCustomer({
name: '',
business_name: '',
@@ -518,11 +492,8 @@ const OrdersPage: React.FC = () => {
setIsCreating(true);
setModalMode('edit');
setShowForm(true);
}}>
<Plus className="w-4 h-4 mr-2" />
Nuevo Cliente
</Button>
</div>
}}
/>
)}
{/* Order Details Modal */}
@@ -663,7 +634,11 @@ const OrdersPage: React.FC = () => {
sections={sections}
showDefaultActions={true}
onSave={async () => {
// TODO: Implement order update functionality
// Note: The backend only has updateOrderStatus, not a general update endpoint
// For now, orders can be updated via status changes using useUpdateOrderStatus
console.log('Saving order:', selectedOrder);
console.warn('Order update not yet implemented - only status updates are supported via useUpdateOrderStatus');
}}
onFieldChange={(sectionIndex, fieldIndex, value) => {
const newOrder = { ...selectedOrder };
@@ -739,6 +714,13 @@ const OrdersPage: React.FC = () => {
value: selectedCustomer.city || '',
type: 'text',
editable: true
},
{
label: 'País',
value: selectedCustomer.country || 'España',
type: 'text',
editable: isCreating,
highlight: false
}
]
},
@@ -829,7 +811,69 @@ const OrdersPage: React.FC = () => {
sections={sections}
showDefaultActions={true}
onSave={async () => {
console.log('Saving customer:', selectedCustomer);
if (!selectedCustomer || !tenantId) {
console.error('Missing required data for customer save');
return;
}
try {
if (isCreating) {
// Create new customer
const customerData: CustomerCreate = {
tenant_id: tenantId,
customer_code: selectedCustomer.customer_code || `CUST-${Date.now()}`,
name: selectedCustomer.name,
business_name: selectedCustomer.business_name,
customer_type: selectedCustomer.customer_type,
email: selectedCustomer.email,
phone: selectedCustomer.phone,
city: selectedCustomer.city,
country: selectedCustomer.country || 'España',
is_active: selectedCustomer.is_active,
preferred_delivery_method: selectedCustomer.preferred_delivery_method,
payment_terms: selectedCustomer.payment_terms,
discount_percentage: selectedCustomer.discount_percentage,
customer_segment: selectedCustomer.customer_segment,
priority_level: selectedCustomer.priority_level,
special_instructions: selectedCustomer.special_instructions
};
await createCustomerMutation.mutateAsync(customerData);
console.log('Customer created successfully');
} else {
// Update existing customer
const updateData: CustomerUpdate = {
name: selectedCustomer.name,
business_name: selectedCustomer.business_name,
customer_type: selectedCustomer.customer_type,
email: selectedCustomer.email,
phone: selectedCustomer.phone,
city: selectedCustomer.city,
preferred_delivery_method: selectedCustomer.preferred_delivery_method,
payment_terms: selectedCustomer.payment_terms,
discount_percentage: selectedCustomer.discount_percentage,
customer_segment: selectedCustomer.customer_segment,
is_active: selectedCustomer.is_active,
special_instructions: selectedCustomer.special_instructions
};
await updateCustomerMutation.mutateAsync({
tenantId,
customerId: selectedCustomer.id!,
data: updateData
});
console.log('Customer updated successfully');
}
// Close modal and reset state
setShowForm(false);
setSelectedCustomer(null);
setIsCreating(false);
setModalMode('view');
} catch (error) {
console.error('Error saving customer:', error);
throw error; // Let the modal show the error
}
}}
onFieldChange={(sectionIndex, fieldIndex, value) => {
const newCustomer = { ...selectedCustomer };
@@ -843,6 +887,7 @@ const OrdersPage: React.FC = () => {
'Email': 'email',
'Teléfono': 'phone',
'Ciudad': 'city',
'País': 'country',
'Código de Cliente': 'customer_code',
'Método de Entrega Preferido': 'preferred_delivery_method',
'Términos de Pago': 'payment_terms',
@@ -880,4 +925,4 @@ const OrdersPage: React.FC = () => {
);
};
export default OrdersPage;
export default OrdersPage;

View File

@@ -1,6 +1,6 @@
import React, { useState, useMemo } from 'react';
import { Plus, ShoppingCart, Euro, Calendar, CheckCircle, AlertCircle, Package, Eye, X, Send, Building2, Play, FileText, Star, TrendingUp, TrendingDown, Minus } from 'lucide-react';
import { Button, Card, StatsGrid, StatusCard, getStatusColor, EditViewModal, SearchAndFilter, Input, type FilterConfig } from '../../../../components/ui';
import { Button, Card, StatsGrid, StatusCard, getStatusColor, EditViewModal, SearchAndFilter, Input, type FilterConfig, EmptyState } from '../../../../components/ui';
import { formatters } from '../../../../components/ui/Stats/StatsPresets';
import { PageHeader } from '../../../../components/layout';
import { CreatePurchaseOrderModal } from '../../../../components/domain/procurement/CreatePurchaseOrderModal';
@@ -799,19 +799,14 @@ const ProcurementPage: React.FC = () => {
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-primary-600"></div>
</div>
) : filteredPOs.length === 0 ? (
<Card className="text-center py-12">
<ShoppingCart className="h-16 w-16 mx-auto mb-4 text-gray-400" />
<h3 className="text-lg font-medium text-gray-900 mb-2">
No hay órdenes de compra
</h3>
<p className="text-gray-500 mb-4">
Comienza creando una nueva orden de compra
</p>
<Button onClick={() => setShowCreatePOModal(true)}>
<Plus className="h-4 w-4 mr-2" />
Nueva Orden
</Button>
</Card>
<EmptyState
icon={ShoppingCart}
title="No hay órdenes de compra"
description="Comienza creando una nueva orden de compra"
actionLabel="Nueva Orden"
actionIcon={Plus}
onAction={() => setShowCreatePOModal(true)}
/>
) : (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{filteredPOs.map((po) => {

View File

@@ -1,6 +1,6 @@
import React, { useState, useMemo } from 'react';
import { Plus, Clock, AlertCircle, CheckCircle, Timer, ChefHat, Eye, Edit, Package, PlusCircle, Play } from 'lucide-react';
import { Button, StatsGrid, EditViewModal, Toggle, SearchAndFilter, type FilterConfig } from '../../../../components/ui';
import { Button, StatsGrid, EditViewModal, Toggle, SearchAndFilter, type FilterConfig, EmptyState } from '../../../../components/ui';
import { statusColors } from '../../../../styles/colors';
import { formatters } from '../../../../components/ui/Stats/StatsPresets';
import { LoadingSpinner } from '../../../../components/ui';
@@ -471,22 +471,18 @@ const ProductionPage: React.FC = () => {
{/* Empty State */}
{filteredBatches.length === 0 && (
<div className="text-center py-12">
<ChefHat className="mx-auto h-12 w-12 text-[var(--text-tertiary)] mb-4" />
<h3 className="text-lg font-medium text-[var(--text-primary)] mb-2">
No se encontraron lotes de producción
</h3>
<p className="text-[var(--text-secondary)] mb-4">
{batches.length === 0
<EmptyState
icon={ChefHat}
title="No se encontraron lotes de producción"
description={
batches.length === 0
? 'No hay lotes de producción activos. Crea el primer lote para comenzar.'
: 'Intenta ajustar la búsqueda o crear un nuevo lote de producción'
}
</p>
<Button onClick={() => setShowCreateModal(true)}>
<Plus className="w-4 h-4 mr-2" />
Nueva Orden de Producción
</Button>
</div>
}
actionLabel="Nueva Orden de Producción"
actionIcon={Plus}
onAction={() => setShowCreateModal(true)}
/>
)}
</>

View File

@@ -1,16 +1,143 @@
import React, { useState, useMemo } from 'react';
import { Plus, Star, Clock, Euro, Package, Eye, Edit, ChefHat, Timer, CheckCircle } from 'lucide-react';
import { Button, StatsGrid, StatusCard, getStatusColor, EditViewModal, SearchAndFilter, type FilterConfig } from '../../../../components/ui';
import { Plus, Star, Clock, Euro, Package, Eye, Edit, ChefHat, Timer, CheckCircle, Trash2, Settings, FileText } from 'lucide-react';
import { Button, StatsGrid, StatusCard, getStatusColor, EditViewModal, SearchAndFilter, type FilterConfig, EmptyState } from '../../../../components/ui';
import { LoadingSpinner } from '../../../../components/ui';
import { QualityPromptDialog } from '../../../../components/ui/QualityPromptDialog';
import { formatters } from '../../../../components/ui/Stats/StatsPresets';
import { PageHeader } from '../../../../components/layout';
import { useRecipes, useRecipeStatistics, useCreateRecipe, useUpdateRecipe, useDeleteRecipe } from '../../../../api/hooks/recipes';
import { useRecipes, useRecipeStatistics, useCreateRecipe, useUpdateRecipe, useDeleteRecipe, useArchiveRecipe } from '../../../../api/hooks/recipes';
import { recipesService } from '../../../../api/services/recipes';
import { useCurrentTenant } from '../../../../stores/tenant.store';
import type { RecipeResponse, RecipeCreate, MeasurementUnit } from '../../../../api/types/recipes';
import type { RecipeResponse, RecipeCreate } from '../../../../api/types/recipes';
import { MeasurementUnit } from '../../../../api/types/recipes';
import { useQualityTemplatesForRecipe } from '../../../../api/hooks/qualityTemplates';
import { useIngredients } from '../../../../api/hooks/inventory';
import { ProcessStage, type RecipeQualityConfiguration } from '../../../../api/types/qualityTemplates';
import { CreateRecipeModal } from '../../../../components/domain/recipes';
import { CreateRecipeModal, DeleteRecipeModal } from '../../../../components/domain/recipes';
import { QualityCheckConfigurationModal } from '../../../../components/domain/recipes/QualityCheckConfigurationModal';
import { useQueryClient } from '@tanstack/react-query';
import type { RecipeIngredientResponse } from '../../../../api/types/recipes';
// Ingredients Edit Component for EditViewModal
const IngredientsEditComponent: React.FC<{
value: RecipeIngredientResponse[];
onChange: (value: RecipeIngredientResponse[]) => void;
availableIngredients: Array<{value: string; label: string}>;
unitOptions: Array<{value: MeasurementUnit; label: string}>;
}> = ({ value, onChange, availableIngredients, unitOptions }) => {
const ingredientsArray = Array.isArray(value) ? value : [];
const addIngredient = () => {
const newIngredient: Partial<RecipeIngredientResponse> = {
id: `temp-${Date.now()}`, // Temporary ID for new ingredients
ingredient_id: '',
quantity: 1,
unit: MeasurementUnit.GRAMS,
ingredient_order: ingredientsArray.length + 1,
is_optional: false
};
onChange([...ingredientsArray, newIngredient as RecipeIngredientResponse]);
};
const removeIngredient = (index: number) => {
onChange(ingredientsArray.filter((_, i) => i !== index));
};
const updateIngredient = (index: number, field: keyof RecipeIngredientResponse, newValue: any) => {
const updated = ingredientsArray.map((ingredient, i) =>
i === index ? { ...ingredient, [field]: newValue } : ingredient
);
onChange(updated);
};
return (
<div className="space-y-4">
<div className="flex items-center justify-between">
<h4 className="text-sm font-medium text-[var(--text-primary)]">Lista de Ingredientes</h4>
<button
type="button"
onClick={addIngredient}
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" />
Agregar
</button>
</div>
<div className="space-y-3 max-h-96 overflow-y-auto">
{ingredientsArray.map((ingredient, index) => (
<div key={ingredient.id || index} className="p-3 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)]">Ingrediente #{index + 1}</span>
<button
type="button"
onClick={() => removeIngredient(index)}
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-3 gap-3">
<div className="sm:col-span-2">
<label className="block text-xs font-medium text-[var(--text-secondary)] mb-1">Ingrediente</label>
<select
value={ingredient.ingredient_id}
onChange={(e) => updateIngredient(index, 'ingredient_id', e.target.value)}
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 => (
<option key={ing.value} value={ing.value}>{ing.label}</option>
))}
</select>
</div>
<div>
<label className="block text-xs font-medium text-[var(--text-secondary)] mb-1">Cantidad</label>
<input
type="number"
value={ingredient.quantity}
onChange={(e) => updateIngredient(index, 'quantity', parseFloat(e.target.value) || 0)}
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"
min="0"
step="0.1"
/>
</div>
</div>
<div className="grid grid-cols-2 gap-3">
<div>
<label className="block text-xs font-medium text-[var(--text-secondary)] mb-1">Unidad</label>
<select
value={ingredient.unit}
onChange={(e) => updateIngredient(index, 'unit', e.target.value as MeasurementUnit)}
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"
>
{unitOptions.map(unit => (
<option key={unit.value} value={unit.value}>{unit.label}</option>
))}
</select>
</div>
<div>
<label className="block text-xs font-medium text-[var(--text-secondary)] mb-1 flex items-center gap-1">
<input
type="checkbox"
checked={ingredient.is_optional}
onChange={(e) => updateIngredient(index, 'is_optional', e.target.checked)}
className="rounded border-[var(--border-secondary)] text-[var(--color-primary)] focus:ring-[var(--color-primary)]"
/>
Opcional
</label>
</div>
</div>
</div>
))}
</div>
</div>
);
};
const RecipesPage: React.FC = () => {
const [searchTerm, setSearchTerm] = useState('');
@@ -22,21 +149,29 @@ const RecipesPage: React.FC = () => {
const [selectedRecipe, setSelectedRecipe] = useState<RecipeResponse | null>(null);
const [showCreateModal, setShowCreateModal] = useState(false);
const [showQualityConfigModal, setShowQualityConfigModal] = useState(false);
const [showQualityPrompt, setShowQualityPrompt] = useState(false);
const [showDeleteModal, setShowDeleteModal] = useState(false);
const [recipeToDelete, setRecipeToDelete] = useState<RecipeResponse | null>(null);
const [newlyCreatedRecipe, setNewlyCreatedRecipe] = useState<RecipeResponse | null>(null);
const [editedRecipe, setEditedRecipe] = useState<Partial<RecipeResponse>>({});
const [editedIngredients, setEditedIngredients] = useState<RecipeIngredientResponse[]>([]);
const currentTenant = useCurrentTenant();
const tenantId = currentTenant?.id || '';
const queryClient = useQueryClient();
// Mutations
const createRecipeMutation = useCreateRecipe(tenantId);
const updateRecipeMutation = useUpdateRecipe(tenantId);
const deleteRecipeMutation = useDeleteRecipe(tenantId);
const archiveRecipeMutation = useArchiveRecipe(tenantId);
// API Data
const {
data: recipes = [],
isLoading: recipesLoading,
error: recipesError
error: recipesError,
isRefetching: isRefetchingRecipes
} = useRecipes(tenantId, { search_term: searchTerm || undefined });
const {
@@ -44,6 +179,42 @@ const RecipesPage: React.FC = () => {
isLoading: statisticsLoading
} = useRecipeStatistics(tenantId);
// Fetch inventory items for ingredient name lookup
const {
data: inventoryItems = [],
isLoading: inventoryLoading
} = useIngredients(tenantId, {});
// Create ingredient lookup map (UUID -> name)
const ingredientLookup = useMemo(() => {
const map: Record<string, string> = {};
inventoryItems.forEach(item => {
map[item.id] = item.name;
});
return map;
}, [inventoryItems]);
// Available ingredients for editing
const availableIngredients = useMemo(() =>
(inventoryItems || [])
.filter(item => item.product_type !== 'finished_product')
.map(ingredient => ({
value: ingredient.id,
label: `${ingredient.name} (${ingredient.category || 'Sin categoría'})`
})),
[inventoryItems]
);
// Unit options for ingredients
const unitOptions = useMemo(() => [
{ value: MeasurementUnit.GRAMS, label: 'g' },
{ value: MeasurementUnit.KILOGRAMS, label: 'kg' },
{ value: MeasurementUnit.MILLILITERS, label: 'ml' },
{ value: MeasurementUnit.LITERS, label: 'L' },
{ value: MeasurementUnit.UNITS, label: 'unidades' },
{ value: MeasurementUnit.TABLESPOONS, label: 'cucharadas' },
{ value: MeasurementUnit.TEASPOONS, label: 'cucharaditas' },
], []);
const getRecipeStatusConfig = (recipe: RecipeResponse) => {
const category = recipe.category || 'other';
@@ -106,6 +277,33 @@ const RecipesPage: React.FC = () => {
return `Configurado para ${configuredStages.length} etapas`;
};
const getQualityIndicator = (recipe: RecipeResponse) => {
if (!recipe.quality_check_configuration || !recipe.quality_check_configuration.stages) {
return '❌ Sin configurar';
}
const stages = recipe.quality_check_configuration.stages;
const configuredStages = Object.keys(stages).filter(
stage => stages[stage]?.template_ids?.length > 0
);
const totalTemplates = Object.values(stages).reduce(
(sum, stage) => sum + (stage.template_ids?.length || 0),
0
);
if (configuredStages.length === 0) {
return '❌ Sin configurar';
}
const totalStages = Object.keys(ProcessStage).length;
if (configuredStages.length < totalStages / 2) {
return `⚠️ Parcial (${configuredStages.length}/${totalStages} etapas)`;
}
return `✅ Configurado (${totalTemplates} controles)`;
};
const filteredRecipes = useMemo(() => {
let filtered = recipes;
@@ -197,11 +395,30 @@ const RecipesPage: React.FC = () => {
},
];
// Handle opening a recipe (fetch full details with ingredients)
const handleOpenRecipe = async (recipeId: string) => {
try {
// Fetch full recipe details including ingredients
const fullRecipe = await recipesService.getRecipe(tenantId, recipeId);
setSelectedRecipe(fullRecipe);
setModalMode('view');
setShowForm(true);
} catch (error) {
console.error('Error fetching recipe details:', error);
}
};
// Handle creating a new recipe
const handleCreateRecipe = async (recipeData: RecipeCreate) => {
try {
await createRecipeMutation.mutateAsync(recipeData);
const newRecipe = await createRecipeMutation.mutateAsync(recipeData);
setShowCreateModal(false);
// Fetch full recipe details and show quality prompt
const fullRecipe = await recipesService.getRecipe(tenantId, newRecipe.id);
setNewlyCreatedRecipe(fullRecipe);
setShowQualityPrompt(true);
console.log('Recipe created successfully');
} catch (error) {
console.error('Error creating recipe:', error);
@@ -209,20 +426,71 @@ const RecipesPage: React.FC = () => {
}
};
// Handle quality prompt - configure now
const handleConfigureQualityNow = async () => {
setShowQualityPrompt(false);
if (newlyCreatedRecipe) {
setSelectedRecipe(newlyCreatedRecipe);
setModalMode('edit');
setShowQualityConfigModal(true);
setShowForm(true);
}
};
// Handle quality prompt - configure later
const handleConfigureQualityLater = () => {
setShowQualityPrompt(false);
setNewlyCreatedRecipe(null);
};
// Handle field changes in edit mode
const handleFieldChange = (sectionIndex: number, fieldIndex: number, value: string | number | boolean) => {
if (!selectedRecipe) return;
const fieldMap: Record<string, string> = {
// Información Básica
'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',
'Dificultad': 'difficulty_level',
'Estado': 'status',
'Rendimiento': 'yield_quantity',
'Unidad de rendimiento': 'yield_unit',
'Porciones': 'serves_count',
// Tiempos
'Tiempo de preparación': 'prep_time_minutes',
'Tiempo de cocción': 'cook_time_minutes',
'Tiempo de reposo': 'rest_time_minutes',
// Configuración Especial
'Receta estacional': 'is_seasonal',
'Mes de inicio': 'season_start_month',
'Mes de fin': 'season_end_month',
'Receta estrella': 'is_signature_item',
// Configuración de Producción
'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': 'optimal_production_temperature',
'Humedad óptima': 'optimal_humidity',
// Análisis Financiero
'Costo estimado por unidad': 'estimated_cost_per_unit',
'Precio de venta sugerido': 'suggested_selling_price',
'Margen objetivo': 'target_margin_percentage'
'Margen objetivo': 'target_margin_percentage',
// Instrucciones y Calidad
'Notas de preparación': 'preparation_notes',
'Instrucciones de almacenamiento': 'storage_instructions',
'Estándares de calidad': 'quality_standards',
'Instrucciones de preparación': 'instructions',
'Puntos de control de calidad': 'quality_check_points',
'Problemas comunes y soluciones': 'common_issues',
// Información Nutricional
'Información de alérgenos': 'allergen_info',
'Etiquetas dietéticas': 'dietary_tags',
'Información nutricional': 'nutritional_info'
};
const sections = getModalSections();
@@ -237,12 +505,19 @@ const RecipesPage: React.FC = () => {
}
};
// Refetch callback for wait-for-refetch pattern
const handleRecipeSaveComplete = async () => {
if (!tenantId) return;
// Invalidate recipes query to trigger refetch
await queryClient.invalidateQueries(['recipes', tenantId]);
};
// Handle saving edited recipe
const handleSaveRecipe = async () => {
if (!selectedRecipe || !Object.keys(editedRecipe).length) return;
if (!selectedRecipe || (!Object.keys(editedRecipe).length && editedIngredients.length === 0)) return;
try {
const updateData = {
const updateData: any = {
...editedRecipe,
// Convert time fields from formatted strings back to numbers if needed
prep_time_minutes: typeof editedRecipe.prep_time_minutes === 'string'
@@ -259,13 +534,33 @@ const RecipesPage: React.FC = () => {
: editedRecipe.difficulty_level,
};
// Include ingredient updates if they were edited
if (editedIngredients.length > 0) {
updateData.ingredients = editedIngredients.map((ing, index) => ({
ingredient_id: ing.ingredient_id,
quantity: ing.quantity,
unit: ing.unit,
alternative_quantity: ing.alternative_quantity || null,
alternative_unit: ing.alternative_unit || null,
preparation_method: ing.preparation_method || null,
ingredient_notes: ing.ingredient_notes || null,
is_optional: ing.is_optional || false,
ingredient_order: index + 1, // Maintain order based on array position
ingredient_group: ing.ingredient_group || null,
substitution_options: ing.substitution_options || null,
substitution_ratio: ing.substitution_ratio || null,
}));
}
await updateRecipeMutation.mutateAsync({
id: selectedRecipe.id,
data: updateData
});
setModalMode('view');
// Note: Don't manually switch mode here - EditViewModal will handle it
// after refetch completes if waitForRefetch is enabled
setEditedRecipe({});
setEditedIngredients([]);
console.log('Recipe updated successfully');
} catch (error) {
console.error('Error updating recipe:', error);
@@ -297,6 +592,28 @@ const RecipesPage: React.FC = () => {
}
};
// Handle soft delete (archive)
const handleSoftDelete = async (recipeId: string) => {
try {
await archiveRecipeMutation.mutateAsync(recipeId);
console.log('Recipe archived successfully');
} catch (error) {
console.error('Error archiving recipe:', error);
throw error;
}
};
// Handle hard delete (permanent)
const handleHardDelete = async (recipeId: string) => {
try {
await deleteRecipeMutation.mutateAsync(recipeId);
console.log('Recipe deleted successfully');
} catch (error) {
console.error('Error deleting recipe:', error);
throw error;
}
};
// Get current value for field (edited value or original)
const getFieldValue = (originalValue: any, fieldKey: string) => {
return editedRecipe[fieldKey as keyof RecipeResponse] !== undefined
@@ -304,6 +621,23 @@ const RecipesPage: React.FC = () => {
: originalValue;
};
// Helper to display JSON fields in a readable format
const formatJsonField = (jsonData: any): string => {
if (!jsonData) return 'No especificado';
if (typeof jsonData === 'string') return jsonData;
if (typeof jsonData === 'object') {
// Extract common patterns
if (jsonData.steps) return jsonData.steps;
if (jsonData.checkpoints) return jsonData.checkpoints;
if (jsonData.issues) return jsonData.issues;
if (jsonData.allergens) return jsonData.allergens.join(', ');
if (jsonData.tags) return jsonData.tags.join(', ');
if (jsonData.info) return jsonData.info;
return JSON.stringify(jsonData, null, 2);
}
return String(jsonData);
};
// Get modal sections with editable fields
const getModalSections = () => {
if (!selectedRecipe) return [];
@@ -313,6 +647,32 @@ const RecipesPage: React.FC = () => {
title: 'Información Básica',
icon: ChefHat,
fields: [
{
label: 'Nombre',
value: getFieldValue(selectedRecipe.name, 'name'),
type: modalMode === 'edit' ? 'text' : 'text',
editable: modalMode === 'edit',
span: 2
},
{
label: 'Código de receta',
value: getFieldValue(selectedRecipe.recipe_code || 'Sin código', 'recipe_code'),
type: modalMode === 'edit' ? 'text' : 'text',
editable: modalMode === 'edit'
},
{
label: 'Versión',
value: getFieldValue(selectedRecipe.version, 'version'),
type: modalMode === 'edit' ? 'text' : 'text',
editable: modalMode === 'edit'
},
{
label: 'Descripción',
value: getFieldValue(selectedRecipe.description || 'Sin descripción', 'description'),
type: modalMode === 'edit' ? 'textarea' : 'text',
editable: modalMode === 'edit',
span: 2
},
{
label: 'Categoría',
value: getFieldValue(selectedRecipe.category || 'Sin categoría', 'category'),
@@ -326,6 +686,12 @@ const RecipesPage: React.FC = () => {
] : undefined,
editable: true
},
{
label: 'Tipo de cocina',
value: getFieldValue(selectedRecipe.cuisine_type || 'No especificado', 'cuisine_type'),
type: modalMode === 'edit' ? 'text' : 'text',
editable: modalMode === 'edit'
},
{
label: 'Dificultad',
value: modalMode === 'edit'
@@ -348,16 +714,28 @@ const RecipesPage: React.FC = () => {
options: modalMode === 'edit' ? [
{ value: 'draft', label: 'Borrador' },
{ value: 'active', label: 'Activo' },
{ value: 'archived', label: 'Archivado' }
{ value: 'testing', label: 'Testing' },
{ value: 'archived', label: 'Archivado' },
{ value: 'discontinued', label: 'Discontinuado' }
] : undefined,
highlight: selectedRecipe.status === 'active',
editable: true
},
{
label: 'Rendimiento',
value: modalMode === 'edit'
? getFieldValue(selectedRecipe.yield_quantity, 'yield_quantity')
: `${getFieldValue(selectedRecipe.yield_quantity, 'yield_quantity')} ${selectedRecipe.yield_unit}`,
value: getFieldValue(selectedRecipe.yield_quantity, 'yield_quantity'),
type: modalMode === 'edit' ? 'number' : 'text',
editable: modalMode === 'edit'
},
{
label: 'Unidad de rendimiento',
value: getFieldValue(selectedRecipe.yield_unit, 'yield_unit'),
type: modalMode === 'edit' ? 'text' : 'text',
editable: modalMode === 'edit'
},
{
label: 'Porciones',
value: getFieldValue(selectedRecipe.serves_count || 1, 'serves_count'),
type: modalMode === 'edit' ? 'number' : 'text',
editable: modalMode === 'edit'
}
@@ -385,6 +763,15 @@ const RecipesPage: React.FC = () => {
placeholder: modalMode === 'edit' ? 'minutos' : undefined,
editable: modalMode === 'edit'
},
{
label: 'Tiempo de reposo',
value: modalMode === 'edit'
? getFieldValue(selectedRecipe.rest_time_minutes || 0, 'rest_time_minutes')
: formatTime(getFieldValue(selectedRecipe.rest_time_minutes || 0, 'rest_time_minutes')),
type: modalMode === 'edit' ? 'number' : 'text',
placeholder: modalMode === 'edit' ? 'minutos' : undefined,
editable: modalMode === 'edit'
},
{
label: 'Tiempo total',
value: selectedRecipe.total_time_minutes ? formatTime(selectedRecipe.total_time_minutes) : 'No especificado',
@@ -419,16 +806,184 @@ const RecipesPage: React.FC = () => {
}
]
},
{
title: 'Configuración Especial',
icon: Star,
fields: [
{
label: 'Receta estacional',
value: modalMode === 'edit'
? getFieldValue(selectedRecipe.is_seasonal, 'is_seasonal')
: (selectedRecipe.is_seasonal ? 'Sí' : 'No'),
type: modalMode === 'edit' ? 'select' : 'text',
options: modalMode === 'edit' ? [
{ value: false, label: 'No' },
{ value: true, label: 'Sí' }
] : undefined,
editable: modalMode === 'edit'
},
{
label: 'Mes de inicio',
value: getFieldValue(selectedRecipe.season_start_month || 'No especificado', 'season_start_month'),
type: modalMode === 'edit' ? 'number' : 'text',
editable: modalMode === 'edit'
},
{
label: 'Mes de fin',
value: getFieldValue(selectedRecipe.season_end_month || 'No especificado', 'season_end_month'),
type: modalMode === 'edit' ? 'number' : 'text',
editable: modalMode === 'edit'
},
{
label: 'Receta estrella',
value: modalMode === 'edit'
? getFieldValue(selectedRecipe.is_signature_item, 'is_signature_item')
: (selectedRecipe.is_signature_item ? 'Sí' : 'No'),
type: modalMode === 'edit' ? 'select' : 'text',
options: modalMode === 'edit' ? [
{ value: false, label: 'No' },
{ value: true, label: 'Sí' }
] : undefined,
editable: modalMode === 'edit',
highlight: selectedRecipe.is_signature_item
}
]
},
{
title: 'Configuración de Producción',
icon: Settings,
fields: [
{
label: 'Multiplicador de lote',
value: getFieldValue(selectedRecipe.batch_size_multiplier || 1.0, 'batch_size_multiplier'),
type: modalMode === 'edit' ? 'number' : 'text',
editable: modalMode === 'edit'
},
{
label: 'Tamaño mínimo de lote',
value: getFieldValue(selectedRecipe.minimum_batch_size || 'No especificado', 'minimum_batch_size'),
type: modalMode === 'edit' ? 'number' : 'text',
editable: modalMode === 'edit'
},
{
label: 'Tamaño máximo de lote',
value: getFieldValue(selectedRecipe.maximum_batch_size || 'No especificado', 'maximum_batch_size'),
type: modalMode === 'edit' ? 'number' : 'text',
editable: modalMode === 'edit'
},
{
label: 'Temperatura óptima',
value: modalMode === 'edit'
? getFieldValue(selectedRecipe.optimal_production_temperature || '', 'optimal_production_temperature')
: `${getFieldValue(selectedRecipe.optimal_production_temperature || 'No especificado', 'optimal_production_temperature')}${selectedRecipe.optimal_production_temperature ? '°C' : ''}`,
type: modalMode === 'edit' ? 'number' : 'text',
editable: modalMode === 'edit'
},
{
label: 'Humedad óptima',
value: modalMode === 'edit'
? getFieldValue(selectedRecipe.optimal_humidity || '', 'optimal_humidity')
: `${getFieldValue(selectedRecipe.optimal_humidity || 'No especificado', 'optimal_humidity')}${selectedRecipe.optimal_humidity ? '%' : ''}`,
type: modalMode === 'edit' ? 'number' : 'text',
editable: modalMode === 'edit'
}
]
},
{
title: 'Instrucciones',
icon: FileText,
fields: [
{
label: 'Notas de preparación',
value: getFieldValue(selectedRecipe.preparation_notes || 'No especificado', 'preparation_notes'),
type: modalMode === 'edit' ? 'textarea' : 'text',
editable: modalMode === 'edit',
span: 2
},
{
label: 'Instrucciones de almacenamiento',
value: getFieldValue(selectedRecipe.storage_instructions || 'No especificado', 'storage_instructions'),
type: modalMode === 'edit' ? 'textarea' : 'text',
editable: modalMode === 'edit',
span: 2
},
{
label: 'Instrucciones de preparación',
value: modalMode === 'edit'
? formatJsonField(getFieldValue(selectedRecipe.instructions, 'instructions'))
: formatJsonField(selectedRecipe.instructions),
type: modalMode === 'edit' ? 'textarea' : 'text',
editable: modalMode === 'edit',
span: 2
}
]
},
{
title: 'Información Nutricional',
icon: Package,
fields: [
{
label: 'Información de alérgenos',
value: modalMode === 'edit'
? formatJsonField(getFieldValue(selectedRecipe.allergen_info, 'allergen_info'))
: formatJsonField(selectedRecipe.allergen_info),
type: modalMode === 'edit' ? 'textarea' : 'text',
editable: modalMode === 'edit',
span: 2
},
{
label: 'Etiquetas dietéticas',
value: modalMode === 'edit'
? formatJsonField(getFieldValue(selectedRecipe.dietary_tags, 'dietary_tags'))
: formatJsonField(selectedRecipe.dietary_tags),
type: modalMode === 'edit' ? 'textarea' : 'text',
editable: modalMode === 'edit',
span: 2
},
{
label: 'Información nutricional',
value: modalMode === 'edit'
? formatJsonField(getFieldValue(selectedRecipe.nutritional_info, 'nutritional_info'))
: formatJsonField(selectedRecipe.nutritional_info),
type: modalMode === 'edit' ? 'textarea' : 'text',
editable: modalMode === 'edit',
span: 2
}
]
},
{
title: 'Ingredientes',
icon: Package,
fields: [
{
label: 'Lista de ingredientes',
value: selectedRecipe.ingredients?.map(ing => `${ing.quantity} ${ing.unit} - ${ing.ingredient_id}`) || ['No especificados'],
type: 'list',
value: modalMode === 'edit'
? (() => {
const val = editedIngredients.length > 0 ? editedIngredients : selectedRecipe.ingredients || [];
console.log('[RecipesPage] Edit mode - Ingredients value:', val, 'editedIngredients.length:', editedIngredients.length);
return val;
})()
: (selectedRecipe.ingredients
?.sort((a, b) => a.ingredient_order - b.ingredient_order)
?.map(ing => {
const ingredientName = ingredientLookup[ing.ingredient_id] || ing.ingredient_id;
const optional = ing.is_optional ? ' (opcional)' : '';
const prep = ing.preparation_method ? ` - ${ing.preparation_method}` : '';
const notes = ing.ingredient_notes ? ` [${ing.ingredient_notes}]` : '';
return `${ing.quantity} ${ing.unit} de ${ingredientName}${optional}${prep}${notes}`;
}) || ['No especificados']),
type: modalMode === 'edit' ? 'component' as const : 'list' as const,
component: modalMode === 'edit' ? IngredientsEditComponent : undefined,
componentProps: modalMode === 'edit' ? {
availableIngredients,
unitOptions,
onChange: (newIngredients: RecipeIngredientResponse[]) => {
console.log('[RecipesPage] Ingredients onChange called with:', newIngredients);
setEditedIngredients(newIngredients);
}
} : undefined,
span: 2,
readonly: true // For now, ingredients editing can be complex, so we'll keep it read-only
editable: modalMode === 'edit'
}
]
},
@@ -553,13 +1108,9 @@ const RecipesPage: React.FC = () => {
metadata={[
`Tiempo: ${totalTime}`,
`Rendimiento: ${recipe.yield_quantity} ${recipe.yield_unit}`,
`${recipe.ingredients?.length || 0} ingredientes principales`
`Control de Calidad: ${getQualityIndicator(recipe)}`
]}
onClick={() => {
setSelectedRecipe(recipe);
setModalMode('view');
setShowForm(true);
}}
onClick={() => handleOpenRecipe(recipe.id)}
actions={[
// Primary action - View recipe details
{
@@ -567,21 +1118,17 @@ const RecipesPage: React.FC = () => {
icon: Eye,
variant: 'primary',
priority: 'primary',
onClick: () => {
setSelectedRecipe(recipe);
setModalMode('view');
setShowForm(true);
}
onClick: () => handleOpenRecipe(recipe.id)
},
// Secondary action - Edit recipe
// Delete action
{
label: 'Editar',
icon: Edit,
label: 'Eliminar',
icon: Trash2,
variant: 'danger',
priority: 'secondary',
onClick: () => {
setSelectedRecipe(recipe);
setModalMode('edit');
setShowForm(true);
setRecipeToDelete(recipe);
setShowDeleteModal(true);
}
}
]}
@@ -592,19 +1139,14 @@ const RecipesPage: React.FC = () => {
{/* Empty State */}
{filteredRecipes.length === 0 && (
<div className="text-center py-12">
<ChefHat className="mx-auto h-12 w-12 text-[var(--text-tertiary)] mb-4" />
<h3 className="text-lg font-medium text-[var(--text-primary)] mb-2">
No se encontraron recetas
</h3>
<p className="text-[var(--text-secondary)] mb-4">
Intenta ajustar la búsqueda o crear una nueva receta
</p>
<Button onClick={() => setShowForm(true)}>
<Plus className="w-4 h-4 mr-2" />
Nueva Receta
</Button>
</div>
<EmptyState
icon={ChefHat}
title="No se encontraron recetas"
description="Intenta ajustar la búsqueda o crear una nueva receta"
actionLabel="Nueva Receta"
actionIcon={Plus}
onAction={() => setShowCreateModal(true)}
/>
)}
{/* Recipe Details Modal */}
@@ -616,12 +1158,17 @@ const RecipesPage: React.FC = () => {
setSelectedRecipe(null);
setModalMode('view');
setEditedRecipe({});
setEditedIngredients([]);
}}
mode={modalMode}
onModeChange={(newMode) => {
setModalMode(newMode);
if (newMode === 'view') {
setEditedRecipe({});
setEditedIngredients([]);
} else if (newMode === 'edit' && selectedRecipe) {
// Initialize edited ingredients when entering edit mode
setEditedIngredients(selectedRecipe.ingredients || []);
}
}}
title={selectedRecipe.name}
@@ -632,6 +1179,9 @@ const RecipesPage: React.FC = () => {
onFieldChange={handleFieldChange}
showDefaultActions={true}
onSave={handleSaveRecipe}
waitForRefetch={true}
isRefetching={isRefetchingRecipes}
onSaveComplete={handleRecipeSaveComplete}
/>
)}
@@ -652,6 +1202,30 @@ const RecipesPage: React.FC = () => {
isLoading={updateRecipeMutation.isPending}
/>
)}
{/* Delete Recipe Modal */}
<DeleteRecipeModal
isOpen={showDeleteModal}
onClose={() => {
setShowDeleteModal(false);
setRecipeToDelete(null);
}}
recipe={recipeToDelete}
onSoftDelete={handleSoftDelete}
onHardDelete={handleHardDelete}
isLoading={archiveRecipeMutation.isPending || deleteRecipeMutation.isPending}
/>
{/* Quality Configuration Prompt */}
{newlyCreatedRecipe && (
<QualityPromptDialog
isOpen={showQualityPrompt}
onClose={handleConfigureQualityLater}
onConfigureNow={handleConfigureQualityNow}
onConfigureLater={handleConfigureQualityLater}
recipeName={newlyCreatedRecipe.name}
/>
)}
</div>
);
};

View File

@@ -1,13 +1,15 @@
import React, { useState } from 'react';
import { Plus, Building2, Phone, Mail, Eye, Edit, CheckCircle, AlertCircle, Timer, Users, Euro, Loader } from 'lucide-react';
import { Button, Badge, StatsGrid, StatusCard, getStatusColor, EditViewModal, SearchAndFilter, type FilterConfig } from '../../../../components/ui';
import { Plus, Building2, Phone, Mail, Eye, Edit, CheckCircle, AlertCircle, Timer, Users, Euro, Loader, Trash2 } from 'lucide-react';
import { Button, Badge, StatsGrid, StatusCard, getStatusColor, EditViewModal, AddModal, SearchAndFilter, DialogModal, type FilterConfig, EmptyState } 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 { useSuppliers, useSupplierStatistics, useCreateSupplier, useUpdateSupplier, useApproveSupplier, useDeleteSupplier, useHardDeleteSupplier } from '../../../../api/hooks/suppliers';
import { useCurrentTenant } from '../../../../stores/tenant.store';
import { useAuthUser } from '../../../../stores/auth.store';
import { useTranslation } from 'react-i18next';
import { statusColors } from '../../../../styles/colors';
import { DeleteSupplierModal } from '../../../../components/domain/suppliers';
const SuppliersPage: React.FC = () => {
const [activeTab] = useState('all');
@@ -17,7 +19,10 @@ const SuppliersPage: React.FC = () => {
const [showForm, setShowForm] = useState(false);
const [modalMode, setModalMode] = useState<'view' | 'edit'>('view');
const [selectedSupplier, setSelectedSupplier] = useState<any>(null);
const [isCreating, setIsCreating] = useState(false);
const [showAddModal, setShowAddModal] = useState(false);
const [showDeleteModal, setShowDeleteModal] = useState(false);
const [showApprovalModal, setShowApprovalModal] = useState(false);
const [supplierToApprove, setSupplierToApprove] = useState<any>(null);
// Get tenant ID from tenant store (preferred) or auth user (fallback)
const currentTenant = useCurrentTenant();
@@ -44,6 +49,22 @@ const SuppliersPage: React.FC = () => {
const suppliers = suppliersData || [];
const { t } = useTranslation(['suppliers', 'common']);
// Mutation hooks
const createSupplierMutation = useCreateSupplier();
const updateSupplierMutation = useUpdateSupplier();
const approveSupplierMutation = useApproveSupplier();
const softDeleteMutation = useDeleteSupplier();
const hardDeleteMutation = useHardDeleteSupplier();
// Delete handlers
const handleSoftDelete = async (supplierId: string) => {
await softDeleteMutation.mutateAsync({ tenantId, supplierId });
};
const handleHardDelete = async (supplierId: string) => {
return await hardDeleteMutation.mutateAsync({ tenantId, supplierId });
};
const getSupplierStatusConfig = (status: SupplierStatus) => {
const statusConfig = {
[SupplierStatus.ACTIVE]: { text: t(`suppliers:status.${status.toLowerCase()}`), icon: CheckCircle },
@@ -158,7 +179,7 @@ const SuppliersPage: React.FC = () => {
return (
<div className="space-y-6">
<PageHeader
<PageHeader
title="Gestión de Proveedores"
description="Administra y supervisa todos los proveedores de la panadería"
actions={[
@@ -167,26 +188,7 @@ const SuppliersPage: React.FC = () => {
label: "Nuevo Proveedor",
variant: "primary" as const,
icon: Plus,
onClick: () => {
setSelectedSupplier({
name: '',
contact_person: '',
email: '',
phone: '',
city: '',
country: '',
supplier_code: '',
supplier_type: SupplierType.INGREDIENTS,
payment_terms: PaymentTerms.NET_30,
standard_lead_time: 3,
minimum_order_amount: 0,
credit_limit: 0,
currency: 'EUR'
});
setIsCreating(true);
setModalMode('edit');
setShowForm(true);
}
onClick: () => setShowAddModal(true)
}
]}
/>
@@ -243,7 +245,7 @@ const SuppliersPage: React.FC = () => {
title={supplier.name}
subtitle={`${getSupplierTypeText(supplier.supplier_type)}${supplier.city || 'Sin ubicación'}`}
primaryValue={supplier.standard_lead_time || 0}
primaryValueLabel="días"
primaryValueLabel="días entrega"
secondaryInfo={{
label: 'Pedido Min.',
value: `${formatters.compact(supplier.minimum_order_amount || 0)}`
@@ -256,7 +258,6 @@ const SuppliersPage: React.FC = () => {
]}
onClick={() => {
setSelectedSupplier(supplier);
setIsCreating(false);
setModalMode('view');
setShowForm(true);
}}
@@ -269,23 +270,41 @@ const SuppliersPage: React.FC = () => {
priority: 'primary',
onClick: () => {
setSelectedSupplier(supplier);
setIsCreating(false);
setModalMode('view');
setShowForm(true);
}
},
// Secondary action - Edit supplier
{
label: 'Editar',
icon: Edit,
priority: 'secondary',
onClick: () => {
setSelectedSupplier(supplier);
setIsCreating(false);
setModalMode('edit');
setShowForm(true);
}
}
// Approval action - Only show for pending suppliers + admin/super_admin
...(supplier.status === SupplierStatus.PENDING_APPROVAL &&
(user?.role === 'admin' || user?.role === 'super_admin')
? [{
label: t('suppliers:actions.approve'),
icon: CheckCircle,
variant: 'primary' as const,
priority: 'primary' as const,
highlighted: true,
onClick: () => {
setSupplierToApprove(supplier);
setShowApprovalModal(true);
}
}]
: []
),
// Delete action - Only show for admin/super_admin
...(user?.role === 'admin' || user?.role === 'super_admin'
? [{
label: t('suppliers:actions.delete'),
icon: Trash2,
variant: 'outline' as const,
priority: 'secondary' as const,
destructive: true,
onClick: () => {
setSelectedSupplier(supplier);
setShowDeleteModal(true);
}
}]
: []
)
]}
/>
);
@@ -294,26 +313,236 @@ const SuppliersPage: React.FC = () => {
{/* Empty State */}
{filteredSuppliers.length === 0 && (
<div className="text-center py-12">
<Building2 className="mx-auto h-12 w-12 text-[var(--text-tertiary)] mb-4" />
<h3 className="text-lg font-medium text-[var(--text-primary)] mb-2">
No se encontraron proveedores
</h3>
<p className="text-[var(--text-secondary)] mb-4">
Intenta ajustar la búsqueda o crear un nuevo proveedor
</p>
<Button onClick={() => setShowForm(true)}>
<Plus className="w-4 h-4 mr-2" />
Nuevo Proveedor
</Button>
</div>
<EmptyState
icon={Building2}
title="No se encontraron proveedores"
description="Intenta ajustar la búsqueda o crear un nuevo proveedor"
actionLabel="Nuevo Proveedor"
actionIcon={Plus}
onAction={() => setShowAddModal(true)}
/>
)}
{/* Add Supplier Modal */}
<AddModal
isOpen={showAddModal}
onClose={() => setShowAddModal(false)}
title={t('suppliers:actions.new_supplier', 'Nuevo Proveedor')}
subtitle={t('suppliers:actions.create_new_supplier', 'Crear nuevo proveedor')}
size="lg"
sections={[
{
title: t('suppliers:sections.contact_info'),
icon: Users,
fields: [
{
label: t('common:fields.name'),
name: 'name',
type: 'text',
required: true,
placeholder: t('suppliers:placeholders.name')
},
{
label: t('common:fields.contact_person'),
name: 'contact_person',
type: 'text',
placeholder: t('suppliers:placeholders.contact_person')
},
{
label: t('common:fields.email'),
name: 'email',
type: 'email',
placeholder: t('common:fields.email_placeholder')
},
{
label: t('common:fields.phone'),
name: 'phone',
type: 'tel',
placeholder: t('common:fields.phone_placeholder')
},
{
label: t('suppliers:labels.mobile'),
name: 'mobile',
type: 'tel',
placeholder: t('suppliers:placeholders.mobile')
},
{
label: t('suppliers:labels.website'),
name: 'website',
type: 'text',
placeholder: t('suppliers:placeholders.website')
}
]
},
{
title: t('suppliers:sections.address_info'),
icon: Building2,
fields: [
{
label: t('suppliers:labels.address_line1'),
name: 'address_line1',
type: 'text',
placeholder: t('suppliers:placeholders.address_line1')
},
{
label: t('suppliers:labels.address_line2'),
name: 'address_line2',
type: 'text',
placeholder: t('suppliers:placeholders.address_line2')
},
{
label: t('common:fields.city'),
name: 'city',
type: 'text',
placeholder: t('common:fields.city')
},
{
label: t('suppliers:labels.state_province'),
name: 'state_province',
type: 'text',
placeholder: t('suppliers:placeholders.state_province')
},
{
label: t('suppliers:labels.postal_code'),
name: 'postal_code',
type: 'text',
placeholder: t('suppliers:placeholders.postal_code')
},
{
label: t('common:fields.country'),
name: 'country',
type: 'text',
placeholder: t('common:fields.country')
}
]
},
{
title: t('suppliers:sections.commercial_info'),
icon: Euro,
fields: [
{
label: t('suppliers:labels.supplier_code'),
name: 'supplier_code',
type: 'text',
placeholder: t('suppliers:placeholders.supplier_code')
},
{
label: t('suppliers:labels.supplier_type'),
name: 'supplier_type',
type: 'select',
required: true,
defaultValue: SupplierType.INGREDIENTS,
options: Object.values(SupplierType).map(value => ({
value,
label: t(`suppliers:types.${value.toLowerCase()}`)
}))
},
{
label: t('suppliers:labels.payment_terms'),
name: 'payment_terms',
type: 'select',
defaultValue: PaymentTerms.NET_30,
options: Object.values(PaymentTerms).map(value => ({
value,
label: t(`suppliers:payment_terms.${value.toLowerCase()}`)
}))
},
{
label: t('suppliers:labels.currency'),
name: 'currency',
type: 'select',
defaultValue: 'EUR',
options: [
{ value: 'EUR', label: t('suppliers:currencies.EUR') },
{ value: 'USD', label: t('suppliers:currencies.USD') },
{ value: 'GBP', label: t('suppliers:currencies.GBP') }
]
},
{
label: t('suppliers:labels.lead_time'),
name: 'standard_lead_time',
type: 'number',
defaultValue: 3,
placeholder: '3'
},
{
label: t('suppliers:labels.minimum_order'),
name: 'minimum_order_amount',
type: 'currency',
defaultValue: 0,
placeholder: '0.00'
},
{
label: t('suppliers:labels.credit_limit'),
name: 'credit_limit',
type: 'currency',
defaultValue: 0,
placeholder: '0.00'
}
]
},
{
title: t('suppliers:sections.additional_info'),
icon: Building2,
fields: [
{
label: t('suppliers:labels.tax_id'),
name: 'tax_id',
type: 'text',
placeholder: t('suppliers:placeholders.tax_id')
},
{
label: t('suppliers:labels.registration_number'),
name: 'registration_number',
type: 'text',
placeholder: t('suppliers:placeholders.registration_number')
},
{
label: t('suppliers:labels.delivery_area'),
name: 'delivery_area',
type: 'text',
placeholder: t('suppliers:placeholders.delivery_area')
}
]
}
]}
onSave={async (formData) => {
await createSupplierMutation.mutateAsync({
tenantId,
supplierData: {
name: formData.name,
supplier_code: formData.supplier_code || null,
tax_id: formData.tax_id || null,
registration_number: formData.registration_number || null,
supplier_type: formData.supplier_type || SupplierType.INGREDIENTS,
contact_person: formData.contact_person || null,
email: formData.email || null,
phone: formData.phone || null,
mobile: formData.mobile || null,
website: formData.website || null,
address_line1: formData.address_line1 || null,
address_line2: formData.address_line2 || null,
city: formData.city || null,
state_province: formData.state_province || null,
postal_code: formData.postal_code || null,
country: formData.country || null,
payment_terms: formData.payment_terms || PaymentTerms.NET_30,
credit_limit: formData.credit_limit || null,
currency: formData.currency || 'EUR',
standard_lead_time: formData.standard_lead_time || 3,
minimum_order_amount: formData.minimum_order_amount || null,
delivery_area: formData.delivery_area || null,
notes: formData.notes || null
}
});
}}
/>
{/* Supplier Details Modal */}
{showForm && selectedSupplier && (() => {
const sections = [
{
title: 'Información de Contacto',
title: t('suppliers:sections.contact_info'),
icon: Users,
fields: [
{
@@ -346,6 +575,40 @@ const SuppliersPage: React.FC = () => {
editable: true,
placeholder: t('common:fields.phone_placeholder')
},
{
label: t('suppliers:labels.mobile'),
value: selectedSupplier.mobile || '',
type: 'tel' as const,
editable: true,
placeholder: t('suppliers:placeholders.mobile')
},
{
label: t('suppliers:labels.website'),
value: selectedSupplier.website || '',
type: 'text' as const,
editable: true,
placeholder: t('suppliers:placeholders.website')
}
]
},
{
title: t('suppliers:sections.address_info'),
icon: Building2,
fields: [
{
label: t('suppliers:labels.address_line1'),
value: selectedSupplier.address_line1 || '',
type: 'text' as const,
editable: true,
placeholder: t('suppliers:placeholders.address_line1')
},
{
label: t('suppliers:labels.address_line2'),
value: selectedSupplier.address_line2 || '',
type: 'text' as const,
editable: true,
placeholder: t('suppliers:placeholders.address_line2')
},
{
label: t('common:fields.city'),
value: selectedSupplier.city || '',
@@ -353,6 +616,20 @@ const SuppliersPage: React.FC = () => {
editable: true,
placeholder: t('common:fields.city')
},
{
label: t('suppliers:labels.state_province'),
value: selectedSupplier.state_province || '',
type: 'text' as const,
editable: true,
placeholder: t('suppliers:placeholders.state_province')
},
{
label: t('suppliers:labels.postal_code'),
value: selectedSupplier.postal_code || '',
type: 'text' as const,
editable: true,
placeholder: t('suppliers:placeholders.postal_code')
},
{
label: t('common:fields.country'),
value: selectedSupplier.country || '',
@@ -363,8 +640,8 @@ const SuppliersPage: React.FC = () => {
]
},
{
title: 'Información Comercial',
icon: Building2,
title: t('suppliers:sections.commercial_info'),
icon: Euro,
fields: [
{
label: t('suppliers:labels.supplier_code'),
@@ -376,11 +653,12 @@ const SuppliersPage: React.FC = () => {
},
{
label: t('suppliers:labels.supplier_type'),
value: modalMode === 'view'
value: modalMode === 'view'
? getSupplierTypeText(selectedSupplier.supplier_type || SupplierType.INGREDIENTS)
: selectedSupplier.supplier_type || SupplierType.INGREDIENTS,
type: modalMode === 'view' ? 'text' as const : 'select' as const,
editable: true,
required: true,
options: modalMode === 'edit' ? Object.values(SupplierType).map(value => ({
value,
label: t(`suppliers:types.${value.toLowerCase()}`)
@@ -388,7 +666,7 @@ const SuppliersPage: React.FC = () => {
},
{
label: t('suppliers:labels.payment_terms'),
value: modalMode === 'view'
value: modalMode === 'view'
? getPaymentTermsText(selectedSupplier.payment_terms || PaymentTerms.NET_30)
: selectedSupplier.payment_terms || PaymentTerms.NET_30,
type: modalMode === 'view' ? 'text' as const : 'select' as const,
@@ -398,6 +676,19 @@ const SuppliersPage: React.FC = () => {
label: t(`suppliers:payment_terms.${value.toLowerCase()}`)
})) : undefined
},
{
label: t('suppliers:labels.currency'),
value: modalMode === 'view'
? t(`suppliers:currencies.${selectedSupplier.currency || 'EUR'}`)
: selectedSupplier.currency || 'EUR',
type: modalMode === 'view' ? 'text' as const : 'select' as const,
editable: true,
options: modalMode === 'edit' ? [
{ value: 'EUR', label: t('suppliers:currencies.EUR') },
{ value: 'USD', label: t('suppliers:currencies.USD') },
{ value: 'GBP', label: t('suppliers:currencies.GBP') }
] : undefined
},
{
label: t('suppliers:labels.lead_time'),
value: selectedSupplier.standard_lead_time || 3,
@@ -422,16 +713,37 @@ const SuppliersPage: React.FC = () => {
]
},
{
title: t('suppliers:sections.performance'),
icon: Euro,
title: t('suppliers:sections.additional_info'),
icon: Building2,
fields: [
{
label: t('suppliers:labels.currency'),
value: selectedSupplier.currency || 'EUR',
label: t('suppliers:labels.tax_id'),
value: selectedSupplier.tax_id || '',
type: 'text' as const,
editable: true,
placeholder: 'EUR'
placeholder: t('suppliers:placeholders.tax_id')
},
{
label: t('suppliers:labels.registration_number'),
value: selectedSupplier.registration_number || '',
type: 'text' as const,
editable: true,
placeholder: t('suppliers:placeholders.registration_number')
},
{
label: t('suppliers:labels.delivery_area'),
value: selectedSupplier.delivery_area || '',
type: 'text' as const,
editable: true,
placeholder: t('suppliers:placeholders.delivery_area')
}
]
},
// Performance section
{
title: t('suppliers:sections.performance'),
icon: CheckCircle,
fields: [
{
label: t('suppliers:labels.created_date'),
value: selectedSupplier.created_at,
@@ -467,19 +779,56 @@ const SuppliersPage: React.FC = () => {
setShowForm(false);
setSelectedSupplier(null);
setModalMode('view');
setIsCreating(false);
}}
mode={modalMode}
onModeChange={setModalMode}
title={isCreating ? 'Nuevo Proveedor' : selectedSupplier.name || 'Proveedor'}
subtitle={isCreating ? 'Crear nuevo proveedor' : `Proveedor ${selectedSupplier.supplier_code || ''}`}
statusIndicator={isCreating ? undefined : getSupplierStatusConfig(selectedSupplier.status)}
title={selectedSupplier.name || 'Proveedor'}
subtitle={`Proveedor ${selectedSupplier.supplier_code || ''}`}
statusIndicator={getSupplierStatusConfig(selectedSupplier.status)}
size="lg"
sections={sections}
showDefaultActions={modalMode === 'edit'}
showDefaultActions={true}
onSave={async () => {
// TODO: Implement save functionality
console.log('Saving supplier:', selectedSupplier);
try {
// Update existing supplier
await updateSupplierMutation.mutateAsync({
tenantId,
supplierId: selectedSupplier.id,
updateData: {
name: selectedSupplier.name,
supplier_code: selectedSupplier.supplier_code || null,
tax_id: selectedSupplier.tax_id || null,
registration_number: selectedSupplier.registration_number || null,
supplier_type: selectedSupplier.supplier_type,
contact_person: selectedSupplier.contact_person || null,
email: selectedSupplier.email || null,
phone: selectedSupplier.phone || null,
mobile: selectedSupplier.mobile || null,
website: selectedSupplier.website || null,
address_line1: selectedSupplier.address_line1 || null,
address_line2: selectedSupplier.address_line2 || null,
city: selectedSupplier.city || null,
state_province: selectedSupplier.state_province || null,
postal_code: selectedSupplier.postal_code || null,
country: selectedSupplier.country || null,
payment_terms: selectedSupplier.payment_terms,
credit_limit: selectedSupplier.credit_limit || null,
currency: selectedSupplier.currency || 'EUR',
standard_lead_time: selectedSupplier.standard_lead_time || 3,
minimum_order_amount: selectedSupplier.minimum_order_amount || null,
delivery_area: selectedSupplier.delivery_area || null,
notes: selectedSupplier.notes || null
}
});
// Close modal on success
setShowForm(false);
setSelectedSupplier(null);
setModalMode('view');
} catch (error) {
console.error('Error saving supplier:', error);
// Error will be handled by the modal's error display
throw error;
}
}}
onFieldChange={(sectionIndex, fieldIndex, value) => {
// Update the selectedSupplier state when fields change
@@ -493,15 +842,24 @@ const SuppliersPage: React.FC = () => {
[t('common:fields.contact_person')]: 'contact_person',
[t('common:fields.email')]: 'email',
[t('common:fields.phone')]: 'phone',
[t('suppliers:labels.mobile')]: 'mobile',
[t('suppliers:labels.website')]: 'website',
[t('suppliers:labels.address_line1')]: 'address_line1',
[t('suppliers:labels.address_line2')]: 'address_line2',
[t('common:fields.city')]: 'city',
[t('suppliers:labels.state_province')]: 'state_province',
[t('suppliers:labels.postal_code')]: 'postal_code',
[t('common:fields.country')]: 'country',
[t('suppliers:labels.supplier_code')]: 'supplier_code',
[t('suppliers:labels.supplier_type')]: 'supplier_type',
[t('suppliers:labels.payment_terms')]: 'payment_terms',
[t('suppliers:labels.currency')]: 'currency',
[t('suppliers:labels.lead_time')]: 'standard_lead_time',
[t('suppliers:labels.minimum_order')]: 'minimum_order_amount',
[t('suppliers:labels.credit_limit')]: 'credit_limit',
[t('suppliers:labels.currency')]: 'currency',
[t('suppliers:labels.tax_id')]: 'tax_id',
[t('suppliers:labels.registration_number')]: 'registration_number',
[t('suppliers:labels.delivery_area')]: 'delivery_area',
[t('suppliers:labels.notes')]: 'notes'
};
@@ -514,6 +872,76 @@ const SuppliersPage: React.FC = () => {
/>
);
})()}
{/* Delete Supplier Modal */}
<DeleteSupplierModal
isOpen={showDeleteModal}
onClose={() => {
setShowDeleteModal(false);
setSelectedSupplier(null);
}}
supplier={selectedSupplier}
onSoftDelete={handleSoftDelete}
onHardDelete={handleHardDelete}
isLoading={softDeleteMutation.isPending || hardDeleteMutation.isPending}
/>
{/* Approval Confirmation Modal */}
<DialogModal
isOpen={showApprovalModal}
onClose={() => {
setShowApprovalModal(false);
setSupplierToApprove(null);
}}
type="confirm"
title={t('suppliers:confirm.approve_title', 'Aprobar Proveedor')}
message={
supplierToApprove ? (
<div className="space-y-3">
<p className="text-[var(--text-primary)]">
{t('suppliers:confirm.approve_message', '¿Estás seguro de que deseas aprobar este proveedor?')}
</p>
<div className="bg-[var(--bg-secondary)] p-4 rounded-lg border border-[var(--border-primary)]">
<div className="space-y-2">
<div className="flex items-center gap-2">
<Building2 className="w-4 h-4 text-[var(--color-primary)]" />
<span className="font-semibold text-[var(--text-primary)]">{supplierToApprove.name}</span>
</div>
{supplierToApprove.supplier_code && (
<p className="text-sm text-[var(--text-secondary)]">
{t('suppliers:labels.supplier_code')}: {supplierToApprove.supplier_code}
</p>
)}
{supplierToApprove.email && (
<p className="text-sm text-[var(--text-secondary)]">
{t('common:fields.email')}: {supplierToApprove.email}
</p>
)}
</div>
</div>
<p className="text-sm text-[var(--text-secondary)]">
{t('suppliers:confirm.approve_description', 'Una vez aprobado, el proveedor estará activo y podrá ser utilizado para realizar pedidos.')}
</p>
</div>
) : null
}
confirmLabel={t('suppliers:actions.approve', 'Aprobar')}
cancelLabel={t('common:modals.actions.cancel', 'Cancelar')}
onConfirm={async () => {
if (supplierToApprove) {
try {
await approveSupplierMutation.mutateAsync({
tenantId,
supplierId: supplierToApprove.id,
approvalData: { action: 'approve' }
});
} catch (error) {
console.error('Error approving supplier:', error);
}
}
}}
loading={approveSupplierMutation.isPending}
/>
</div>
);
};