Create new services: inventory, recipes, suppliers

This commit is contained in:
Urtzi Alfaro
2025-08-13 17:39:35 +02:00
parent fbe7470ad9
commit 16b8a9d50c
151 changed files with 35799 additions and 857 deletions

View File

@@ -0,0 +1,542 @@
import React, { useState, useEffect, useCallback } from 'react';
import {
Search,
Plus,
Filter,
Download,
Upload,
Grid3X3,
List,
Package,
TrendingDown,
AlertTriangle,
Loader,
RefreshCw,
BarChart3,
Calendar
} from 'lucide-react';
import toast from 'react-hot-toast';
import { useInventory } from '../../api/hooks/useInventory';
import {
InventorySearchParams,
ProductType,
CreateInventoryItemRequest,
UpdateInventoryItemRequest,
StockAdjustmentRequest,
InventoryItem
} from '../../api/services/inventory.service';
import InventoryItemCard from '../../components/inventory/InventoryItemCard';
import StockAlertsPanel from '../../components/inventory/StockAlertsPanel';
type ViewMode = 'grid' | 'list';
interface FilterState {
search: string;
product_type?: ProductType;
category?: string;
is_active?: boolean;
low_stock_only?: boolean;
expiring_soon_only?: boolean;
sort_by?: 'name' | 'category' | 'stock_level' | 'last_movement' | 'created_at';
sort_order?: 'asc' | 'desc';
}
const InventoryPage: React.FC = () => {
const {
items,
stockLevels,
alerts,
dashboardData,
isLoading,
error,
pagination,
loadItems,
createItem,
updateItem,
deleteItem,
adjustStock,
acknowledgeAlert,
refresh,
clearError
} = useInventory();
// Local state
const [viewMode, setViewMode] = useState<ViewMode>('grid');
const [showAlerts, setShowAlerts] = useState(false);
const [filters, setFilters] = useState<FilterState>({
search: '',
sort_by: 'name',
sort_order: 'asc'
});
const [showFilters, setShowFilters] = useState(false);
const [selectedItems, setSelectedItems] = useState<Set<string>>(new Set());
// Load items when filters change
useEffect(() => {
const searchParams: InventorySearchParams = {
...filters,
page: 1,
limit: 20
};
// Remove empty values
Object.keys(searchParams).forEach(key => {
if (searchParams[key as keyof InventorySearchParams] === '' ||
searchParams[key as keyof InventorySearchParams] === undefined) {
delete searchParams[key as keyof InventorySearchParams];
}
});
loadItems(searchParams);
}, [filters, loadItems]);
// Handle search
const handleSearch = useCallback((value: string) => {
setFilters(prev => ({ ...prev, search: value }));
}, []);
// Handle filter changes
const handleFilterChange = useCallback((key: keyof FilterState, value: any) => {
setFilters(prev => ({ ...prev, [key]: value }));
}, []);
// Clear all filters
const clearFilters = useCallback(() => {
setFilters({
search: '',
sort_by: 'name',
sort_order: 'asc'
});
}, []);
// Handle item selection
const toggleItemSelection = (itemId: string) => {
const newSelection = new Set(selectedItems);
if (newSelection.has(itemId)) {
newSelection.delete(itemId);
} else {
newSelection.add(itemId);
}
setSelectedItems(newSelection);
};
// Handle stock adjustment
const handleStockAdjust = async (item: InventoryItem, adjustment: StockAdjustmentRequest) => {
const result = await adjustStock(item.id, adjustment);
if (result) {
// Refresh data to get updated stock levels
refresh();
}
};
// Handle item edit
const handleItemEdit = (item: InventoryItem) => {
// TODO: Open edit modal
console.log('Edit item:', item);
};
// Handle item view details
const handleItemViewDetails = (item: InventoryItem) => {
// TODO: Open details modal or navigate to details page
console.log('View details:', item);
};
// Handle alert acknowledgment
const handleAcknowledgeAlert = async (alertId: string) => {
await acknowledgeAlert(alertId);
};
// Handle bulk acknowledge alerts
const handleBulkAcknowledgeAlerts = async (alertIds: string[]) => {
// TODO: Implement bulk acknowledge
for (const alertId of alertIds) {
await acknowledgeAlert(alertId);
}
};
// Get quick stats
const getQuickStats = () => {
const totalItems = items.length;
const lowStockItems = alerts.filter(a => a.alert_type === 'low_stock' && !a.is_acknowledged).length;
const expiringItems = alerts.filter(a => a.alert_type === 'expiring_soon' && !a.is_acknowledged).length;
const totalValue = dashboardData?.total_value || 0;
return { totalItems, lowStockItems, expiringItems, totalValue };
};
const stats = getQuickStats();
return (
<div className="min-h-screen bg-gray-50">
{/* Header */}
<div className="bg-white border-b">
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<div className="py-6">
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold text-gray-900">Gestión de Inventario</h1>
<p className="text-gray-600 mt-1">
Administra tus productos, stock y alertas
</p>
</div>
<div className="flex items-center space-x-3">
<button
onClick={() => setShowAlerts(!showAlerts)}
className={`relative p-2 rounded-lg transition-colors ${
showAlerts ? 'bg-blue-100 text-blue-600' : 'bg-gray-100 text-gray-600 hover:bg-gray-200'
}`}
>
<AlertTriangle className="w-5 h-5" />
{alerts.filter(a => !a.is_acknowledged).length > 0 && (
<span className="absolute -top-1 -right-1 bg-red-500 text-white text-xs rounded-full w-5 h-5 flex items-center justify-center">
{alerts.filter(a => !a.is_acknowledged).length}
</span>
)}
</button>
<button
onClick={() => refresh()}
disabled={isLoading}
className="p-2 bg-gray-100 text-gray-600 rounded-lg hover:bg-gray-200 transition-colors disabled:opacity-50"
>
<RefreshCw className={`w-5 h-5 ${isLoading ? 'animate-spin' : ''}`} />
</button>
<button className="px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors flex items-center space-x-2">
<Plus className="w-4 h-4" />
<span>Nuevo Producto</span>
</button>
</div>
</div>
</div>
</div>
</div>
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-6">
<div className="grid grid-cols-1 lg:grid-cols-4 gap-6">
{/* Main Content */}
<div className={`${showAlerts ? 'lg:col-span-3' : 'lg:col-span-4'}`}>
{/* Quick Stats */}
<div className="grid grid-cols-2 lg:grid-cols-4 gap-4 mb-6">
<div className="bg-white p-4 rounded-lg border">
<div className="flex items-center">
<Package className="w-8 h-8 text-blue-600" />
<div className="ml-3">
<p className="text-sm text-gray-600">Total Productos</p>
<p className="text-2xl font-bold text-gray-900">{stats.totalItems}</p>
</div>
</div>
</div>
<div className="bg-white p-4 rounded-lg border">
<div className="flex items-center">
<TrendingDown className="w-8 h-8 text-yellow-600" />
<div className="ml-3">
<p className="text-sm text-gray-600">Stock Bajo</p>
<p className="text-2xl font-bold text-gray-900">{stats.lowStockItems}</p>
</div>
</div>
</div>
<div className="bg-white p-4 rounded-lg border">
<div className="flex items-center">
<Calendar className="w-8 h-8 text-red-600" />
<div className="ml-3">
<p className="text-sm text-gray-600">Por Vencer</p>
<p className="text-2xl font-bold text-gray-900">{stats.expiringItems}</p>
</div>
</div>
</div>
<div className="bg-white p-4 rounded-lg border">
<div className="flex items-center">
<BarChart3 className="w-8 h-8 text-green-600" />
<div className="ml-3">
<p className="text-sm text-gray-600">Valor Total</p>
<p className="text-2xl font-bold text-gray-900">
{stats.totalValue.toLocaleString()}
</p>
</div>
</div>
</div>
</div>
{/* Filters and Search */}
<div className="bg-white rounded-lg border mb-6 p-4">
<div className="flex flex-col lg:flex-row lg:items-center lg:justify-between space-y-4 lg:space-y-0">
{/* Search */}
<div className="flex-1 max-w-md">
<div className="relative">
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 text-gray-400 w-4 h-4" />
<input
type="text"
placeholder="Buscar productos..."
value={filters.search}
onChange={(e) => handleSearch(e.target.value)}
className="w-full pl-10 pr-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
/>
</div>
</div>
{/* View Controls */}
<div className="flex items-center space-x-2">
<button
onClick={() => setShowFilters(!showFilters)}
className={`px-3 py-2 rounded-lg transition-colors flex items-center space-x-1 ${
showFilters ? 'bg-blue-100 text-blue-600' : 'bg-gray-100 text-gray-600 hover:bg-gray-200'
}`}
>
<Filter className="w-4 h-4" />
<span>Filtros</span>
</button>
<div className="flex rounded-lg border">
<button
onClick={() => setViewMode('grid')}
className={`p-2 ${
viewMode === 'grid'
? 'bg-blue-600 text-white'
: 'bg-white text-gray-600 hover:bg-gray-50'
}`}
>
<Grid3X3 className="w-4 h-4" />
</button>
<button
onClick={() => setViewMode('list')}
className={`p-2 ${
viewMode === 'list'
? 'bg-blue-600 text-white'
: 'bg-white text-gray-600 hover:bg-gray-50'
}`}
>
<List className="w-4 h-4" />
</button>
</div>
</div>
</div>
{/* Advanced Filters */}
{showFilters && (
<div className="mt-4 pt-4 border-t">
<div className="grid grid-cols-1 md:grid-cols-3 lg:grid-cols-5 gap-4">
{/* Product Type */}
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Tipo de Producto
</label>
<select
value={filters.product_type || ''}
onChange={(e) => handleFilterChange('product_type', e.target.value || undefined)}
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
>
<option value="">Todos</option>
<option value="ingredient">Ingredientes</option>
<option value="finished_product">Productos Finales</option>
</select>
</div>
{/* Status */}
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Estado
</label>
<select
value={filters.is_active?.toString() || ''}
onChange={(e) => handleFilterChange('is_active',
e.target.value === '' ? undefined : e.target.value === 'true'
)}
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
>
<option value="">Todos</option>
<option value="true">Activos</option>
<option value="false">Inactivos</option>
</select>
</div>
{/* Stock Status */}
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Stock
</label>
<div className="space-y-2">
<label className="flex items-center">
<input
type="checkbox"
checked={filters.low_stock_only || false}
onChange={(e) => handleFilterChange('low_stock_only', e.target.checked || undefined)}
className="rounded border-gray-300 text-blue-600 focus:ring-blue-500"
/>
<span className="ml-2 text-sm text-gray-700">Stock bajo</span>
</label>
<label className="flex items-center">
<input
type="checkbox"
checked={filters.expiring_soon_only || false}
onChange={(e) => handleFilterChange('expiring_soon_only', e.target.checked || undefined)}
className="rounded border-gray-300 text-blue-600 focus:ring-blue-500"
/>
<span className="ml-2 text-sm text-gray-700">Por vencer</span>
</label>
</div>
</div>
{/* Sort By */}
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Ordenar por
</label>
<select
value={filters.sort_by || 'name'}
onChange={(e) => handleFilterChange('sort_by', e.target.value)}
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
>
<option value="name">Nombre</option>
<option value="category">Categoría</option>
<option value="stock_level">Nivel de Stock</option>
<option value="created_at">Fecha de Creación</option>
</select>
</div>
{/* Sort Order */}
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Orden
</label>
<select
value={filters.sort_order || 'asc'}
onChange={(e) => handleFilterChange('sort_order', e.target.value as 'asc' | 'desc')}
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
>
<option value="asc">Ascendente</option>
<option value="desc">Descendente</option>
</select>
</div>
</div>
<div className="mt-4 flex justify-end">
<button
onClick={clearFilters}
className="px-3 py-1 text-sm text-gray-600 hover:text-gray-800"
>
Limpiar filtros
</button>
</div>
</div>
)}
</div>
{/* Items Grid/List */}
{isLoading ? (
<div className="flex items-center justify-center py-12">
<Loader className="w-8 h-8 animate-spin text-blue-600" />
<span className="ml-3 text-gray-600">Cargando inventario...</span>
</div>
) : error ? (
<div className="bg-red-50 border border-red-200 rounded-lg p-6 text-center">
<AlertTriangle className="w-8 h-8 text-red-600 mx-auto mb-3" />
<h3 className="text-lg font-medium text-red-900 mb-2">Error al cargar inventario</h3>
<p className="text-red-700 mb-4">{error}</p>
<button
onClick={refresh}
className="px-4 py-2 bg-red-600 text-white rounded-lg hover:bg-red-700"
>
Reintentar
</button>
</div>
) : items.length === 0 ? (
<div className="bg-white rounded-lg border p-12 text-center">
<Package className="w-16 h-16 text-gray-400 mx-auto mb-4" />
<h3 className="text-lg font-medium text-gray-900 mb-2">
{filters.search || Object.values(filters).some(v => v)
? 'No se encontraron productos'
: 'No tienes productos en tu inventario'
}
</h3>
<p className="text-gray-600 mb-6">
{filters.search || Object.values(filters).some(v => v)
? 'Prueba ajustando los filtros de búsqueda'
: 'Comienza agregando tu primer producto al inventario'
}
</p>
<button className="px-6 py-3 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors">
Agregar Producto
</button>
</div>
) : (
<div>
<div className={
viewMode === 'grid'
? 'grid grid-cols-1 md:grid-cols-2 xl:grid-cols-3 gap-6'
: 'space-y-4'
}>
{items.map((item) => (
<InventoryItemCard
key={item.id}
item={item}
stockLevel={stockLevels[item.id]}
compact={viewMode === 'list'}
onEdit={handleItemEdit}
onViewDetails={handleItemViewDetails}
onStockAdjust={handleStockAdjust}
/>
))}
</div>
{/* Pagination */}
{pagination.totalPages > 1 && (
<div className="mt-8 flex items-center justify-between">
<div className="text-sm text-gray-600">
Mostrando {((pagination.page - 1) * pagination.limit) + 1} a{' '}
{Math.min(pagination.page * pagination.limit, pagination.total)} de{' '}
{pagination.total} productos
</div>
<div className="flex space-x-2">
{Array.from({ length: pagination.totalPages }, (_, i) => i + 1).map((page) => (
<button
key={page}
onClick={() => {
const searchParams: InventorySearchParams = {
...filters,
page,
limit: pagination.limit
};
loadItems(searchParams);
}}
className={`px-3 py-2 rounded-lg ${
page === pagination.page
? 'bg-blue-600 text-white'
: 'bg-white text-gray-600 hover:bg-gray-50 border'
}`}
>
{page}
</button>
))}
</div>
</div>
)}
</div>
)}
</div>
{/* Alerts Panel */}
{showAlerts && (
<div className="lg:col-span-1">
<StockAlertsPanel
alerts={alerts}
onAcknowledge={handleAcknowledgeAlert}
onAcknowledgeAll={handleBulkAcknowledgeAlerts}
onViewItem={handleItemViewDetails}
/>
</div>
)}
</div>
</div>
</div>
);
};
export default InventoryPage;

