Add frontend imporvements
This commit is contained in:
@@ -3,4 +3,5 @@ export * from './production';
|
||||
export * from './recipes';
|
||||
export * from './procurement';
|
||||
export * from './orders';
|
||||
export * from './suppliers';
|
||||
export * from './pos';
|
||||
@@ -1,190 +1,180 @@
|
||||
import React, { useState } from 'react';
|
||||
import React, { useState, useMemo } from 'react';
|
||||
import { Plus, Download, AlertTriangle, Package, Clock, CheckCircle, Eye, Edit, Calendar, DollarSign } from 'lucide-react';
|
||||
import { Button, Input, Card, Badge, StatsGrid, StatusCard, getStatusColor, StatusModal } from '../../../../components/ui';
|
||||
import { LoadingSpinner } from '../../../../components/shared';
|
||||
import { formatters } from '../../../../components/ui/Stats/StatsPresets';
|
||||
import { PageHeader } from '../../../../components/layout';
|
||||
import { InventoryForm, LowStockAlert } from '../../../../components/domain/inventory';
|
||||
import { useIngredients, useLowStockIngredients, useStockAnalytics } from '../../../../api/hooks/inventory';
|
||||
import { useCurrentTenant } from '../../../../stores/tenant.store';
|
||||
import { IngredientResponse } from '../../../../api/types/inventory';
|
||||
|
||||
const InventoryPage: React.FC = () => {
|
||||
const [searchTerm, setSearchTerm] = useState('');
|
||||
const [showForm, setShowForm] = useState(false);
|
||||
const [modalMode, setModalMode] = useState<'view' | 'edit'>('view');
|
||||
const [selectedItem, setSelectedItem] = useState<typeof mockInventoryItems[0] | null>(null);
|
||||
const [selectedItem, setSelectedItem] = useState<IngredientResponse | null>(null);
|
||||
|
||||
const currentTenant = useCurrentTenant();
|
||||
const tenantId = currentTenant?.id || '';
|
||||
|
||||
const mockInventoryItems = [
|
||||
{
|
||||
id: '1',
|
||||
name: 'Harina de Trigo',
|
||||
category: 'Harinas',
|
||||
currentStock: 45,
|
||||
minStock: 20,
|
||||
maxStock: 100,
|
||||
unit: 'kg',
|
||||
cost: 1.20,
|
||||
supplier: 'Molinos del Sur',
|
||||
lastRestocked: '2024-01-20',
|
||||
expirationDate: '2024-06-30',
|
||||
status: 'normal',
|
||||
},
|
||||
{
|
||||
id: '2',
|
||||
name: 'Levadura Fresca',
|
||||
category: 'Levaduras',
|
||||
currentStock: 8,
|
||||
minStock: 10,
|
||||
maxStock: 25,
|
||||
unit: 'kg',
|
||||
cost: 8.50,
|
||||
supplier: 'Levaduras SA',
|
||||
lastRestocked: '2024-01-25',
|
||||
expirationDate: '2024-02-15',
|
||||
status: 'low',
|
||||
},
|
||||
{
|
||||
id: '3',
|
||||
name: 'Mantequilla',
|
||||
category: 'Lácteos',
|
||||
currentStock: 15,
|
||||
minStock: 5,
|
||||
maxStock: 30,
|
||||
unit: 'kg',
|
||||
cost: 5.80,
|
||||
supplier: 'Lácteos Frescos',
|
||||
lastRestocked: '2024-01-24',
|
||||
expirationDate: '2024-02-10',
|
||||
status: 'normal',
|
||||
},
|
||||
{
|
||||
id: '4',
|
||||
name: 'Azúcar Blanco',
|
||||
category: 'Azúcares',
|
||||
currentStock: 0,
|
||||
minStock: 15,
|
||||
maxStock: 50,
|
||||
unit: 'kg',
|
||||
cost: 0.95,
|
||||
supplier: 'Distribuidora Central',
|
||||
lastRestocked: '2024-01-10',
|
||||
expirationDate: '2024-12-31',
|
||||
status: 'out',
|
||||
},
|
||||
{
|
||||
id: '5',
|
||||
name: 'Leche Entera',
|
||||
category: 'Lácteos',
|
||||
currentStock: 3,
|
||||
minStock: 10,
|
||||
maxStock: 40,
|
||||
unit: 'L',
|
||||
cost: 1.45,
|
||||
supplier: 'Lácteos Frescos',
|
||||
lastRestocked: '2024-01-22',
|
||||
expirationDate: '2024-01-28',
|
||||
status: 'expired',
|
||||
},
|
||||
];
|
||||
// API Data
|
||||
const {
|
||||
data: ingredientsData,
|
||||
isLoading: ingredientsLoading,
|
||||
error: ingredientsError
|
||||
} = useIngredients(tenantId, { search: searchTerm || undefined });
|
||||
|
||||
const {
|
||||
data: lowStockData,
|
||||
isLoading: lowStockLoading
|
||||
} = useLowStockIngredients(tenantId);
|
||||
|
||||
const {
|
||||
data: analyticsData,
|
||||
isLoading: analyticsLoading
|
||||
} = useStockAnalytics(tenantId);
|
||||
|
||||
const ingredients = ingredientsData?.items || [];
|
||||
const lowStockItems = lowStockData || [];
|
||||
|
||||
const getInventoryStatusConfig = (item: typeof mockInventoryItems[0]) => {
|
||||
const { currentStock, minStock, status } = item;
|
||||
const getInventoryStatusConfig = (ingredient: IngredientResponse) => {
|
||||
const { current_stock_level, low_stock_threshold, stock_status } = ingredient;
|
||||
|
||||
if (status === 'expired') {
|
||||
return {
|
||||
color: getStatusColor('expired'),
|
||||
text: 'Caducado',
|
||||
icon: AlertTriangle,
|
||||
isCritical: true,
|
||||
isHighlight: false
|
||||
};
|
||||
switch (stock_status) {
|
||||
case 'out_of_stock':
|
||||
return {
|
||||
color: getStatusColor('cancelled'),
|
||||
text: 'Sin Stock',
|
||||
icon: AlertTriangle,
|
||||
isCritical: true,
|
||||
isHighlight: false
|
||||
};
|
||||
case 'low_stock':
|
||||
return {
|
||||
color: getStatusColor('pending'),
|
||||
text: 'Stock Bajo',
|
||||
icon: AlertTriangle,
|
||||
isCritical: false,
|
||||
isHighlight: true
|
||||
};
|
||||
case 'overstock':
|
||||
return {
|
||||
color: getStatusColor('info'),
|
||||
text: 'Sobrestock',
|
||||
icon: Package,
|
||||
isCritical: false,
|
||||
isHighlight: false
|
||||
};
|
||||
case 'in_stock':
|
||||
default:
|
||||
return {
|
||||
color: getStatusColor('completed'),
|
||||
text: 'Normal',
|
||||
icon: CheckCircle,
|
||||
isCritical: false,
|
||||
isHighlight: false
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
const filteredItems = useMemo(() => {
|
||||
if (!searchTerm) return ingredients;
|
||||
|
||||
if (currentStock === 0) {
|
||||
return ingredients.filter(ingredient => {
|
||||
const searchLower = searchTerm.toLowerCase();
|
||||
return ingredient.name.toLowerCase().includes(searchLower) ||
|
||||
ingredient.category.toLowerCase().includes(searchLower) ||
|
||||
(ingredient.description && ingredient.description.toLowerCase().includes(searchLower));
|
||||
});
|
||||
}, [ingredients, searchTerm]);
|
||||
|
||||
const inventoryStats = useMemo(() => {
|
||||
if (!analyticsData) {
|
||||
return {
|
||||
color: getStatusColor('out'),
|
||||
text: 'Sin Stock',
|
||||
icon: AlertTriangle,
|
||||
isCritical: true,
|
||||
isHighlight: false
|
||||
};
|
||||
}
|
||||
|
||||
if (currentStock <= minStock) {
|
||||
return {
|
||||
color: getStatusColor('low'),
|
||||
text: 'Stock Bajo',
|
||||
icon: AlertTriangle,
|
||||
isCritical: false,
|
||||
isHighlight: true
|
||||
totalItems: ingredients.length,
|
||||
lowStockItems: lowStockItems.length,
|
||||
outOfStock: ingredients.filter(item => item.stock_status === 'out_of_stock').length,
|
||||
expiringSoon: 0, // This would come from expired stock API
|
||||
totalValue: ingredients.reduce((sum, item) => sum + (item.current_stock_level * (item.average_cost || 0)), 0),
|
||||
categories: [...new Set(ingredients.map(item => item.category))].length,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
color: getStatusColor('normal'),
|
||||
text: 'Normal',
|
||||
icon: CheckCircle,
|
||||
isCritical: false,
|
||||
isHighlight: false
|
||||
totalItems: analyticsData.total_ingredients || 0,
|
||||
lowStockItems: analyticsData.low_stock_count || 0,
|
||||
outOfStock: analyticsData.out_of_stock_count || 0,
|
||||
expiringSoon: analyticsData.expiring_soon_count || 0,
|
||||
totalValue: analyticsData.total_stock_value || 0,
|
||||
categories: [...new Set(ingredients.map(item => item.category))].length,
|
||||
};
|
||||
};
|
||||
}, [analyticsData, ingredients, lowStockItems]);
|
||||
|
||||
const filteredItems = mockInventoryItems.filter(item => {
|
||||
const matchesSearch = item.name.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
||||
item.category.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
||||
item.supplier.toLowerCase().includes(searchTerm.toLowerCase());
|
||||
|
||||
return matchesSearch;
|
||||
});
|
||||
|
||||
const lowStockItems = mockInventoryItems.filter(item =>
|
||||
item.currentStock <= item.minStock || item.status === 'low' || item.status === 'out' || item.status === 'expired'
|
||||
);
|
||||
|
||||
const mockInventoryStats = {
|
||||
totalItems: mockInventoryItems.length,
|
||||
lowStockItems: lowStockItems.length,
|
||||
outOfStock: mockInventoryItems.filter(item => item.currentStock === 0).length,
|
||||
expiringSoon: mockInventoryItems.filter(item => item.status === 'expired').length,
|
||||
totalValue: mockInventoryItems.reduce((sum, item) => sum + (item.currentStock * item.cost), 0),
|
||||
categories: [...new Set(mockInventoryItems.map(item => item.category))].length,
|
||||
};
|
||||
|
||||
const inventoryStats = [
|
||||
const stats = [
|
||||
{
|
||||
title: 'Total Artículos',
|
||||
value: mockInventoryStats.totalItems,
|
||||
value: inventoryStats.totalItems,
|
||||
variant: 'default' as const,
|
||||
icon: Package,
|
||||
},
|
||||
{
|
||||
title: 'Stock Bajo',
|
||||
value: mockInventoryStats.lowStockItems,
|
||||
value: inventoryStats.lowStockItems,
|
||||
variant: 'warning' as const,
|
||||
icon: AlertTriangle,
|
||||
},
|
||||
{
|
||||
title: 'Sin Stock',
|
||||
value: mockInventoryStats.outOfStock,
|
||||
value: inventoryStats.outOfStock,
|
||||
variant: 'error' as const,
|
||||
icon: AlertTriangle,
|
||||
},
|
||||
{
|
||||
title: 'Por Caducar',
|
||||
value: mockInventoryStats.expiringSoon,
|
||||
value: inventoryStats.expiringSoon,
|
||||
variant: 'error' as const,
|
||||
icon: Clock,
|
||||
},
|
||||
{
|
||||
title: 'Valor Total',
|
||||
value: formatters.currency(mockInventoryStats.totalValue),
|
||||
value: formatters.currency(inventoryStats.totalValue),
|
||||
variant: 'success' as const,
|
||||
icon: DollarSign,
|
||||
},
|
||||
{
|
||||
title: 'Categorías',
|
||||
value: mockInventoryStats.categories,
|
||||
value: inventoryStats.categories,
|
||||
variant: 'info' as const,
|
||||
icon: Package,
|
||||
},
|
||||
];
|
||||
|
||||
// Loading and error states
|
||||
if (ingredientsLoading || analyticsLoading || !tenantId) {
|
||||
return (
|
||||
<div className="flex items-center justify-center min-h-64">
|
||||
<LoadingSpinner text="Cargando inventario..." />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (ingredientsError) {
|
||||
return (
|
||||
<div className="text-center py-12">
|
||||
<AlertTriangle className="mx-auto h-12 w-12 text-red-500 mb-4" />
|
||||
<h3 className="text-lg font-medium text-[var(--text-primary)] mb-2">
|
||||
Error al cargar el inventario
|
||||
</h3>
|
||||
<p className="text-[var(--text-secondary)] mb-4">
|
||||
{ingredientsError.message || 'Ha ocurrido un error inesperado'}
|
||||
</p>
|
||||
<Button onClick={() => window.location.reload()}>
|
||||
Reintentar
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
@@ -211,7 +201,7 @@ const InventoryPage: React.FC = () => {
|
||||
|
||||
{/* Stats Grid */}
|
||||
<StatsGrid
|
||||
stats={inventoryStats}
|
||||
stats={stats}
|
||||
columns={3}
|
||||
/>
|
||||
|
||||
@@ -240,34 +230,39 @@ const InventoryPage: React.FC = () => {
|
||||
|
||||
{/* Inventory Items Grid */}
|
||||
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
|
||||
{filteredItems.map((item) => {
|
||||
const statusConfig = getInventoryStatusConfig(item);
|
||||
const stockPercentage = Math.round((item.currentStock / item.maxStock) * 100);
|
||||
const isExpiringSoon = new Date(item.expirationDate) < new Date(Date.now() + 7 * 24 * 60 * 60 * 1000);
|
||||
const isExpired = new Date(item.expirationDate) < new Date();
|
||||
{filteredItems.map((ingredient) => {
|
||||
const statusConfig = getInventoryStatusConfig(ingredient);
|
||||
const stockPercentage = ingredient.max_stock_level ?
|
||||
Math.round((ingredient.current_stock_level / ingredient.max_stock_level) * 100) : 0;
|
||||
const averageCost = ingredient.average_cost || 0;
|
||||
const totalValue = ingredient.current_stock_level * averageCost;
|
||||
|
||||
return (
|
||||
<StatusCard
|
||||
key={item.id}
|
||||
id={item.id}
|
||||
key={ingredient.id}
|
||||
id={ingredient.id}
|
||||
statusIndicator={statusConfig}
|
||||
title={item.name}
|
||||
subtitle={`${item.category} • ${item.supplier}`}
|
||||
primaryValue={item.currentStock}
|
||||
primaryValueLabel={item.unit}
|
||||
title={ingredient.name}
|
||||
subtitle={`${ingredient.category}${ingredient.description ? ` • ${ingredient.description}` : ''}`}
|
||||
primaryValue={ingredient.current_stock_level}
|
||||
primaryValueLabel={ingredient.unit_of_measure}
|
||||
secondaryInfo={{
|
||||
label: 'Valor total',
|
||||
value: `${formatters.currency(item.currentStock * item.cost)} (${formatters.currency(item.cost)}/${item.unit})`
|
||||
value: `${formatters.currency(totalValue)}${averageCost > 0 ? ` (${formatters.currency(averageCost)}/${ingredient.unit_of_measure})` : ''}`
|
||||
}}
|
||||
progress={{
|
||||
progress={ingredient.max_stock_level ? {
|
||||
label: 'Nivel de stock',
|
||||
percentage: stockPercentage,
|
||||
color: statusConfig.color
|
||||
}}
|
||||
} : undefined}
|
||||
metadata={[
|
||||
`Rango: ${item.minStock} - ${item.maxStock} ${item.unit}`,
|
||||
`Caduca: ${new Date(item.expirationDate).toLocaleDateString('es-ES')}${isExpired ? ' (CADUCADO)' : isExpiringSoon ? ' (PRONTO)' : ''}`,
|
||||
`Último restock: ${new Date(item.lastRestocked).toLocaleDateString('es-ES')}`
|
||||
`Rango: ${ingredient.low_stock_threshold} - ${ingredient.max_stock_level || 'Sin límite'} ${ingredient.unit_of_measure}`,
|
||||
`Stock disponible: ${ingredient.available_stock} ${ingredient.unit_of_measure}`,
|
||||
`Stock reservado: ${ingredient.reserved_stock} ${ingredient.unit_of_measure}`,
|
||||
ingredient.last_restocked ? `Último restock: ${new Date(ingredient.last_restocked).toLocaleDateString('es-ES')}` : 'Sin historial de restock',
|
||||
...(ingredient.requires_refrigeration ? ['Requiere refrigeración'] : []),
|
||||
...(ingredient.requires_freezing ? ['Requiere congelación'] : []),
|
||||
...(ingredient.is_seasonal ? ['Producto estacional'] : [])
|
||||
]}
|
||||
actions={[
|
||||
{
|
||||
@@ -275,7 +270,7 @@ const InventoryPage: React.FC = () => {
|
||||
icon: Eye,
|
||||
variant: 'outline',
|
||||
onClick: () => {
|
||||
setSelectedItem(item);
|
||||
setSelectedItem(ingredient);
|
||||
setModalMode('view');
|
||||
setShowForm(true);
|
||||
}
|
||||
@@ -285,7 +280,7 @@ const InventoryPage: React.FC = () => {
|
||||
icon: Edit,
|
||||
variant: 'outline',
|
||||
onClick: () => {
|
||||
setSelectedItem(item);
|
||||
setSelectedItem(ingredient);
|
||||
setModalMode('edit');
|
||||
setShowForm(true);
|
||||
}
|
||||
@@ -325,7 +320,7 @@ const InventoryPage: React.FC = () => {
|
||||
mode={modalMode}
|
||||
onModeChange={setModalMode}
|
||||
title={selectedItem.name}
|
||||
subtitle={`${selectedItem.category} - ${selectedItem.supplier}`}
|
||||
subtitle={`${selectedItem.category}${selectedItem.description ? ` - ${selectedItem.description}` : ''}`}
|
||||
statusIndicator={getInventoryStatusConfig(selectedItem)}
|
||||
size="lg"
|
||||
sections={[
|
||||
@@ -343,12 +338,12 @@ const InventoryPage: React.FC = () => {
|
||||
value: selectedItem.category
|
||||
},
|
||||
{
|
||||
label: 'Proveedor',
|
||||
value: selectedItem.supplier
|
||||
label: 'Descripción',
|
||||
value: selectedItem.description || 'Sin descripción'
|
||||
},
|
||||
{
|
||||
label: 'Unidad de medida',
|
||||
value: selectedItem.unit
|
||||
value: selectedItem.unit_of_measure
|
||||
}
|
||||
]
|
||||
},
|
||||
@@ -358,22 +353,28 @@ const InventoryPage: React.FC = () => {
|
||||
fields: [
|
||||
{
|
||||
label: 'Stock actual',
|
||||
value: `${selectedItem.currentStock} ${selectedItem.unit}`,
|
||||
value: `${selectedItem.current_stock_level} ${selectedItem.unit_of_measure}`,
|
||||
highlight: true
|
||||
},
|
||||
{
|
||||
label: 'Stock mínimo',
|
||||
value: `${selectedItem.minStock} ${selectedItem.unit}`
|
||||
label: 'Stock disponible',
|
||||
value: `${selectedItem.available_stock} ${selectedItem.unit_of_measure}`
|
||||
},
|
||||
{
|
||||
label: 'Stock reservado',
|
||||
value: `${selectedItem.reserved_stock} ${selectedItem.unit_of_measure}`
|
||||
},
|
||||
{
|
||||
label: 'Umbral mínimo',
|
||||
value: `${selectedItem.low_stock_threshold} ${selectedItem.unit_of_measure}`
|
||||
},
|
||||
{
|
||||
label: 'Stock máximo',
|
||||
value: `${selectedItem.maxStock} ${selectedItem.unit}`
|
||||
value: selectedItem.max_stock_level ? `${selectedItem.max_stock_level} ${selectedItem.unit_of_measure}` : 'Sin límite'
|
||||
},
|
||||
{
|
||||
label: 'Porcentaje de stock',
|
||||
value: Math.round((selectedItem.currentStock / selectedItem.maxStock) * 100),
|
||||
type: 'percentage',
|
||||
highlight: selectedItem.currentStock <= selectedItem.minStock
|
||||
label: 'Punto de reorden',
|
||||
value: `${selectedItem.reorder_point} ${selectedItem.unit_of_measure}`
|
||||
}
|
||||
]
|
||||
},
|
||||
@@ -382,35 +383,62 @@ const InventoryPage: React.FC = () => {
|
||||
icon: DollarSign,
|
||||
fields: [
|
||||
{
|
||||
label: 'Costo por unidad',
|
||||
value: selectedItem.cost,
|
||||
label: 'Costo promedio por unidad',
|
||||
value: selectedItem.average_cost || 0,
|
||||
type: 'currency'
|
||||
},
|
||||
{
|
||||
label: 'Valor total en stock',
|
||||
value: selectedItem.currentStock * selectedItem.cost,
|
||||
value: selectedItem.current_stock_level * (selectedItem.average_cost || 0),
|
||||
type: 'currency',
|
||||
highlight: true
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
title: 'Fechas Importantes',
|
||||
title: 'Información Adicional',
|
||||
icon: Calendar,
|
||||
fields: [
|
||||
{
|
||||
label: 'Último restock',
|
||||
value: selectedItem.lastRestocked,
|
||||
type: 'date'
|
||||
value: selectedItem.last_restocked || 'Sin historial',
|
||||
type: selectedItem.last_restocked ? 'datetime' : undefined
|
||||
},
|
||||
{
|
||||
label: 'Fecha de caducidad',
|
||||
value: selectedItem.expirationDate,
|
||||
type: 'date',
|
||||
highlight: new Date(selectedItem.expirationDate) < new Date(Date.now() + 7 * 24 * 60 * 60 * 1000)
|
||||
label: 'Vida útil',
|
||||
value: selectedItem.shelf_life_days ? `${selectedItem.shelf_life_days} días` : 'No especificada'
|
||||
},
|
||||
{
|
||||
label: 'Requiere refrigeración',
|
||||
value: selectedItem.requires_refrigeration ? 'Sí' : 'No',
|
||||
highlight: selectedItem.requires_refrigeration
|
||||
},
|
||||
{
|
||||
label: 'Requiere congelación',
|
||||
value: selectedItem.requires_freezing ? 'Sí' : 'No',
|
||||
highlight: selectedItem.requires_freezing
|
||||
},
|
||||
{
|
||||
label: 'Producto estacional',
|
||||
value: selectedItem.is_seasonal ? 'Sí' : 'No'
|
||||
},
|
||||
{
|
||||
label: 'Creado',
|
||||
value: selectedItem.created_at,
|
||||
type: 'datetime'
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
...(selectedItem.notes ? [{
|
||||
title: 'Notas',
|
||||
fields: [
|
||||
{
|
||||
label: 'Observaciones',
|
||||
value: selectedItem.notes,
|
||||
span: 2 as const
|
||||
}
|
||||
]
|
||||
}] : [])
|
||||
]}
|
||||
onEdit={() => {
|
||||
console.log('Editing inventory item:', selectedItem.id);
|
||||
|
||||
435
frontend/src/pages/app/operations/suppliers/SuppliersPage.tsx
Normal file
435
frontend/src/pages/app/operations/suppliers/SuppliersPage.tsx
Normal file
@@ -0,0 +1,435 @@
|
||||
import React, { useState } from 'react';
|
||||
import { Plus, Download, Building2, Phone, Mail, Eye, Edit, CheckCircle, AlertCircle, Timer, Users, DollarSign } from 'lucide-react';
|
||||
import { Button, Input, Card, Badge, StatsGrid, StatusCard, getStatusColor, StatusModal } from '../../../../components/ui';
|
||||
import { formatters } from '../../../../components/ui/Stats/StatsPresets';
|
||||
import { PageHeader } from '../../../../components/layout';
|
||||
import { SupplierStatus, SupplierType, PaymentTerms } from '../../../../api/types/suppliers';
|
||||
|
||||
const SuppliersPage: React.FC = () => {
|
||||
const [activeTab] = useState('all');
|
||||
const [searchTerm, setSearchTerm] = useState('');
|
||||
const [showForm, setShowForm] = useState(false);
|
||||
const [modalMode, setModalMode] = useState<'view' | 'edit'>('view');
|
||||
const [selectedSupplier, setSelectedSupplier] = useState<typeof mockSuppliers[0] | null>(null);
|
||||
|
||||
const mockSuppliers = [
|
||||
{
|
||||
id: 'SUP-2024-001',
|
||||
supplier_code: 'HAR001',
|
||||
name: 'Harinas del Norte S.L.',
|
||||
supplier_type: SupplierType.INGREDIENTS,
|
||||
status: SupplierStatus.ACTIVE,
|
||||
contact_person: 'María González',
|
||||
email: 'maria@harinasdelnorte.es',
|
||||
phone: '+34 987 654 321',
|
||||
city: 'León',
|
||||
country: 'España',
|
||||
payment_terms: PaymentTerms.NET_30,
|
||||
credit_limit: 5000,
|
||||
currency: 'EUR',
|
||||
standard_lead_time: 5,
|
||||
minimum_order_amount: 200,
|
||||
total_orders: 45,
|
||||
total_spend: 12750.50,
|
||||
last_order_date: '2024-01-25T14:30:00Z',
|
||||
performance_score: 92,
|
||||
notes: 'Proveedor principal de harinas. Excelente calidad y puntualidad.'
|
||||
},
|
||||
{
|
||||
id: 'SUP-2024-002',
|
||||
supplier_code: 'EMB002',
|
||||
name: 'Embalajes Biodegradables SA',
|
||||
supplier_type: SupplierType.PACKAGING,
|
||||
status: SupplierStatus.ACTIVE,
|
||||
contact_person: 'Carlos Ruiz',
|
||||
email: 'carlos@embalajes-bio.com',
|
||||
phone: '+34 600 123 456',
|
||||
city: 'Valencia',
|
||||
country: 'España',
|
||||
payment_terms: PaymentTerms.NET_15,
|
||||
credit_limit: 2500,
|
||||
currency: 'EUR',
|
||||
standard_lead_time: 3,
|
||||
minimum_order_amount: 150,
|
||||
total_orders: 28,
|
||||
total_spend: 4280.75,
|
||||
last_order_date: '2024-01-24T10:15:00Z',
|
||||
performance_score: 88,
|
||||
notes: 'Especialista en packaging sostenible.'
|
||||
},
|
||||
{
|
||||
id: 'SUP-2024-003',
|
||||
supplier_code: 'MAN003',
|
||||
name: 'Maquinaria Industrial López',
|
||||
supplier_type: SupplierType.EQUIPMENT,
|
||||
status: SupplierStatus.PENDING_APPROVAL,
|
||||
contact_person: 'Ana López',
|
||||
email: 'ana@maquinaria-lopez.es',
|
||||
phone: '+34 655 987 654',
|
||||
city: 'Madrid',
|
||||
country: 'España',
|
||||
payment_terms: PaymentTerms.NET_45,
|
||||
credit_limit: 15000,
|
||||
currency: 'EUR',
|
||||
standard_lead_time: 14,
|
||||
minimum_order_amount: 500,
|
||||
total_orders: 0,
|
||||
total_spend: 0,
|
||||
last_order_date: null,
|
||||
performance_score: null,
|
||||
notes: 'Nuevo proveedor de equipamiento industrial. Pendiente de aprobación.'
|
||||
},
|
||||
];
|
||||
|
||||
const getSupplierStatusConfig = (status: SupplierStatus) => {
|
||||
const statusConfig = {
|
||||
[SupplierStatus.ACTIVE]: { text: 'Activo', icon: CheckCircle },
|
||||
[SupplierStatus.INACTIVE]: { text: 'Inactivo', icon: Timer },
|
||||
[SupplierStatus.PENDING_APPROVAL]: { text: 'Pendiente Aprobación', icon: AlertCircle },
|
||||
[SupplierStatus.SUSPENDED]: { text: 'Suspendido', icon: AlertCircle },
|
||||
[SupplierStatus.BLACKLISTED]: { text: 'Lista Negra', icon: AlertCircle },
|
||||
};
|
||||
|
||||
const config = statusConfig[status];
|
||||
const Icon = config?.icon;
|
||||
|
||||
return {
|
||||
color: getStatusColor(status === SupplierStatus.ACTIVE ? 'completed' :
|
||||
status === SupplierStatus.PENDING_APPROVAL ? 'pending' : 'cancelled'),
|
||||
text: config?.text || status,
|
||||
icon: Icon,
|
||||
isCritical: status === SupplierStatus.BLACKLISTED,
|
||||
isHighlight: status === SupplierStatus.PENDING_APPROVAL
|
||||
};
|
||||
};
|
||||
|
||||
const getSupplierTypeText = (type: SupplierType): string => {
|
||||
const typeMap = {
|
||||
[SupplierType.INGREDIENTS]: 'Ingredientes',
|
||||
[SupplierType.PACKAGING]: 'Embalajes',
|
||||
[SupplierType.EQUIPMENT]: 'Equipamiento',
|
||||
[SupplierType.SERVICES]: 'Servicios',
|
||||
[SupplierType.UTILITIES]: 'Servicios Públicos',
|
||||
[SupplierType.MULTI]: 'Múltiple',
|
||||
};
|
||||
return typeMap[type] || type;
|
||||
};
|
||||
|
||||
const getPaymentTermsText = (terms: PaymentTerms): string => {
|
||||
const termsMap = {
|
||||
[PaymentTerms.CASH_ON_DELIVERY]: 'Pago Contraentrega',
|
||||
[PaymentTerms.NET_15]: 'Neto 15 días',
|
||||
[PaymentTerms.NET_30]: 'Neto 30 días',
|
||||
[PaymentTerms.NET_45]: 'Neto 45 días',
|
||||
[PaymentTerms.NET_60]: 'Neto 60 días',
|
||||
[PaymentTerms.PREPAID]: 'Prepago',
|
||||
};
|
||||
return termsMap[terms] || terms;
|
||||
};
|
||||
|
||||
const filteredSuppliers = mockSuppliers.filter(supplier => {
|
||||
const matchesSearch = supplier.name.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
||||
supplier.supplier_code.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
||||
supplier.email?.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
||||
supplier.contact_person?.toLowerCase().includes(searchTerm.toLowerCase());
|
||||
|
||||
const matchesTab = activeTab === 'all' || supplier.status === activeTab;
|
||||
|
||||
return matchesSearch && matchesTab;
|
||||
});
|
||||
|
||||
const mockSupplierStats = {
|
||||
total: mockSuppliers.length,
|
||||
active: mockSuppliers.filter(s => s.status === SupplierStatus.ACTIVE).length,
|
||||
pendingApproval: mockSuppliers.filter(s => s.status === SupplierStatus.PENDING_APPROVAL).length,
|
||||
suspended: mockSuppliers.filter(s => s.status === SupplierStatus.SUSPENDED).length,
|
||||
totalSpend: mockSuppliers.reduce((sum, supplier) => sum + supplier.total_spend, 0),
|
||||
averageScore: mockSuppliers
|
||||
.filter(s => s.performance_score !== null)
|
||||
.reduce((sum, supplier, _, arr) => sum + (supplier.performance_score || 0) / arr.length, 0),
|
||||
totalOrders: mockSuppliers.reduce((sum, supplier) => sum + supplier.total_orders, 0),
|
||||
};
|
||||
|
||||
const stats = [
|
||||
{
|
||||
title: 'Total Proveedores',
|
||||
value: mockSupplierStats.total,
|
||||
variant: 'default' as const,
|
||||
icon: Building2,
|
||||
},
|
||||
{
|
||||
title: 'Activos',
|
||||
value: mockSupplierStats.active,
|
||||
variant: 'success' as const,
|
||||
icon: CheckCircle,
|
||||
},
|
||||
{
|
||||
title: 'Pendientes',
|
||||
value: mockSupplierStats.pendingApproval,
|
||||
variant: 'warning' as const,
|
||||
icon: AlertCircle,
|
||||
},
|
||||
{
|
||||
title: 'Gasto Total',
|
||||
value: formatters.currency(mockSupplierStats.totalSpend),
|
||||
variant: 'info' as const,
|
||||
icon: DollarSign,
|
||||
},
|
||||
{
|
||||
title: 'Total Pedidos',
|
||||
value: mockSupplierStats.totalOrders,
|
||||
variant: 'default' as const,
|
||||
icon: Building2,
|
||||
},
|
||||
{
|
||||
title: 'Puntuación Media',
|
||||
value: mockSupplierStats.averageScore.toFixed(1),
|
||||
variant: 'success' as const,
|
||||
icon: CheckCircle,
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<PageHeader
|
||||
title="Gestión de Proveedores"
|
||||
description="Administra y supervisa todos los proveedores de la panadería"
|
||||
actions={[
|
||||
{
|
||||
id: "export",
|
||||
label: "Exportar",
|
||||
variant: "outline" as const,
|
||||
icon: Download,
|
||||
onClick: () => console.log('Export suppliers')
|
||||
},
|
||||
{
|
||||
id: "new",
|
||||
label: "Nuevo Proveedor",
|
||||
variant: "primary" as const,
|
||||
icon: Plus,
|
||||
onClick: () => setShowForm(true)
|
||||
}
|
||||
]}
|
||||
/>
|
||||
|
||||
{/* Stats Grid */}
|
||||
<StatsGrid
|
||||
stats={stats}
|
||||
columns={3}
|
||||
/>
|
||||
|
||||
{/* Simplified Controls */}
|
||||
<Card className="p-4">
|
||||
<div className="flex flex-col sm:flex-row gap-4">
|
||||
<div className="flex-1">
|
||||
<Input
|
||||
placeholder="Buscar proveedores por nombre, código, email o contacto..."
|
||||
value={searchTerm}
|
||||
onChange={(e) => setSearchTerm(e.target.value)}
|
||||
className="w-full"
|
||||
/>
|
||||
</div>
|
||||
<Button variant="outline" onClick={() => console.log('Export filtered')}>
|
||||
<Download className="w-4 h-4 mr-2" />
|
||||
Exportar
|
||||
</Button>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* Suppliers Grid */}
|
||||
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
|
||||
{filteredSuppliers.map((supplier) => {
|
||||
const statusConfig = getSupplierStatusConfig(supplier.status);
|
||||
const performanceNote = supplier.performance_score
|
||||
? `Puntuación: ${supplier.performance_score}/100`
|
||||
: 'Sin evaluación';
|
||||
|
||||
return (
|
||||
<StatusCard
|
||||
key={supplier.id}
|
||||
id={supplier.id}
|
||||
statusIndicator={statusConfig}
|
||||
title={supplier.name}
|
||||
subtitle={supplier.supplier_code}
|
||||
primaryValue={formatters.currency(supplier.total_spend)}
|
||||
primaryValueLabel={`${supplier.total_orders} pedidos`}
|
||||
secondaryInfo={{
|
||||
label: 'Tipo',
|
||||
value: getSupplierTypeText(supplier.supplier_type)
|
||||
}}
|
||||
metadata={[
|
||||
supplier.contact_person || 'Sin contacto',
|
||||
supplier.email || 'Sin email',
|
||||
supplier.phone || 'Sin teléfono',
|
||||
performanceNote
|
||||
]}
|
||||
actions={[
|
||||
{
|
||||
label: 'Ver',
|
||||
icon: Eye,
|
||||
variant: 'outline',
|
||||
onClick: () => {
|
||||
setSelectedSupplier(supplier);
|
||||
setModalMode('view');
|
||||
setShowForm(true);
|
||||
}
|
||||
},
|
||||
{
|
||||
label: 'Editar',
|
||||
icon: Edit,
|
||||
variant: 'outline',
|
||||
onClick: () => {
|
||||
setSelectedSupplier(supplier);
|
||||
setModalMode('edit');
|
||||
setShowForm(true);
|
||||
}
|
||||
}
|
||||
]}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* 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>
|
||||
)}
|
||||
|
||||
{/* Supplier Details Modal */}
|
||||
{showForm && selectedSupplier && (
|
||||
<StatusModal
|
||||
isOpen={showForm}
|
||||
onClose={() => {
|
||||
setShowForm(false);
|
||||
setSelectedSupplier(null);
|
||||
setModalMode('view');
|
||||
}}
|
||||
mode={modalMode}
|
||||
onModeChange={setModalMode}
|
||||
title={selectedSupplier.name}
|
||||
subtitle={`Proveedor ${selectedSupplier.supplier_code}`}
|
||||
statusIndicator={getSupplierStatusConfig(selectedSupplier.status)}
|
||||
size="lg"
|
||||
sections={[
|
||||
{
|
||||
title: 'Información de Contacto',
|
||||
icon: Users,
|
||||
fields: [
|
||||
{
|
||||
label: 'Nombre',
|
||||
value: selectedSupplier.name,
|
||||
highlight: true
|
||||
},
|
||||
{
|
||||
label: 'Persona de Contacto',
|
||||
value: selectedSupplier.contact_person || 'No especificado'
|
||||
},
|
||||
{
|
||||
label: 'Email',
|
||||
value: selectedSupplier.email || 'No especificado'
|
||||
},
|
||||
{
|
||||
label: 'Teléfono',
|
||||
value: selectedSupplier.phone || 'No especificado'
|
||||
},
|
||||
{
|
||||
label: 'Ciudad',
|
||||
value: selectedSupplier.city || 'No especificado'
|
||||
},
|
||||
{
|
||||
label: 'País',
|
||||
value: selectedSupplier.country || 'No especificado'
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
title: 'Información Comercial',
|
||||
icon: Building2,
|
||||
fields: [
|
||||
{
|
||||
label: 'Código de Proveedor',
|
||||
value: selectedSupplier.supplier_code,
|
||||
highlight: true
|
||||
},
|
||||
{
|
||||
label: 'Tipo de Proveedor',
|
||||
value: getSupplierTypeText(selectedSupplier.supplier_type)
|
||||
},
|
||||
{
|
||||
label: 'Condiciones de Pago',
|
||||
value: getPaymentTermsText(selectedSupplier.payment_terms)
|
||||
},
|
||||
{
|
||||
label: 'Tiempo de Entrega',
|
||||
value: `${selectedSupplier.standard_lead_time} días`
|
||||
},
|
||||
{
|
||||
label: 'Pedido Mínimo',
|
||||
value: selectedSupplier.minimum_order_amount,
|
||||
type: 'currency'
|
||||
},
|
||||
{
|
||||
label: 'Límite de Crédito',
|
||||
value: selectedSupplier.credit_limit,
|
||||
type: 'currency'
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
title: 'Rendimiento y Estadísticas',
|
||||
icon: DollarSign,
|
||||
fields: [
|
||||
{
|
||||
label: 'Total de Pedidos',
|
||||
value: selectedSupplier.total_orders.toString()
|
||||
},
|
||||
{
|
||||
label: 'Gasto Total',
|
||||
value: selectedSupplier.total_spend,
|
||||
type: 'currency',
|
||||
highlight: true
|
||||
},
|
||||
{
|
||||
label: 'Puntuación de Rendimiento',
|
||||
value: selectedSupplier.performance_score ? `${selectedSupplier.performance_score}/100` : 'No evaluado'
|
||||
},
|
||||
{
|
||||
label: 'Último Pedido',
|
||||
value: selectedSupplier.last_order_date || 'Nunca',
|
||||
type: selectedSupplier.last_order_date ? 'datetime' : undefined
|
||||
}
|
||||
]
|
||||
},
|
||||
...(selectedSupplier.notes ? [{
|
||||
title: 'Notas',
|
||||
fields: [
|
||||
{
|
||||
label: 'Observaciones',
|
||||
value: selectedSupplier.notes,
|
||||
span: 2 as const
|
||||
}
|
||||
]
|
||||
}] : [])
|
||||
]}
|
||||
onEdit={() => {
|
||||
console.log('Editing supplier:', selectedSupplier.id);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default SuppliersPage;
|
||||
1
frontend/src/pages/app/operations/suppliers/index.ts
Normal file
1
frontend/src/pages/app/operations/suppliers/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { default as SuppliersPage } from './SuppliersPage';
|
||||
Reference in New Issue
Block a user