Create new services: inventory, recipes, suppliers
This commit is contained in:
542
frontend/src/pages/inventory/InventoryPage.tsx
Normal file
542
frontend/src/pages/inventory/InventoryPage.tsx
Normal 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;
|
||||
@@ -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"
|
||||
|
||||
517
frontend/src/pages/recipes/RecipesPage.tsx
Normal file
517
frontend/src/pages/recipes/RecipesPage.tsx
Normal 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;
|
||||
203
frontend/src/pages/sales/SalesPage.tsx
Normal file
203
frontend/src/pages/sales/SalesPage.tsx
Normal 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;
|
||||
Reference in New Issue
Block a user