View File

@@ -3,6 +3,7 @@ import { ChevronLeft, ChevronRight, Upload, MapPin, Store, Factory, Check, Brain
import toast from 'react-hot-toast';
import SimplifiedTrainingProgress from '../../components/SimplifiedTrainingProgress';
import SmartHistoricalDataImport from '../../components/onboarding/SmartHistoricalDataImport';
import {
useTenant,
@@ -50,6 +51,7 @@ const MADRID_PRODUCTS = [
const OnboardingPage: React.FC<OnboardingPageProps> = ({ user, onComplete }) => {
const [currentStep, setCurrentStep] = useState(1);
const [isLoading, setIsLoading] = useState(false);
const [useSmartImport, setUseSmartImport] = useState(true); // New state for smart import
const manualNavigation = useRef(false);
// Enhanced onboarding with progress tracking
@@ -477,6 +479,11 @@ const OnboardingPage: React.FC<OnboardingPageProps> = ({ user, onComplete }) =>
}
return true;
case 2:
// Skip validation if using smart import (it handles its own validation)
if (useSmartImport) {
return true;
}
if (!bakeryData.csvFile) {
toast.error('Por favor, selecciona un archivo con tus datos históricos');
return false;
@@ -704,328 +711,373 @@ const OnboardingPage: React.FC<OnboardingPageProps> = ({ user, onComplete }) =>
);
case 2:
// If tenantId is not available, show loading or message
if (!tenantId) {
return (
<div className="text-center py-12">
<Loader className="h-8 w-8 animate-spin mx-auto mb-4 text-primary-500" />
<p className="text-gray-600">Preparando la importación inteligente...</p>
<p className="text-sm text-gray-500 mt-2">
Asegúrate de haber completado el paso anterior
</p>
</div>
);
}
// Use Smart Import by default, with option to switch to traditional
if (useSmartImport) {
return (
<div className="space-y-6">
{/* Header with import mode toggle */}
<div className="flex items-center justify-between mb-6">
<div>
<h3 className="text-xl font-semibold text-gray-900">
Importación Inteligente de Datos 🧠
</h3>
<p className="text-gray-600 mt-1">
Nuestra IA creará automáticamente tu inventario desde tus datos históricos
</p>
</div>
<button
onClick={() => setUseSmartImport(false)}
className="flex items-center space-x-2 px-4 py-2 text-sm border border-gray-300 rounded-lg hover:bg-gray-50 transition-colors"
>
<Upload className="w-4 h-4" />
<span>Importación tradicional</span>
</button>
</div>
{/* Smart Import Component */}
<SmartHistoricalDataImport
tenantId={tenantId}
onComplete={(result) => {
// Mark sales data as uploaded and proceed to training
completeStep('sales_data_uploaded', {
smart_import: true,
records_imported: result.successful_imports,
import_job_id: result.import_job_id,
tenant_id: tenantId,
user_id: user?.id
}).then(() => {
setBakeryData(prev => ({ ...prev, hasHistoricalData: true }));
startTraining();
}).catch(() => {
// Continue even if step completion fails
setBakeryData(prev => ({ ...prev, hasHistoricalData: true }));
startTraining();
});
}}
onBack={() => setUseSmartImport(false)}
/>
</div>
);
}
// Traditional import fallback
return (
<div className="space-y-6">
<div>
<h3 className="text-lg font-semibold text-gray-900 mb-4">
Datos Históricos
</h3>
<p className="text-gray-600 mb-6">
Para obtener predicciones precisas, necesitamos tus datos históricos de ventas.
Puedes subir archivos en varios formatos.
</p>
{/* Header with import mode toggle */}
<div className="flex items-center justify-between mb-6">
<div>
<h3 className="text-lg font-semibold text-gray-900">
Datos Históricos (Modo Tradicional)
</h3>
<p className="text-gray-600 mt-1">
Sube tus datos y configura tu inventario manualmente
</p>
</div>
<div className="bg-blue-50 border border-blue-200 p-4 rounded-lg mb-6">
<div className="flex items-start">
<div className="flex-shrink-0">
<div className="w-6 h-6 bg-blue-500 rounded-full flex items-center justify-center">
<span className="text-white text-sm font-bold">!</span>
</div>
</div>
<div className="ml-3">
<h4 className="text-sm font-medium text-blue-900">
Formatos soportados y estructura de datos
</h4>
<div className="mt-2 text-sm text-blue-700">
<p className="mb-3"><strong>Formatos aceptados:</strong></p>
<div className="grid grid-cols-2 gap-4 mb-3">
<div>
<p className="font-medium">📊 Hojas de cálculo:</p>
<ul className="list-disc list-inside text-xs space-y-1">
<li>.xlsx (Excel moderno)</li>
<li>.xls (Excel clásico)</li>
</ul>
</div>
<div>
<p className="font-medium">📄 Datos estructurados:</p>
<ul className="list-disc list-inside text-xs space-y-1">
<li>.csv (Valores separados por comas)</li>
<li>.json (Formato JSON)</li>
</ul>
</div>
</div>
<p className="mb-2"><strong>Columnas requeridas (en cualquier idioma):</strong></p>
<ul className="list-disc list-inside text-xs space-y-1">
<li><strong>Fecha</strong>: fecha, date, datum (formato: YYYY-MM-DD, DD/MM/YYYY, etc.)</li>
<li><strong>Producto</strong>: producto, product, item, articulo, nombre</li>
<li><strong>Cantidad</strong>: cantidad, quantity, cantidad_vendida, qty</li>
</ul>
</div>
<button
onClick={() => setUseSmartImport(true)}
className="flex items-center space-x-2 px-4 py-2 text-sm bg-gradient-to-r from-blue-500 to-purple-500 text-white rounded-lg hover:from-blue-600 hover:to-purple-600 transition-colors"
>
<Brain className="w-4 h-4" />
<span>Activar IA</span>
</button>
</div>
<p className="text-gray-600 mb-6">
Para obtener predicciones precisas, necesitamos tus datos históricos de ventas.
Puedes subir archivos en varios formatos.
</p>
<div className="bg-blue-50 border border-blue-200 p-4 rounded-lg mb-6">
<div className="flex items-start">
<div className="flex-shrink-0">
<div className="w-6 h-6 bg-blue-500 rounded-full flex items-center justify-center">
<span className="text-white text-sm font-bold">!</span>
</div>
</div>
</div>
<div className="mt-6 p-6 border-2 border-dashed border-gray-300 rounded-xl hover:border-primary-300 transition-colors">
<div className="text-center">
<Upload className="mx-auto h-12 w-12 text-gray-400" />
<div className="mt-4">
<label htmlFor="sales-file-upload" className="cursor-pointer">
<span className="mt-2 block text-sm font-medium text-gray-900">
Subir archivo de datos históricos
</span>
<span className="mt-1 block text-sm text-gray-500">
Arrastra y suelta tu archivo aquí, o haz clic para seleccionar
</span>
<span className="mt-1 block text-xs text-gray-400">
Máximo 10MB - CSV, Excel (.xlsx, .xls), JSON
</span>
</label>
<input
id="sales-file-upload"
type="file"
accept=".csv,.xlsx,.xls,.json"
required
onChange={async (e) => {
const file = e.target.files?.[0];
if (file) {
// Validate file size (10MB limit)
const maxSize = 10 * 1024 * 1024;
if (file.size > maxSize) {
toast.error('El archivo es demasiado grande. Máximo 10MB.');
return;
}
// Update bakery data with the selected file
setBakeryData(prev => ({
...prev,
csvFile: file,
hasHistoricalData: true
}));
toast.success(`Archivo ${file.name} seleccionado correctamente`);
// Auto-validate the file after upload if tenantId exists
if (tenantId) {
setValidationStatus({ status: 'validating' });
try {
const validationResult = await validateSalesData(tenantId, file);
if (validationResult.is_valid) {
setValidationStatus({
status: 'valid',
message: validationResult.message,
records: validationResult.details?.total_records || 0
});
toast.success('¡Archivo validado correctamente!');
} else {
setValidationStatus({
status: 'invalid',
message: validationResult.message
});
toast.error(`Error en validación: ${validationResult.message}`);
}
} catch (error: any) {
setValidationStatus({
status: 'invalid',
message: 'Error al validar el archivo'
});
toast.error('Error al validar el archivo');
}
} else {
// If no tenantId yet, set to idle and wait for manual validation
setValidationStatus({ status: 'idle' });
}
}
}}
className="hidden"
/>
</div>
</div>
{bakeryData.csvFile ? (
<div className="mt-4 space-y-3">
<div className="p-4 bg-gray-50 rounded-lg">
<div className="flex items-center justify-between">
<div className="flex items-center">
<CheckCircle className="h-5 w-5 text-gray-500 mr-2" />
<div>
<p className="text-sm font-medium text-gray-700">
{bakeryData.csvFile.name}
</p>
<p className="text-xs text-gray-600">
{(bakeryData.csvFile.size / 1024).toFixed(1)} KB {bakeryData.csvFile.type || 'Archivo de datos'}
</p>
</div>
</div>
<button
onClick={() => {
setBakeryData(prev => ({ ...prev, csvFile: undefined }));
setValidationStatus({ status: 'idle' });
}}
className="text-red-600 hover:text-red-800 text-sm"
>
Quitar
</button>
<div className="ml-3">
<h4 className="text-sm font-medium text-blue-900">
Formatos soportados y estructura de datos
</h4>
<div className="mt-2 text-sm text-blue-700">
<p className="mb-3"><strong>Formatos aceptados:</strong></p>
<div className="grid grid-cols-2 gap-4 mb-3">
<div>
<p className="font-medium">📊 Hojas de cálculo:</p>
<ul className="list-disc list-inside text-xs space-y-1">
<li>.xlsx (Excel moderno)</li>
<li>.xls (Excel clásico)</li>
</ul>
</div>
<div>
<p className="font-medium">📄 Datos estructurados:</p>
<ul className="list-disc list-inside text-xs space-y-1">
<li>.csv (Valores separados por comas)</li>
<li>.json (Formato JSON)</li>
</ul>
</div>
</div>
{/* Validation Section */}
<div className="p-4 border rounded-lg">
<div className="flex items-center justify-between mb-3">
<h4 className="text-sm font-medium text-gray-900">
Validación de datos
</h4>
{validationStatus.status === 'validating' ? (
<Loader className="h-4 w-4 animate-spin text-blue-500" />
) : validationStatus.status === 'valid' ? (
<CheckCircle className="h-4 w-4 text-green-500" />
) : validationStatus.status === 'invalid' ? (
<AlertTriangle className="h-4 w-4 text-red-500" />
) : (
<Clock className="h-4 w-4 text-gray-400" />
)}
</div>
{validationStatus.status === 'idle' && tenantId ? (
<div className="space-y-2">
<p className="text-sm text-gray-600">
Valida tu archivo para verificar que tiene el formato correcto.
</p>
<button
onClick={validateSalesFile}
disabled={isLoading}
className="w-full px-4 py-2 bg-blue-600 text-white text-sm font-medium rounded-lg hover:bg-blue-700 disabled:opacity-50 disabled:cursor-not-allowed"
>
Validar archivo
</button>
</div>
) : (
<div className="space-y-2">
{!tenantId ? (
<div className="p-3 bg-yellow-50 border border-yellow-200 rounded-lg">
<p className="text-sm text-yellow-800">
No se ha encontrado la panadería registrada.
</p>
<p className="text-xs text-yellow-600 mt-1">
Ve al paso anterior para registrar tu panadería o espera mientras se carga desde el servidor.
</p>
</div>
) : validationStatus.status !== 'idle' ? (
<button
onClick={() => setValidationStatus({ status: 'idle' })}
className="px-4 py-2 bg-gray-500 text-white text-sm rounded-lg hover:bg-gray-600"
>
Resetear validación
</button>
) : null}
</div>
)}
{validationStatus.status === 'validating' && (
<p className="text-sm text-blue-600">
Validando archivo... Por favor espera.
</p>
)}
{validationStatus.status === 'valid' && (
<div className="space-y-2">
<p className="text-sm text-green-700">
Archivo validado correctamente
</p>
{validationStatus.records && (
<p className="text-xs text-green-600">
{validationStatus.records} registros encontrados
</p>
)}
{validationStatus.message && (
<p className="text-xs text-green-600">
{validationStatus.message}
</p>
)}
</div>
)}
{validationStatus.status === 'invalid' && (
<div className="space-y-2">
<p className="text-sm text-red-700">
Error en validación
</p>
{validationStatus.message && (
<p className="text-xs text-red-600">
{validationStatus.message}
</p>
)}
<button
onClick={validateSalesFile}
disabled={isLoading}
className="w-full px-4 py-2 bg-blue-600 text-white text-sm font-medium rounded-lg hover:bg-blue-700 disabled:opacity-50 disabled:cursor-not-allowed"
>
Validar de nuevo
</button>
</div>
)}
</div>
</div>
) : (
<div className="mt-4 p-4 bg-yellow-50 border border-yellow-200 rounded-lg">
<div className="flex items-center">
<AlertTriangle className="h-5 w-5 text-yellow-600 mr-2" />
<p className="text-sm text-yellow-800">
<strong>Archivo requerido:</strong> Selecciona un archivo con tus datos históricos de ventas
</p>
</div>
</div>
)}
</div>
{/* Sample formats examples */}
<div className="mt-6 space-y-4">
<h5 className="text-sm font-medium text-gray-900">
Ejemplos de formato:
</h5>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
{/* CSV Example */}
<div className="bg-gray-50 p-4 rounded-lg">
<div className="flex items-center mb-2">
<span className="text-sm font-medium text-gray-900">📄 CSV</span>
</div>
<div className="bg-white p-3 rounded border text-xs font-mono">
<div className="text-gray-600">fecha,producto,cantidad</div>
<div>2024-01-15,Croissants,45</div>
<div>2024-01-15,Pan de molde,32</div>
<div>2024-01-16,Baguettes,28</div>
</div>
</div>
{/* Excel Example */}
<div className="bg-gray-50 p-4 rounded-lg">
<div className="flex items-center mb-2">
<span className="text-sm font-medium text-gray-900">📊 Excel</span>
</div>
<div className="bg-white p-3 rounded border text-xs">
<table className="w-full">
<thead>
<tr className="bg-gray-100">
<th className="p-1 text-left">Fecha</th>
<th className="p-1 text-left">Producto</th>
<th className="p-1 text-left">Cantidad</th>
</tr>
</thead>
<tbody>
<tr><td className="p-1">15/01/2024</td><td className="p-1">Croissants</td><td className="p-1">45</td></tr>
<tr><td className="p-1">15/01/2024</td><td className="p-1">Pan molde</td><td className="p-1">32</td></tr>
</tbody>
</table>
</div>
</div>
</div>
{/* JSON Example */}
<div className="bg-gray-50 p-4 rounded-lg">
<div className="flex items-center mb-2">
<span className="text-sm font-medium text-gray-900">🔧 JSON</span>
</div>
<div className="bg-white p-3 rounded border text-xs font-mono">
<div className="text-gray-600">[</div>
<div className="ml-2">{"{"}"fecha": "2024-01-15", "producto": "Croissants", "cantidad": 45{"}"},</div>
<div className="ml-2">{"{"}"fecha": "2024-01-15", "producto": "Pan de molde", "cantidad": 32{"}"}</div>
<div className="text-gray-600">]</div>
<p className="mb-2"><strong>Columnas requeridas (en cualquier idioma):</strong></p>
<ul className="list-disc list-inside text-xs space-y-1">
<li><strong>Fecha</strong>: fecha, date, datum (formato: YYYY-MM-DD, DD/MM/YYYY, etc.)</li>
<li><strong>Producto</strong>: producto, product, item, articulo, nombre</li>
<li><strong>Cantidad</strong>: cantidad, quantity, cantidad_vendida, qty</li>
</ul>
</div>
</div>
</div>
</div>
<div className="mt-6 p-6 border-2 border-dashed border-gray-300 rounded-xl hover:border-primary-300 transition-colors">
<div className="text-center">
<Upload className="mx-auto h-12 w-12 text-gray-400" />
<div className="mt-4">
<label htmlFor="sales-file-upload" className="cursor-pointer">
<span className="mt-2 block text-sm font-medium text-gray-900">
Subir archivo de datos históricos
</span>
<span className="mt-1 block text-sm text-gray-500">
Arrastra y suelta tu archivo aquí, o haz clic para seleccionar
</span>
<span className="mt-1 block text-xs text-gray-400">
Máximo 10MB - CSV, Excel (.xlsx, .xls), JSON
</span>
</label>
<input
id="sales-file-upload"
type="file"
accept=".csv,.xlsx,.xls,.json"
required
onChange={async (e) => {
const file = e.target.files?.[0];
if (file) {
// Validate file size (10MB limit)
const maxSize = 10 * 1024 * 1024;
if (file.size > maxSize) {
toast.error('El archivo es demasiado grande. Máximo 10MB.');
return;
}
// Update bakery data with the selected file
setBakeryData(prev => ({
...prev,
csvFile: file,
hasHistoricalData: true
}));
toast.success(`Archivo ${file.name} seleccionado correctamente`);
// Auto-validate the file after upload if tenantId exists
if (tenantId) {
setValidationStatus({ status: 'validating' });
try {
const validationResult = await validateSalesData(tenantId, file);
if (validationResult.is_valid) {
setValidationStatus({
status: 'valid',
message: validationResult.message,
records: validationResult.details?.total_records || 0
});
toast.success('¡Archivo validado correctamente!');
} else {
setValidationStatus({
status: 'invalid',
message: validationResult.message
});
toast.error(`Error en validación: ${validationResult.message}`);
}
} catch (error: any) {
setValidationStatus({
status: 'invalid',
message: 'Error al validar el archivo'
});
toast.error('Error al validar el archivo');
}
} else {
// If no tenantId yet, set to idle and wait for manual validation
setValidationStatus({ status: 'idle' });
}
}
}}
className="hidden"
/>
</div>
</div>
{bakeryData.csvFile ? (
<div className="mt-4 space-y-3">
<div className="p-4 bg-gray-50 rounded-lg">
<div className="flex items-center justify-between">
<div className="flex items-center">
<CheckCircle className="h-5 w-5 text-gray-500 mr-2" />
<div>
<p className="text-sm font-medium text-gray-700">
{bakeryData.csvFile.name}
</p>
<p className="text-xs text-gray-600">
{(bakeryData.csvFile.size / 1024).toFixed(1)} KB {bakeryData.csvFile.type || 'Archivo de datos'}
</p>
</div>
</div>
<button
onClick={() => {
setBakeryData(prev => ({ ...prev, csvFile: undefined }));
setValidationStatus({ status: 'idle' });
}}
className="text-red-600 hover:text-red-800 text-sm"
>
Quitar
</button>
</div>
</div>
{/* Validation Section */}
<div className="p-4 border rounded-lg">
<div className="flex items-center justify-between mb-3">
<h4 className="text-sm font-medium text-gray-900">
Validación de datos
</h4>
{validationStatus.status === 'validating' ? (
<Loader className="h-4 w-4 animate-spin text-blue-500" />
) : validationStatus.status === 'valid' ? (
<CheckCircle className="h-4 w-4 text-green-500" />
) : validationStatus.status === 'invalid' ? (
<AlertTriangle className="h-4 w-4 text-red-500" />
) : (
<Clock className="h-4 w-4 text-gray-400" />
)}
</div>
{validationStatus.status === 'idle' && tenantId ? (
<div className="space-y-2">
<p className="text-sm text-gray-600">
Valida tu archivo para verificar que tiene el formato correcto.
</p>
<button
onClick={validateSalesFile}
disabled={isLoading}
className="w-full px-4 py-2 bg-blue-600 text-white text-sm font-medium rounded-lg hover:bg-blue-700 disabled:opacity-50 disabled:cursor-not-allowed"
>
Validar archivo
</button>
</div>
) : (
<div className="space-y-2">
{!tenantId ? (
<div className="p-3 bg-yellow-50 border border-yellow-200 rounded-lg">
<p className="text-sm text-yellow-800">
No se ha encontrado la panadería registrada.
</p>
<p className="text-xs text-yellow-600 mt-1">
Ve al paso anterior para registrar tu panadería o espera mientras se carga desde el servidor.
</p>
</div>
) : validationStatus.status !== 'idle' ? (
<button
onClick={() => setValidationStatus({ status: 'idle' })}
className="px-4 py-2 bg-gray-500 text-white text-sm rounded-lg hover:bg-gray-600"
>
Resetear validación
</button>
) : null}
</div>
)}
{validationStatus.status === 'validating' && (
<p className="text-sm text-blue-600">
Validando archivo... Por favor espera.
</p>
)}
{validationStatus.status === 'valid' && (
<div className="space-y-2">
<p className="text-sm text-green-700">
Archivo validado correctamente
</p>
{validationStatus.records && (
<p className="text-xs text-green-600">
{validationStatus.records} registros encontrados
</p>
)}
{validationStatus.message && (
<p className="text-xs text-green-600">
{validationStatus.message}
</p>
)}
</div>
)}
{validationStatus.status === 'invalid' && (
<div className="space-y-2">
<p className="text-sm text-red-700">
Error en validación
</p>
{validationStatus.message && (
<p className="text-xs text-red-600">
{validationStatus.message}
</p>
)}
<button
onClick={validateSalesFile}
disabled={isLoading}
className="w-full px-4 py-2 bg-blue-600 text-white text-sm font-medium rounded-lg hover:bg-blue-700 disabled:opacity-50 disabled:cursor-not-allowed"
>
Validar de nuevo
</button>
</div>
)}
</div>
</div>
) : (
<div className="mt-4 p-4 bg-yellow-50 border border-yellow-200 rounded-lg">
<div className="flex items-center">
<AlertTriangle className="h-5 w-5 text-yellow-600 mr-2" />
<p className="text-sm text-yellow-800">
<strong>Archivo requerido:</strong> Selecciona un archivo con tus datos históricos de ventas
</p>
</div>
</div>
)}
</div>
{/* Show switch to smart import suggestion if traditional validation fails */}
{validationStatus.status === 'invalid' && (
<div className="bg-blue-50 border border-blue-200 rounded-lg p-4">
<div className="flex items-start space-x-3">
<Brain className="w-5 h-5 text-blue-500 flex-shrink-0 mt-0.5" />
<div>
<h4 className="text-sm font-medium text-blue-900 mb-2">
💡 ¿Problemas con la validación?
</h4>
<p className="text-sm text-blue-700 mb-3">
Nuestra IA puede manejar archivos con formatos más flexibles y ayudarte a solucionar problemas automáticamente.
</p>
<button
onClick={() => setUseSmartImport(true)}
className="px-4 py-2 bg-blue-600 text-white text-sm rounded-lg hover:bg-blue-700 transition-colors"
>
Probar importación inteligente
</button>
</div>
</div>
</div>
)}
</div>
);
@@ -1316,8 +1368,8 @@ const OnboardingPage: React.FC<OnboardingPageProps> = ({ user, onComplete }) =>
{renderStep()}
</main>
{/* Navigation with Enhanced Accessibility - Hidden during training (step 3) and completion (step 4) */}
{currentStep < 3 && (
{/* Navigation with Enhanced Accessibility - Hidden during training (step 3), completion (step 4), and smart import */}
{currentStep < 3 && !(currentStep === 2 && useSmartImport) && (
<nav
className="flex justify-between items-center bg-white rounded-3xl shadow-sm p-6"
role="navigation"

View File

@@ -0,0 +1,517 @@
// frontend/src/pages/recipes/RecipesPage.tsx
import React, { useState, useEffect, useCallback } from 'react';
import {
Search,
Plus,
Filter,
Grid3X3,
List,
ChefHat,
TrendingUp,
AlertTriangle,
Loader,
RefreshCw,
BarChart3,
Star,
Calendar,
Download,
Upload
} from 'lucide-react';
import toast from 'react-hot-toast';
import { useRecipes } from '../../api/hooks/useRecipes';
import { Recipe, RecipeSearchParams } from '../../api/services/recipes.service';
import RecipeCard from '../../components/recipes/RecipeCard';
type ViewMode = 'grid' | 'list';
interface FilterState {
search: string;
status?: string;
category?: string;
is_seasonal?: boolean;
is_signature?: boolean;
difficulty_level?: number;
}
const RecipesPage: React.FC = () => {
const {
recipes,
categories,
statistics,
isLoading,
isCreating,
error,
pagination,
loadRecipes,
createRecipe,
updateRecipe,
deleteRecipe,
duplicateRecipe,
activateRecipe,
checkFeasibility,
loadStatistics,
clearError,
refresh,
setPage
} = useRecipes();
// Local state
const [viewMode, setViewMode] = useState<ViewMode>('grid');
const [filters, setFilters] = useState<FilterState>({
search: ''
});
const [showFilters, setShowFilters] = useState(false);
const [selectedRecipes, setSelectedRecipes] = useState<Set<string>>(new Set());
const [feasibilityResults, setFeasibilityResults] = useState<Map<string, any>>(new Map());
// Load recipes when filters change
useEffect(() => {
const searchParams: RecipeSearchParams = {
search_term: filters.search || undefined,
status: filters.status || undefined,
category: filters.category || undefined,
is_seasonal: filters.is_seasonal,
is_signature: filters.is_signature,
difficulty_level: filters.difficulty_level,
limit: 20,
offset: (pagination.page - 1) * 20
};
// Remove undefined values
Object.keys(searchParams).forEach(key => {
if (searchParams[key as keyof RecipeSearchParams] === undefined) {
delete searchParams[key as keyof RecipeSearchParams];
}
});
loadRecipes(searchParams);
}, [filters, pagination.page, loadRecipes]);
// Handle search
const handleSearch = useCallback((value: string) => {
setFilters(prev => ({ ...prev, search: value }));
setPage(1); // Reset to first page
}, [setPage]);
// Handle filter changes
const handleFilterChange = useCallback((key: keyof FilterState, value: any) => {
setFilters(prev => ({ ...prev, [key]: value }));
setPage(1); // Reset to first page
}, [setPage]);
// Clear all filters
const clearFilters = useCallback(() => {
setFilters({ search: '' });
setPage(1);
}, [setPage]);
// Handle recipe selection
const toggleRecipeSelection = (recipeId: string) => {
const newSelection = new Set(selectedRecipes);
if (newSelection.has(recipeId)) {
newSelection.delete(recipeId);
} else {
newSelection.add(recipeId);
}
setSelectedRecipes(newSelection);
};
// Handle recipe actions
const handleViewRecipe = (recipe: Recipe) => {
// TODO: Navigate to recipe details page or open modal
console.log('View recipe:', recipe);
};
const handleEditRecipe = (recipe: Recipe) => {
// TODO: Navigate to recipe edit page or open modal
console.log('Edit recipe:', recipe);
};
const handleDuplicateRecipe = async (recipe: Recipe) => {
const newName = prompt(`Enter name for duplicated recipe:`, `${recipe.name} (Copy)`);
if (newName && newName.trim()) {
const result = await duplicateRecipe(recipe.id, newName.trim());
if (result) {
toast.success('Recipe duplicated successfully');
}
}
};
const handleActivateRecipe = async (recipe: Recipe) => {
if (confirm(`Are you sure you want to activate "${recipe.name}"?`)) {
const result = await activateRecipe(recipe.id);
if (result) {
toast.success('Recipe activated successfully');
}
}
};
const handleCheckFeasibility = async (recipe: Recipe) => {
const result = await checkFeasibility(recipe.id, 1.0);
if (result) {
setFeasibilityResults(prev => new Map(prev.set(recipe.id, result)));
if (result.feasible) {
toast.success('Recipe can be produced with current inventory');
} else {
toast.error('Recipe cannot be produced - missing ingredients');
}
}
};
const handleDeleteRecipe = async (recipe: Recipe) => {
if (confirm(`Are you sure you want to delete "${recipe.name}"? This action cannot be undone.`)) {
const success = await deleteRecipe(recipe.id);
if (success) {
toast.success('Recipe deleted successfully');
}
}
};
// Get quick stats
const getQuickStats = () => {
if (!statistics) {
return {
totalRecipes: recipes.length,
activeRecipes: recipes.filter(r => r.status === 'active').length,
signatureRecipes: recipes.filter(r => r.is_signature_item).length,
seasonalRecipes: recipes.filter(r => r.is_seasonal).length
};
}
return {
totalRecipes: statistics.total_recipes,
activeRecipes: statistics.active_recipes,
signatureRecipes: statistics.signature_recipes,
seasonalRecipes: statistics.seasonal_recipes
};
};
const stats = getQuickStats();
return (
<div className="min-h-screen bg-gray-50">
{/* Header */}
<div className="bg-white border-b">
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<div className="py-6">
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold text-gray-900">Recipe Management</h1>
<p className="text-gray-600 mt-1">
Create and manage your bakery recipes
</p>
</div>
<div className="flex items-center space-x-3">
<button
onClick={() => refresh()}
disabled={isLoading}
className="p-2 bg-gray-100 text-gray-600 rounded-lg hover:bg-gray-200 transition-colors disabled:opacity-50"
>
<RefreshCw className={`w-5 h-5 ${isLoading ? 'animate-spin' : ''}`} />
</button>
<button className="px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors flex items-center space-x-2">
<Plus className="w-4 h-4" />
<span>New Recipe</span>
</button>
</div>
</div>
</div>
</div>
</div>
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-6">
{/* Quick Stats */}
<div className="grid grid-cols-2 lg:grid-cols-4 gap-4 mb-6">
<div className="bg-white p-4 rounded-lg border">
<div className="flex items-center">
<ChefHat className="w-8 h-8 text-blue-600" />
<div className="ml-3">
<p className="text-sm text-gray-600">Total Recipes</p>
<p className="text-2xl font-bold text-gray-900">{stats.totalRecipes}</p>
</div>
</div>
</div>
<div className="bg-white p-4 rounded-lg border">
<div className="flex items-center">
<TrendingUp className="w-8 h-8 text-green-600" />
<div className="ml-3">
<p className="text-sm text-gray-600">Active Recipes</p>
<p className="text-2xl font-bold text-gray-900">{stats.activeRecipes}</p>
</div>
</div>
</div>
<div className="bg-white p-4 rounded-lg border">
<div className="flex items-center">
<Star className="w-8 h-8 text-yellow-600" />
<div className="ml-3">
<p className="text-sm text-gray-600">Signature Items</p>
<p className="text-2xl font-bold text-gray-900">{stats.signatureRecipes}</p>
</div>
</div>
</div>
<div className="bg-white p-4 rounded-lg border">
<div className="flex items-center">
<Calendar className="w-8 h-8 text-purple-600" />
<div className="ml-3">
<p className="text-sm text-gray-600">Seasonal Items</p>
<p className="text-2xl font-bold text-gray-900">{stats.seasonalRecipes}</p>
</div>
</div>
</div>
</div>
{/* Filters and Search */}
<div className="bg-white rounded-lg border mb-6 p-4">
<div className="flex flex-col lg:flex-row lg:items-center lg:justify-between space-y-4 lg:space-y-0">
{/* Search */}
<div className="flex-1 max-w-md">
<div className="relative">
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 text-gray-400 w-4 h-4" />
<input
type="text"
placeholder="Search recipes..."
value={filters.search}
onChange={(e) => handleSearch(e.target.value)}
className="w-full pl-10 pr-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
/>
</div>
</div>
{/* View Controls */}
<div className="flex items-center space-x-2">
<button
onClick={() => setShowFilters(!showFilters)}
className={`px-3 py-2 rounded-lg transition-colors flex items-center space-x-1 ${
showFilters ? 'bg-blue-100 text-blue-600' : 'bg-gray-100 text-gray-600 hover:bg-gray-200'
}`}
>
<Filter className="w-4 h-4" />
<span>Filters</span>
</button>
<div className="flex rounded-lg border">
<button
onClick={() => setViewMode('grid')}
className={`p-2 ${
viewMode === 'grid'
? 'bg-blue-600 text-white'
: 'bg-white text-gray-600 hover:bg-gray-50'
}`}
>
<Grid3X3 className="w-4 h-4" />
</button>
<button
onClick={() => setViewMode('list')}
className={`p-2 ${
viewMode === 'list'
? 'bg-blue-600 text-white'
: 'bg-white text-gray-600 hover:bg-gray-50'
}`}
>
<List className="w-4 h-4" />
</button>
</div>
</div>
</div>
{/* Advanced Filters */}
{showFilters && (
<div className="mt-4 pt-4 border-t">
<div className="grid grid-cols-1 md:grid-cols-3 lg:grid-cols-5 gap-4">
{/* Status */}
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Status
</label>
<select
value={filters.status || ''}
onChange={(e) => handleFilterChange('status', e.target.value || undefined)}
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
>
<option value="">All Statuses</option>
<option value="draft">Draft</option>
<option value="active">Active</option>
<option value="testing">Testing</option>
<option value="archived">Archived</option>
<option value="discontinued">Discontinued</option>
</select>
</div>
{/* Category */}
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Category
</label>
<select
value={filters.category || ''}
onChange={(e) => handleFilterChange('category', e.target.value || undefined)}
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
>
<option value="">All Categories</option>
{categories.map(category => (
<option key={category} value={category}>{category}</option>
))}
</select>
</div>
{/* Difficulty */}
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Difficulty
</label>
<select
value={filters.difficulty_level || ''}
onChange={(e) => handleFilterChange('difficulty_level',
e.target.value ? parseInt(e.target.value) : undefined
)}
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
>
<option value="">All Levels</option>
<option value="1">Level 1 (Easy)</option>
<option value="2">Level 2</option>
<option value="3">Level 3</option>
<option value="4">Level 4</option>
<option value="5">Level 5 (Hard)</option>
</select>
</div>
{/* Special Types */}
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Special Types
</label>
<div className="space-y-2">
<label className="flex items-center">
<input
type="checkbox"
checked={filters.is_signature || false}
onChange={(e) => handleFilterChange('is_signature', e.target.checked || undefined)}
className="rounded border-gray-300 text-blue-600 focus:ring-blue-500"
/>
<span className="ml-2 text-sm text-gray-700">Signature items</span>
</label>
<label className="flex items-center">
<input
type="checkbox"
checked={filters.is_seasonal || false}
onChange={(e) => handleFilterChange('is_seasonal', e.target.checked || undefined)}
className="rounded border-gray-300 text-blue-600 focus:ring-blue-500"
/>
<span className="ml-2 text-sm text-gray-700">Seasonal items</span>
</label>
</div>
</div>
{/* Clear Filters */}
<div className="flex items-end">
<button
onClick={clearFilters}
className="px-3 py-2 text-sm text-gray-600 hover:text-gray-800"
>
Clear filters
</button>
</div>
</div>
</div>
)}
</div>
{/* Recipes Grid/List */}
{isLoading ? (
<div className="flex items-center justify-center py-12">
<Loader className="w-8 h-8 animate-spin text-blue-600" />
<span className="ml-3 text-gray-600">Loading recipes...</span>
</div>
) : error ? (
<div className="bg-red-50 border border-red-200 rounded-lg p-6 text-center">
<AlertTriangle className="w-8 h-8 text-red-600 mx-auto mb-3" />
<h3 className="text-lg font-medium text-red-900 mb-2">Error loading recipes</h3>
<p className="text-red-700 mb-4">{error}</p>
<button
onClick={refresh}
className="px-4 py-2 bg-red-600 text-white rounded-lg hover:bg-red-700"
>
Try Again
</button>
</div>
) : recipes.length === 0 ? (
<div className="bg-white rounded-lg border p-12 text-center">
<ChefHat className="w-16 h-16 text-gray-400 mx-auto mb-4" />
<h3 className="text-lg font-medium text-gray-900 mb-2">
{Object.values(filters).some(v => v)
? 'No recipes found'
: 'No recipes yet'
}
</h3>
<p className="text-gray-600 mb-6">
{Object.values(filters).some(v => v)
? 'Try adjusting your search and filter criteria'
: 'Create your first recipe to get started'
}
</p>
<button className="px-6 py-3 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors">
Create Recipe
</button>
</div>
) : (
<div>
<div className={
viewMode === 'grid'
? 'grid grid-cols-1 md:grid-cols-2 xl:grid-cols-3 gap-6'
: 'space-y-4'
}>
{recipes.map((recipe) => (
<RecipeCard
key={recipe.id}
recipe={recipe}
compact={viewMode === 'list'}
onView={handleViewRecipe}
onEdit={handleEditRecipe}
onDuplicate={handleDuplicateRecipe}
onActivate={handleActivateRecipe}
onCheckFeasibility={handleCheckFeasibility}
feasibility={feasibilityResults.get(recipe.id)}
/>
))}
</div>
{/* Pagination */}
{pagination.totalPages > 1 && (
<div className="mt-8 flex items-center justify-between">
<div className="text-sm text-gray-600">
Showing {((pagination.page - 1) * pagination.limit) + 1} to{' '}
{Math.min(pagination.page * pagination.limit, pagination.total)} of{' '}
{pagination.total} recipes
</div>
<div className="flex space-x-2">
{Array.from({ length: pagination.totalPages }, (_, i) => i + 1).map((page) => (
<button
key={page}
onClick={() => setPage(page)}
className={`px-3 py-2 rounded-lg ${
page === pagination.page
? 'bg-blue-600 text-white'
: 'bg-white text-gray-600 hover:bg-gray-50 border'
}`}
>
{page}
</button>
))}
</div>
</div>
)}
</div>
)}
</div>
</div>
);
};
export default RecipesPage;

View File

@@ -0,0 +1,203 @@
import React, { useState } from 'react';
import {
BarChart3,
ShoppingCart,
TrendingUp,
Package
} from 'lucide-react';
import { SalesAnalyticsDashboard, SalesManagementPage } from '../../components/sales';
import Button from '../../components/ui/Button';
type SalesPageView = 'overview' | 'analytics' | 'management';
const SalesPage: React.FC = () => {
const [activeView, setActiveView] = useState<SalesPageView>('overview');
const renderContent = () => {
switch (activeView) {
case 'analytics':
return <SalesAnalyticsDashboard />;
case 'management':
return <SalesManagementPage />;
case 'overview':
default:
return (
<div className="space-y-6">
{/* Overview Header */}
<div className="bg-gradient-to-r from-blue-600 to-blue-700 rounded-xl p-8 text-white">
<div className="flex items-center justify-between">
<div>
<h1 className="text-3xl font-bold mb-2">Panel de Ventas</h1>
<p className="text-blue-100">
Gestiona, analiza y optimiza tus ventas con insights inteligentes
</p>
</div>
<div className="w-16 h-16 bg-white bg-opacity-20 rounded-full flex items-center justify-center">
<ShoppingCart className="w-8 h-8 text-white" />
</div>
</div>
</div>
{/* Quick Actions */}
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
<div
onClick={() => setActiveView('analytics')}
className="bg-white rounded-xl p-6 border border-gray-200 hover:shadow-md transition-shadow cursor-pointer group"
>
<div className="flex items-center justify-between mb-4">
<div className="w-12 h-12 bg-blue-100 rounded-lg flex items-center justify-center group-hover:bg-blue-200 transition-colors">
<BarChart3 className="w-6 h-6 text-blue-600" />
</div>
<TrendingUp className="w-5 h-5 text-green-500" />
</div>
<h3 className="text-lg font-semibold text-gray-900 mb-2">
Análisis de Ventas
</h3>
<p className="text-gray-600 text-sm mb-4">
Explora métricas detalladas, tendencias y insights de rendimiento
</p>
<div className="flex items-center text-blue-600 text-sm font-medium">
Ver Analytics
<svg className="w-4 h-4 ml-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M9 5l7 7-7 7" />
</svg>
</div>
</div>
<div
onClick={() => setActiveView('management')}
className="bg-white rounded-xl p-6 border border-gray-200 hover:shadow-md transition-shadow cursor-pointer group"
>
<div className="flex items-center justify-between mb-4">
<div className="w-12 h-12 bg-green-100 rounded-lg flex items-center justify-center group-hover:bg-green-200 transition-colors">
<Package className="w-6 h-6 text-green-600" />
</div>
<ShoppingCart className="w-5 h-5 text-blue-500" />
</div>
<h3 className="text-lg font-semibold text-gray-900 mb-2">
Gestión de Ventas
</h3>
<p className="text-gray-600 text-sm mb-4">
Administra, filtra y exporta todos tus registros de ventas
</p>
<div className="flex items-center text-green-600 text-sm font-medium">
Gestionar Ventas
<svg className="w-4 h-4 ml-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M9 5l7 7-7 7" />
</svg>
</div>
</div>
<div className="bg-white rounded-xl p-6 border border-gray-200 hover:shadow-md transition-shadow cursor-pointer group">
<div className="flex items-center justify-between mb-4">
<div className="w-12 h-12 bg-purple-100 rounded-lg flex items-center justify-center group-hover:bg-purple-200 transition-colors">
<TrendingUp className="w-6 h-6 text-purple-600" />
</div>
<div className="text-xs bg-purple-100 text-purple-800 px-2 py-1 rounded-full">
Próximamente
</div>
</div>
<h3 className="text-lg font-semibold text-gray-900 mb-2">
Predicciones IA
</h3>
<p className="text-gray-600 text-sm mb-4">
Predicciones inteligentes y recomendaciones de ventas
</p>
<div className="flex items-center text-purple-600 text-sm font-medium opacity-50">
En Desarrollo
</div>
</div>
</div>
{/* Quick Insights */}
<div className="bg-white rounded-xl p-6 border border-gray-200">
<h2 className="text-xl font-semibold text-gray-900 mb-6">Insights Rápidos</h2>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6">
<div className="text-center">
<div className="w-16 h-16 bg-green-100 rounded-full flex items-center justify-center mx-auto mb-3">
<TrendingUp className="w-8 h-8 text-green-600" />
</div>
<div className="text-2xl font-bold text-gray-900 mb-1">+12.5%</div>
<div className="text-sm text-gray-600">Crecimiento mensual</div>
</div>
<div className="text-center">
<div className="w-16 h-16 bg-blue-100 rounded-full flex items-center justify-center mx-auto mb-3">
<ShoppingCart className="w-8 h-8 text-blue-600" />
</div>
<div className="text-2xl font-bold text-gray-900 mb-1">247</div>
<div className="text-sm text-gray-600">Pedidos este mes</div>
</div>
<div className="text-center">
<div className="w-16 h-16 bg-purple-100 rounded-full flex items-center justify-center mx-auto mb-3">
<Package className="w-8 h-8 text-purple-600" />
</div>
<div className="text-2xl font-bold text-gray-900 mb-1">18.50</div>
<div className="text-sm text-gray-600">Valor promedio pedido</div>
</div>
<div className="text-center">
<div className="w-16 h-16 bg-orange-100 rounded-full flex items-center justify-center mx-auto mb-3">
<BarChart3 className="w-8 h-8 text-orange-600" />
</div>
<div className="text-2xl font-bold text-gray-900 mb-1">4.2</div>
<div className="text-sm text-gray-600">Puntuación satisfacción</div>
</div>
</div>
</div>
{/* Getting Started */}
<div className="bg-gradient-to-r from-indigo-50 to-purple-50 rounded-xl p-6 border border-indigo-200">
<h2 className="text-xl font-semibold text-gray-900 mb-4">¿Primera vez aquí?</h2>
<p className="text-gray-700 mb-6">
Comienza explorando tus análisis de ventas para descubrir insights valiosos
sobre el rendimiento de tu panadería.
</p>
<div className="flex flex-col sm:flex-row gap-3">
<Button
onClick={() => setActiveView('analytics')}
className="bg-indigo-600 hover:bg-indigo-700 text-white"
>
<BarChart3 className="w-4 h-4 mr-2" />
Ver Analytics
</Button>
<Button
variant="outline"
onClick={() => setActiveView('management')}
>
<Package className="w-4 h-4 mr-2" />
Gestionar Ventas
</Button>
</div>
</div>
</div>
);
}
};
return (
<div className="p-4 md:p-6 space-y-6">
{/* Navigation */}
{activeView !== 'overview' && (
<div className="flex items-center justify-between mb-6">
<nav className="flex items-center space-x-4">
<button
onClick={() => setActiveView('overview')}
className="text-gray-600 hover:text-gray-900 text-sm font-medium"
>
Volver al Panel
</button>
</nav>
</div>
)}
{/* Content */}
{renderContent()}
</div>
);
};
export default SalesPage;