From ecfc6a199708cb1a388b3a4f410a26beb88a05f8 Mon Sep 17 00:00:00 2001 From: Urtzi Alfaro Date: Sun, 21 Sep 2025 22:56:55 +0200 Subject: [PATCH] Add frontend pages imporvements --- .../domain/team/AddTeamMemberModal.tsx | 35 +- .../src/components/layout/Header/Header.tsx | 68 +- frontend/src/components/ui/TenantSwitcher.tsx | 52 +- .../components/ui/ThemeToggle/ThemeToggle.tsx | 23 +- frontend/src/hooks/index.ts | 5 + frontend/src/hooks/useTenantId.ts | 49 + .../analytics/forecasting/ForecastingPage.tsx | 797 ++++++--------- .../sales-analytics/SalesAnalyticsPage.tsx | 942 ++++++++++++------ .../operations/inventory/InventoryPage.tsx | 5 +- .../src/pages/app/operations/pos/POSPage.tsx | 451 ++++++--- .../bakery-config/BakeryConfigPage.tsx | 67 +- .../src/pages/app/settings/team/TeamPage.tsx | 82 +- frontend/src/stores/tenant.store.ts | 1 + .../tenant/app/services/tenant_service.py | 54 +- 14 files changed, 1538 insertions(+), 1093 deletions(-) create mode 100644 frontend/src/hooks/index.ts create mode 100644 frontend/src/hooks/useTenantId.ts diff --git a/frontend/src/components/domain/team/AddTeamMemberModal.tsx b/frontend/src/components/domain/team/AddTeamMemberModal.tsx index fd72c38e..de0e539e 100644 --- a/frontend/src/components/domain/team/AddTeamMemberModal.tsx +++ b/frontend/src/components/domain/team/AddTeamMemberModal.tsx @@ -23,6 +23,7 @@ export const AddTeamMemberModal: React.FC = ({ }) => { const [formData, setFormData] = useState({ userId: '', + userEmail: '', // Add email field for manual input role: TENANT_ROLES.MEMBER }); @@ -33,7 +34,7 @@ export const AddTeamMemberModal: React.FC = ({ // Map field positions to form data fields const fieldMappings = [ // Basic Information section - ['userId', 'role'] + ['userId', 'userEmail', 'role'] ]; const fieldName = fieldMappings[sectionIndex]?.[fieldIndex] as keyof typeof formData; @@ -46,9 +47,9 @@ export const AddTeamMemberModal: React.FC = ({ }; const handleSave = async () => { - // Validation - if (!formData.userId) { - alert('Por favor selecciona un usuario'); + // Validation - need either userId OR userEmail + if (!formData.userId && !formData.userEmail) { + alert('Por favor selecciona un usuario o ingresa un email'); return; } @@ -61,7 +62,7 @@ export const AddTeamMemberModal: React.FC = ({ try { if (onAddMember) { await onAddMember({ - userId: formData.userId, + userId: formData.userId || formData.userEmail, // Use email as userId if no userId selected role: formData.role }); } @@ -69,6 +70,7 @@ export const AddTeamMemberModal: React.FC = ({ // Reset form setFormData({ userId: '', + userEmail: '', role: TENANT_ROLES.MEMBER }); @@ -85,6 +87,7 @@ export const AddTeamMemberModal: React.FC = ({ // Reset form to initial values setFormData({ userId: '', + userEmail: '', role: TENANT_ROLES.MEMBER }); onClose(); @@ -104,10 +107,12 @@ export const AddTeamMemberModal: React.FC = ({ { label: 'Observador - Solo lectura', value: TENANT_ROLES.VIEWER } ]; - const userOptions = availableUsers.map(user => ({ - label: `${user.full_name} (${user.email})`, - value: user.id - })); + const userOptions = availableUsers.length > 0 + ? availableUsers.map(user => ({ + label: `${user.full_name} (${user.email})`, + value: user.id + })) + : []; const getRoleDescription = (role: string) => { switch (role) { @@ -127,14 +132,22 @@ export const AddTeamMemberModal: React.FC = ({ title: 'Información del Miembro', icon: Users, fields: [ - { + ...(userOptions.length > 0 ? [{ label: 'Usuario', value: formData.userId, type: 'select' as const, editable: true, - required: true, + required: !formData.userEmail, // Only required if email not provided options: userOptions, placeholder: 'Seleccionar usuario...' + }] : []), + { + label: userOptions.length > 0 ? 'O Email del Usuario' : 'Email del Usuario', + value: formData.userEmail, + type: 'email' as const, + editable: true, + required: !formData.userId, // Only required if user not selected + placeholder: 'usuario@ejemplo.com' }, { label: 'Rol', diff --git a/frontend/src/components/layout/Header/Header.tsx b/frontend/src/components/layout/Header/Header.tsx index cd3b2fd7..15cea68e 100644 --- a/frontend/src/components/layout/Header/Header.tsx +++ b/frontend/src/components/layout/Header/Header.tsx @@ -9,16 +9,14 @@ import { Avatar } from '../../ui'; import { Badge } from '../../ui'; import { Modal } from '../../ui'; import { TenantSwitcher } from '../../ui/TenantSwitcher'; +import { ThemeToggle } from '../../ui/ThemeToggle'; import { NotificationPanel } from '../../ui/NotificationPanel/NotificationPanel'; -import { - Menu, - Search, - Bell, - Sun, - Moon, - Computer, - Settings, - User, +import { + Menu, + Search, + Bell, + Settings, + User, LogOut, X } from 'lucide-react'; @@ -116,7 +114,6 @@ export const Header = forwardRef(({ const [isUserMenuOpen, setIsUserMenuOpen] = useState(false); const [isSearchFocused, setIsSearchFocused] = useState(false); const [searchValue, setSearchValue] = useState(''); - const [isThemeMenuOpen, setIsThemeMenuOpen] = useState(false); const [isNotificationPanelOpen, setIsNotificationPanelOpen] = useState(false); const searchInputRef = React.useRef(null); @@ -179,7 +176,6 @@ export const Header = forwardRef(({ // Escape to close menus if (e.key === 'Escape') { setIsUserMenuOpen(false); - setIsThemeMenuOpen(false); setIsNotificationPanelOpen(false); if (isSearchFocused) { searchInputRef.current?.blur(); @@ -198,9 +194,6 @@ export const Header = forwardRef(({ if (!target.closest('[data-user-menu]')) { setIsUserMenuOpen(false); } - if (!target.closest('[data-theme-menu]')) { - setIsThemeMenuOpen(false); - } if (!target.closest('[data-notification-panel]')) { setIsNotificationPanelOpen(false); } @@ -210,13 +203,6 @@ export const Header = forwardRef(({ return () => document.removeEventListener('click', handleClickOutside); }, []); - const themeIcons = { - light: Sun, - dark: Moon, - auto: Computer, - }; - - const ThemeIcon = themeIcons[theme] || Sun; return (
(({ {/* Theme toggle */} {showThemeToggle && ( -
- - - {isThemeMenuOpen && ( -
- {[ - { key: 'light' as const, label: 'Claro', icon: Sun }, - { key: 'dark' as const, label: 'Oscuro', icon: Moon }, - { key: 'auto' as const, label: 'Sistema', icon: Computer }, - ].map(({ key, label, icon: Icon }) => ( - - ))} -
- )} -
+ )} {/* Notifications */} diff --git a/frontend/src/components/ui/TenantSwitcher.tsx b/frontend/src/components/ui/TenantSwitcher.tsx index 91790723..b701ef5b 100644 --- a/frontend/src/components/ui/TenantSwitcher.tsx +++ b/frontend/src/components/ui/TenantSwitcher.tsx @@ -1,8 +1,9 @@ import React, { useState, useRef, useEffect } from 'react'; import { createPortal } from 'react-dom'; +import { useNavigate } from 'react-router-dom'; import { useTenant } from '../../stores/tenant.store'; import { useToast } from '../../hooks/ui/useToast'; -import { ChevronDown, Building2, Check, AlertCircle } from 'lucide-react'; +import { ChevronDown, Building2, Check, AlertCircle, Plus } from 'lucide-react'; interface TenantSwitcherProps { className?: string; @@ -13,6 +14,7 @@ export const TenantSwitcher: React.FC = ({ className = '', showLabel = true, }) => { + const navigate = useNavigate(); const [isOpen, setIsOpen] = useState(false); const [dropdownPosition, setDropdownPosition] = useState<{ top: number; @@ -23,7 +25,7 @@ export const TenantSwitcher: React.FC = ({ }>({ top: 0, left: 0, width: 288, isMobile: false }); const dropdownRef = useRef(null); const buttonRef = useRef(null); - + const { currentTenant, availableTenants, @@ -33,7 +35,7 @@ export const TenantSwitcher: React.FC = ({ loadUserTenants, clearError, } = useTenant(); - + const { success: showSuccessToast, error: showErrorToast } = useToast(); // Load tenants on mount @@ -170,6 +172,12 @@ export const TenantSwitcher: React.FC = ({ loadUserTenants(); }; + // Handle creating new tenant + const handleCreateNewTenant = () => { + setIsOpen(false); + navigate('/app/onboarding'); + }; + // Don't render if no tenants available if (!availableTenants || availableTenants.length === 0) { return null; @@ -229,11 +237,8 @@ export const TenantSwitcher: React.FC = ({ {/* Header */}

- Switch Organization + Organizations

-

- Select the organization you want to work with -

{/* Error State */} @@ -267,21 +272,25 @@ export const TenantSwitcher: React.FC = ({ >
-
-
- +
+
+

{tenant.name}

- {tenant.business_type} • {tenant.city} + {tenant.city}

- + {tenant.id === currentTenant?.id && ( )} @@ -294,14 +303,17 @@ export const TenantSwitcher: React.FC = ({
-

- Need to add a new organization?{' '} - -

+
, document.body diff --git a/frontend/src/components/ui/ThemeToggle/ThemeToggle.tsx b/frontend/src/components/ui/ThemeToggle/ThemeToggle.tsx index 6a22e7f5..ef928cef 100644 --- a/frontend/src/components/ui/ThemeToggle/ThemeToggle.tsx +++ b/frontend/src/components/ui/ThemeToggle/ThemeToggle.tsx @@ -2,7 +2,7 @@ import React, { useState } from 'react'; import { clsx } from 'clsx'; import { useTheme } from '../../../contexts/ThemeContext'; import { Button } from '../Button'; -import { Sun, Moon, Computer } from 'lucide-react'; +import { Sun, Moon } from 'lucide-react'; export interface ThemeToggleProps { className?: string; @@ -29,7 +29,7 @@ export interface ThemeToggleProps { * * Features: * - Multiple display variants (button, dropdown, switch) - * - Support for light/dark/system themes + * - Support for light/dark themes * - Configurable size and labels * - Accessible keyboard navigation * - Click outside to close dropdown @@ -47,10 +47,11 @@ export const ThemeToggle: React.FC = ({ const themes = [ { key: 'light' as const, label: 'Claro', icon: Sun }, { key: 'dark' as const, label: 'Oscuro', icon: Moon }, - { key: 'auto' as const, label: 'Sistema', icon: Computer }, ]; - const currentTheme = themes.find(t => t.key === theme) || themes[0]; + // If theme is 'auto', use the resolved theme for display + const displayTheme = theme === 'auto' ? resolvedTheme : theme; + const currentTheme = themes.find(t => t.key === displayTheme) || themes[0]; const CurrentIcon = currentTheme.icon; // Size mappings @@ -93,20 +94,18 @@ export const ThemeToggle: React.FC = ({ return () => document.removeEventListener('click', handleClickOutside); }, [isDropdownOpen]); - // Cycle through themes for button variant + // Toggle between light and dark for button variant const handleButtonToggle = () => { - const currentIndex = themes.findIndex(t => t.key === theme); - const nextIndex = (currentIndex + 1) % themes.length; - setTheme(themes[nextIndex].key); + setTheme(resolvedTheme === 'dark' ? 'light' : 'dark'); }; // Handle theme selection - const handleThemeSelect = (themeKey: 'light' | 'dark' | 'auto') => { + const handleThemeSelect = (themeKey: 'light' | 'dark') => { setTheme(themeKey); setIsDropdownOpen(false); }; - // Button variant - cycles through themes + // Button variant - toggles between light and dark if (variant === 'button') { return ( diff --git a/frontend/src/hooks/index.ts b/frontend/src/hooks/index.ts new file mode 100644 index 00000000..b2e95938 --- /dev/null +++ b/frontend/src/hooks/index.ts @@ -0,0 +1,5 @@ +// Export commonly used hooks +export { default as useLocalStorage } from './useLocalStorage'; +export { default as useDebounce } from './useDebounce'; +export { default as useSubscription } from './useSubscription'; +export { useTenantId, useTenantInfo, useRequiredTenant } from './useTenantId'; \ No newline at end of file diff --git a/frontend/src/hooks/useTenantId.ts b/frontend/src/hooks/useTenantId.ts new file mode 100644 index 00000000..1d4973a9 --- /dev/null +++ b/frontend/src/hooks/useTenantId.ts @@ -0,0 +1,49 @@ +/** + * Custom hook to consistently get tenant ID from tenant store + * Provides a standardized way to access tenant ID across the application + */ +import { useCurrentTenant } from '../stores/tenant.store'; + +/** + * Hook to get the current tenant ID + * @returns {string} The current tenant ID, or empty string if not available + */ +export const useTenantId = (): string => { + const currentTenant = useCurrentTenant(); + return currentTenant?.id || ''; +}; + +/** + * Hook to get both tenant and tenant ID + * @returns {object} Object containing currentTenant and tenantId + */ +export const useTenantInfo = () => { + const currentTenant = useCurrentTenant(); + const tenantId = currentTenant?.id || ''; + + return { + currentTenant, + tenantId, + hasTenant: !!currentTenant, + }; +}; + +/** + * Hook to ensure tenant is available before proceeding + * Useful for components that absolutely require a tenant to function + * @returns {object} Object with tenant info and loading state + */ +export const useRequiredTenant = () => { + const currentTenant = useCurrentTenant(); + const tenantId = currentTenant?.id || ''; + const isReady = !!tenantId; + + return { + currentTenant, + tenantId, + isReady, + isLoading: !isReady, // Indicates if we're still waiting for tenant + }; +}; + +export default useTenantId; \ No newline at end of file diff --git a/frontend/src/pages/app/analytics/forecasting/ForecastingPage.tsx b/frontend/src/pages/app/analytics/forecasting/ForecastingPage.tsx index ce63c160..1f051cf9 100644 --- a/frontend/src/pages/app/analytics/forecasting/ForecastingPage.tsx +++ b/frontend/src/pages/app/analytics/forecasting/ForecastingPage.tsx @@ -1,15 +1,17 @@ import React, { useState, useMemo } from 'react'; -import { Calendar, TrendingUp, AlertTriangle, BarChart3, Download, Settings, Loader, Zap, Brain, Target, CloudRain, Sun, Thermometer } from 'lucide-react'; -import { Button, Card, Badge, Select, Table, StatsGrid } from '../../../../components/ui'; +import { Calendar, TrendingUp, AlertTriangle, BarChart3, Download, Settings, Loader, Zap, Brain, Target, CloudRain, Sun, Thermometer, Package, Activity, Clock } from 'lucide-react'; +import { Button, Card, Badge, Table, StatsGrid, StatusCard, getStatusColor } from '../../../../components/ui'; import type { TableColumn } from '../../../../components/ui'; import { PageHeader } from '../../../../components/layout'; +import { LoadingSpinner } from '../../../../components/shared'; import { DemandChart, ForecastTable } from '../../../../components/domain/forecasting'; import { useTenantForecasts, useCreateSingleForecast } from '../../../../api/hooks/forecasting'; import { useIngredients } from '../../../../api/hooks/inventory'; import { useModels } from '../../../../api/hooks/training'; -import { useCurrentTenant } from '../../../../stores/tenant.store'; +import { useTenantId } from '../../../../hooks/useTenantId'; import { ForecastResponse } from '../../../../api/types/forecasting'; import { forecastingService } from '../../../../api/services/forecasting'; +import { formatters } from '../../../../components/ui/Stats/StatsPresets'; const ForecastingPage: React.FC = () => { const [selectedProduct, setSelectedProduct] = useState(''); @@ -20,8 +22,7 @@ const ForecastingPage: React.FC = () => { const [currentForecastData, setCurrentForecastData] = useState([]); // Get tenant ID from tenant store - const currentTenant = useCurrentTenant(); - const tenantId = currentTenant?.id || ''; + const tenantId = useTenantId(); // Calculate date range based on selected period const endDate = new Date(); @@ -145,42 +146,6 @@ const ForecastingPage: React.FC = () => { }; - // Extract weather data from all forecasts for 7-day view - const getWeatherImpact = (forecasts: ForecastResponse[]) => { - if (!forecasts || forecasts.length === 0) return null; - - // Calculate average temperature across all forecast days - const avgTemp = forecasts.reduce((sum, f) => sum + (f.weather_temperature || 0), 0) / forecasts.length; - const tempRange = { - min: Math.min(...forecasts.map(f => f.weather_temperature || 0)), - max: Math.max(...forecasts.map(f => f.weather_temperature || 0)) - }; - - // Aggregate weather descriptions - const weatherTypes = forecasts - .map(f => f.weather_description) - .filter(Boolean) - .reduce((acc, desc) => { - acc[desc] = (acc[desc] || 0) + 1; - return acc; - }, {} as Record); - - const dominantWeather = Object.entries(weatherTypes) - .sort(([,a], [,b]) => b - a)[0]?.[0] || 'N/A'; - - return { - avgTemperature: Math.round(avgTemp), - tempRange, - dominantWeather, - forecastDays: forecasts.length, - dailyForecasts: forecasts.map(f => ({ - date: f.forecast_date, - temperature: f.weather_temperature, - description: f.weather_description, - predicted_demand: f.predicted_demand - })) - }; - }; const forecastColumns: TableColumn[] = [ @@ -224,7 +189,6 @@ const ForecastingPage: React.FC = () => { // Use either current forecast data or fetched data const forecasts = currentForecastData.length > 0 ? currentForecastData : (forecastsData?.forecasts || []); const transformedForecasts = transformForecastsForTable(forecasts); - const weatherImpact = getWeatherImpact(forecasts); const isLoading = forecastsLoading || ingredientsLoading || modelsLoading || isGenerating; const hasError = forecastsError || ingredientsError || modelsError; @@ -234,495 +198,370 @@ const ForecastingPage: React.FC = () => { ? Math.round((forecasts.reduce((sum, f) => sum + f.confidence_level, 0) / forecasts.length) * 100) : 0; - // Get forecast insights from the latest forecast - only real backend data - const getForecastInsights = (forecast: ForecastResponse) => { + // Get simplified forecast insights + const getForecastInsights = (forecasts: ForecastResponse[]) => { + if (!forecasts || forecasts.length === 0) return []; + const insights = []; + const avgConfidence = forecasts.reduce((sum, f) => sum + f.confidence_level, 0) / forecasts.length; - // Weather data (only factual) - if (forecast.weather_temperature) { - insights.push({ - type: 'weather', - icon: Thermometer, - title: 'Temperatura', - description: `${forecast.weather_temperature}°C`, - impact: 'info' - }); - } - - if (forecast.weather_description) { - insights.push({ - type: 'weather', - icon: CloudRain, - title: 'Condición Climática', - description: forecast.weather_description, - impact: 'info' - }); - } - - // Temporal factors (only factual) - if (forecast.is_weekend) { - insights.push({ - type: 'temporal', - icon: Calendar, - title: 'Fin de Semana', - description: 'Día de fin de semana', - impact: 'info' - }); - } - - if (forecast.is_holiday) { - insights.push({ - type: 'temporal', - icon: Calendar, - title: 'Día Festivo', - description: 'Día festivo', - impact: 'info' - }); - } - - // Model confidence (factual) + // Model confidence insights.push({ type: 'model', icon: Target, title: 'Confianza del Modelo', - description: `${Math.round(forecast.confidence_level * 100)}%`, - impact: forecast.confidence_level > 0.8 ? 'positive' : forecast.confidence_level > 0.6 ? 'moderate' : 'high' + description: `${Math.round(avgConfidence * 100)}%`, + impact: avgConfidence > 0.8 ? 'positive' : avgConfidence > 0.6 ? 'moderate' : 'high' + }); + + // Algorithm used + if (forecasts[0]?.algorithm) { + insights.push({ + type: 'algorithm', + icon: Brain, + title: 'Algoritmo', + description: forecasts[0].algorithm, + impact: 'info' + }); + } + + // Forecast period + insights.push({ + type: 'period', + icon: Calendar, + title: 'Período', + description: `${forecasts.length} días predichos`, + impact: 'info' }); return insights; }; - const currentInsights = forecasts.length > 0 ? getForecastInsights(forecasts[0]) : []; + const currentInsights = getForecastInsights(forecasts); + // Loading and error states - using project patterns + if (isLoading || !tenantId) { + return ( +
+ +
+ ); + } + + if (hasError) { + return ( +
+ +

+ Error al cargar datos +

+

+ {forecastsError?.message || ingredientsError?.message || modelsError?.message || 'Ha ocurrido un error inesperado'} +

+ +
+ ); + } + return ( -
+
- - -
- } + description="Sistema inteligente de predicción de demanda basado en IA" /> - {isLoading && ( - - - - {isGenerating ? 'Generando nuevas predicciones...' : 'Cargando predicciones...'} - - - )} + {/* Stats Grid - Similar to POSPage */} + - {hasError && ( - -
- - Error al cargar las predicciones. Por favor, inténtalo de nuevo. -
-
- )} +
+ {/* Ingredient Selection Section */} +
+ {/* Ingredients Grid - Similar to POSPage products */} + +

+ + Ingredientes Disponibles ({products.length}) +

+
+ {products.map(product => { + const isSelected = selectedProduct === product.id; - {!isLoading && !hasError && ( - <> + const getStatusConfig = () => { + if (isSelected) { + return { + color: getStatusColor('completed'), + text: 'Seleccionado', + icon: Target, + isCritical: false, + isHighlight: true + }; + } else { + return { + color: getStatusColor('pending'), + text: 'Disponible', + icon: Brain, + isCritical: false, + isHighlight: false + }; + } + }; - - )} + return ( + setSelectedProduct(product.id) + } + ]} + /> + ); + })} +
- {/* Forecast Configuration */} - -

Configurar Predicción

-
- {/* Step 1: Select Ingredient */} -
- - + {/* Empty State */} {products.length === 0 && ( -

- No hay ingredientes con modelos entrenados -

+
+ +

+ No hay modelos entrenados +

+

+ Para generar predicciones, necesitas modelos IA entrenados para tus ingredientes +

+ +
)} -
+ +
- {/* Step 2: Select Period */} -
- - -
+ {/* Configuration and Generation Section */} +
+ {/* Period Selection */} + +

+ + Configuración +

+
+
+ + +
- {/* Step 3: Generate */} -
- + {selectedProduct && ( +
+

+ {products.find(p => p.id === selectedProduct)?.name} +

+

+ Predicción para {forecastPeriod} días +

+
+ )} +
+ + + {/* Generate Forecast */} + +

+ + Generar Predicción +

-
+ + {!selectedProduct && ( +

+ Selecciona un ingrediente para continuar +

+ )} +
+
- {selectedProduct && ( -
-

- Ingrediente seleccionado: {products.find(p => p.id === selectedProduct)?.name} -

-

- Se generará una predicción de demanda para los próximos {forecastPeriod} días usando IA -

-
- )} - - - {/* Results Section - Only show after generating forecast */} + {/* Results Section */} {hasGeneratedForecast && forecasts.length > 0 && ( <> - {/* Enhanced Layout Structure */} -
- - {/* Key Metrics Row - Using StatsGrid */} - f.predicted_demand)) - Math.min(...forecasts.map(f => f.predicted_demand))).toFixed(1), - icon: BarChart3, - variant: "warning", - size: "sm" - } - ]} - /> - - {/* Main Content Grid */} -
- - {/* Chart Section - Takes most space */} -
- -
-
-
-

Predicción de Demanda

-

- {products.find(p => p.id === selectedProduct)?.name} • {forecastPeriod} días • {forecasts.length} puntos -

-
-
-
- - -
- -
-
-
- -
- {viewMode === 'chart' ? ( - - ) : ( - - )} -
-
+ {/* Results Header */} + +
+
+

Resultados de Predicción

+

+ {products.find(p => p.id === selectedProduct)?.name} • {forecastPeriod} días +

- - {/* Right Sidebar - Insights */} -
- {/* Weather & External Factors */} -
- {/* Forecast Insights */} - {currentInsights.length > 0 && ( - -

Factores que Afectan la Predicción

-
- {currentInsights.map((insight, index) => { - const IconComponent = insight.icon; - return ( -
-
- -
-
-

{insight.title}

-

{insight.description}

-
-
- ); - })} -
-
- )} - - {/* Weather Impact */} - {weatherImpact && ( - -
-

- - Clima ({weatherImpact.forecastDays} días) -

-

Impacto meteorológico en la demanda

-
-
- {/* Temperature Overview */} -
-
-
- - {weatherImpact.avgTemperature}°C -
-

Promedio

-
-
-
-

{weatherImpact.dominantWeather}

-

Condición

-
-
-
- - {/* Daily forecast - compact */} -
-

Pronóstico detallado:

-
- {weatherImpact.dailyForecasts.slice(0, 5).map((day, index) => ( -
- - {new Date(day.date).toLocaleDateString('es-ES', { weekday: 'short', day: 'numeric' })} - -
- {day.temperature}°C - {day.predicted_demand?.toFixed(0)} -
-
- ))} -
-
-
-
- )} - - - {/* Model Information */} - {forecasts.length > 0 && ( - -
-

- - Modelo IA -

-

Información técnica del algoritmo

-
-
-
-
- Algoritmo - {forecasts[0]?.algorithm || 'N/A'} -
-
- Versión - {forecasts[0]?.model_version || 'N/A'} -
-
- Tiempo - {forecasts[0]?.processing_time_ms || 'N/A'}ms -
-
-
-
- )} +
+
+ + +
+
-
-
+ + {/* Chart or Table */} +
+ {viewMode === 'chart' ? ( + + ) : ( + + )} +
+
+ + {/* Insights */} + {currentInsights.length > 0 && ( + +

+ + Factores de Influencia +

+
+ {currentInsights.map((insight, index) => { + const IconComponent = insight.icon; + return ( +
+
+ +
+
+

{insight.title}

+

{insight.description}

+
+
+ ); + })} +
+
+ )} )} - {/* Detailed Forecasts Table */} - {!isLoading && !hasError && transformedForecasts.length > 0 && ( - -

Predicciones Detalladas

- - - )} - - {/* Empty States */} + {/* Help Section - Only when no models available */} {!isLoading && !hasError && products.length === 0 && (
- -

No hay ingredientes con modelos entrenados

-

- Para generar predicciones, primero necesitas entrenar modelos de IA para tus ingredientes. - Ve a la página de Modelos IA para entrenar modelos para tus ingredientes. + +

+ No hay modelos entrenados +

+

+ Para generar predicciones, necesitas entrenar modelos de IA para tus ingredientes.

-
- -
-
-
- )} - - {!isLoading && !hasError && products.length > 0 && !hasGeneratedForecast && ( - -
- -

Listo para Generar Predicciones

-

- Tienes {products.length} ingrediente{products.length > 1 ? 's' : ''} con modelos entrenados disponibles. - Selecciona un ingrediente y período para comenzar. -

-
-
-
1
-

Selecciona Ingrediente

-

Elige un ingrediente con modelo IA

-
-
-
2
-

Define Período

-

Establece días a predecir

-
-
-
3
-

Generar Predicción

-

Obtén insights de IA

-
-
+
)} diff --git a/frontend/src/pages/app/analytics/sales-analytics/SalesAnalyticsPage.tsx b/frontend/src/pages/app/analytics/sales-analytics/SalesAnalyticsPage.tsx index 1a73d148..fb7b4b87 100644 --- a/frontend/src/pages/app/analytics/sales-analytics/SalesAnalyticsPage.tsx +++ b/frontend/src/pages/app/analytics/sales-analytics/SalesAnalyticsPage.tsx @@ -1,22 +1,89 @@ -import React, { useState } from 'react'; -import { Calendar, TrendingUp, DollarSign, ShoppingCart, Download, Filter } from 'lucide-react'; -import { Button, Card, Badge } from '../../../../components/ui'; +import React, { useState, useMemo } from 'react'; +import { Calendar, TrendingUp, DollarSign, ShoppingCart, Download, Filter, Eye, Users, Package, CreditCard, BarChart3, AlertTriangle, Clock } from 'lucide-react'; +import { Button, Card, Badge, StatsGrid, StatusCard, getStatusColor } from '../../../../components/ui'; import { PageHeader } from '../../../../components/layout'; -import { AnalyticsDashboard, ChartWidget, ReportsTable } from '../../../../components/domain/analytics'; +import { LoadingSpinner } from '../../../../components/shared'; +import { formatters } from '../../../../components/ui/Stats/StatsPresets'; +import { useSalesAnalytics, useSalesRecords, useProductCategories } from '../../../../api/hooks/sales'; +import { useTenantId } from '../../../../hooks/useTenantId'; +import { SalesDataResponse } from '../../../../api/types/sales'; const SalesAnalyticsPage: React.FC = () => { - const [selectedPeriod, setSelectedPeriod] = useState('month'); - const [selectedMetric, setSelectedMetric] = useState('revenue'); + const [selectedPeriod, setSelectedPeriod] = useState('year'); + const [selectedCategory, setSelectedCategory] = useState('all'); const [viewMode, setViewMode] = useState<'overview' | 'detailed'>('overview'); + const [exportLoading, setExportLoading] = useState(false); - const salesMetrics = { - totalRevenue: 45678.90, - totalOrders: 1234, - averageOrderValue: 37.02, - customerCount: 856, - growthRate: 12.5, - conversionRate: 68.4, - }; + const tenantId = useTenantId(); + + // Calculate date range based on selected period + const { startDate, endDate } = useMemo(() => { + const end = new Date(); + const start = new Date(); + + switch (selectedPeriod) { + case 'day': + start.setHours(0, 0, 0, 0); + end.setHours(23, 59, 59, 999); + break; + case 'week': + start.setDate(start.getDate() - 7); + break; + case 'month': + start.setDate(start.getDate() - 30); + break; + case 'quarter': + start.setDate(start.getDate() - 90); + break; + case 'year': + start.setFullYear(start.getFullYear() - 1); + break; + } + + return { + startDate: start.toISOString(), + endDate: end.toISOString() + }; + }, [selectedPeriod]); + + // Fetch real sales analytics data + const { + data: analyticsData, + isLoading: analyticsLoading, + error: analyticsError + } = useSalesAnalytics(tenantId, startDate, endDate, { + enabled: !!tenantId, + retry: 1, + retryDelay: 1000 + }); + + // Fetch sales records for detailed view + const { + data: salesRecords, + isLoading: recordsLoading, + error: recordsError + } = useSalesRecords(tenantId, { + start_date: startDate, + end_date: endDate, + ...(selectedCategory !== 'all' && { product_category: selectedCategory }), + limit: 100, + order_by: 'date', + order_direction: 'desc' + }, { + enabled: !!tenantId, + retry: 1, + retryDelay: 1000 + }); + + // Fetch product categories + const { + data: categoriesData, + isLoading: categoriesLoading + } = useProductCategories(tenantId, { + enabled: !!tenantId, + retry: 1, + retryDelay: 1000 + }); const periods = [ { value: 'day', label: 'Hoy' }, @@ -26,122 +93,281 @@ const SalesAnalyticsPage: React.FC = () => { { value: 'year', label: 'Este Año' }, ]; - const metrics = [ - { value: 'revenue', label: 'Ingresos' }, - { value: 'orders', label: 'Pedidos' }, - { value: 'customers', label: 'Clientes' }, - { value: 'products', label: 'Productos' }, - ]; + // Process analytics data + const salesMetrics = useMemo(() => { + if (!analyticsData) { + return { + totalRevenue: 0, + totalOrders: 0, + averageOrderValue: 0, + totalQuantity: 0, + averageUnitPrice: 0, + topProducts: [], + revenueByCategory: [], + revenueByChannel: [] + }; + } - const topProducts = [ - { - id: '1', - name: 'Pan de Molde Integral', - revenue: 2250.50, - units: 245, - growth: 8.2, - category: 'Panes' - }, - { - id: '2', - name: 'Croissants de Mantequilla', - revenue: 1890.75, - units: 412, - growth: 15.4, - category: 'Bollería' - }, - { - id: '3', - name: 'Tarta de Chocolate', - revenue: 1675.00, - units: 67, - growth: -2.1, - category: 'Tartas' - }, - { - id: '4', - name: 'Empanadas Variadas', - revenue: 1425.25, - units: 285, - growth: 22.8, - category: 'Salados' - }, - { - id: '5', - name: 'Magdalenas', - revenue: 1180.50, - units: 394, - growth: 5.7, - category: 'Bollería' - }, - ]; + return { + totalRevenue: analyticsData.total_revenue, + totalOrders: analyticsData.total_transactions, + averageOrderValue: analyticsData.total_transactions > 0 ? analyticsData.total_revenue / analyticsData.total_transactions : 0, + totalQuantity: analyticsData.total_quantity, + averageUnitPrice: analyticsData.average_unit_price, + topProducts: analyticsData.top_products || [], + revenueByCategory: analyticsData.revenue_by_category || [], + revenueByChannel: analyticsData.revenue_by_channel || [] + }; + }, [analyticsData]); - const salesByHour = [ - { hour: '07:00', sales: 145, orders: 12 }, - { hour: '08:00', sales: 289, orders: 18 }, - { hour: '09:00', sales: 425, orders: 28 }, - { hour: '10:00', sales: 380, orders: 24 }, - { hour: '11:00', sales: 520, orders: 31 }, - { hour: '12:00', sales: 675, orders: 42 }, - { hour: '13:00', sales: 720, orders: 45 }, - { hour: '14:00', sales: 580, orders: 35 }, - { hour: '15:00', sales: 420, orders: 28 }, - { hour: '16:00', sales: 350, orders: 22 }, - { hour: '17:00', sales: 480, orders: 31 }, - { hour: '18:00', sales: 620, orders: 38 }, - { hour: '19:00', sales: 450, orders: 29 }, - { hour: '20:00', sales: 280, orders: 18 }, - ]; + // Transform sales records for table + const transformedSalesData = useMemo(() => { + if (!salesRecords) return []; - const customerSegments = [ - { segment: 'Clientes Frecuentes', count: 123, revenue: 15678, percentage: 34.3 }, - { segment: 'Clientes Regulares', count: 245, revenue: 18950, percentage: 41.5 }, - { segment: 'Clientes Ocasionales', count: 356, revenue: 8760, percentage: 19.2 }, - { segment: 'Clientes Nuevos', count: 132, revenue: 2290, percentage: 5.0 }, - ]; + return salesRecords.map(record => ({ + id: record.id, + date: record.date, + productName: record.product_name, + category: record.product_category || 'Sin categoría', + quantity: record.quantity_sold, + unitPrice: record.unit_price, + totalRevenue: record.total_revenue, + channel: record.sales_channel || 'N/A', + discount: record.discount_applied || 0, + profit: record.profit_margin || 0, + validated: record.is_validated || false + })); + }, [salesRecords]); - const paymentMethods = [ - { method: 'Tarjeta', count: 567, revenue: 28450, percentage: 62.3 }, - { method: 'Efectivo', count: 445, revenue: 13890, percentage: 30.4 }, - { method: 'Transferencia', count: 178, revenue: 2890, percentage: 6.3 }, - { method: 'Otros', count: 44, revenue: 448, percentage: 1.0 }, - ]; + // Categories for filter + const categories = useMemo(() => { + const allCategories = [{ value: 'all', label: 'Todas las Categorías' }]; + if (categoriesData) { + allCategories.push(...categoriesData.map(cat => ({ value: cat, label: cat }))); + } + return allCategories; + }, [categoriesData]); - const getGrowthBadge = (growth: number) => { - if (growth > 0) { - return +{growth.toFixed(1)}%; - } else if (growth < 0) { - return {growth.toFixed(1)}%; - } else { - return 0%; + // Handle export functionality + const handleExport = async (format: 'csv' | 'excel' | 'pdf') => { + if (!salesRecords || salesRecords.length === 0) { + alert('No hay datos para exportar'); + return; + } + + setExportLoading(true); + try { + // Simple CSV export implementation + if (format === 'csv') { + const headers = ['Fecha', 'Producto', 'Categoría', 'Cantidad', 'Precio Unitario', 'Ingresos Totales', 'Canal', 'Descuento', 'Validado']; + const csvContent = [ + headers.join(','), + ...transformedSalesData.map(row => [ + row.date, + `"${row.productName}"`, + `"${row.category}"`, + row.quantity, + row.unitPrice, + row.totalRevenue, + `"${row.channel}"`, + row.discount, + row.validated ? 'Sí' : 'No' + ].join(',')) + ].join('\n'); + + const blob = new Blob([csvContent], { type: 'text/csv;charset=utf-8;' }); + const link = document.createElement('a'); + const url = URL.createObjectURL(blob); + link.setAttribute('href', url); + link.setAttribute('download', `analisis-ventas-${startDate}-${endDate}.csv`); + link.style.visibility = 'hidden'; + document.body.appendChild(link); + link.click(); + document.body.removeChild(link); + } + } catch (error) { + console.error('Error exporting data:', error); + alert('Error al exportar los datos'); + } finally { + setExportLoading(false); } }; - const getGrowthColor = (growth: number) => { - return growth >= 0 ? 'text-[var(--color-success)]' : 'text-[var(--color-error)]'; + // Table columns definition + const salesColumns: TableColumn[] = [ + { + key: 'date', + title: 'Fecha', + dataIndex: 'date', + render: (value) => new Date(value).toLocaleDateString('es-ES') + }, + { + key: 'productName', + title: 'Producto', + dataIndex: 'productName' + }, + { + key: 'category', + title: 'Categoría', + dataIndex: 'category' + }, + { + key: 'quantity', + title: 'Cantidad', + dataIndex: 'quantity', + render: (value) => value.toLocaleString() + }, + { + key: 'unitPrice', + title: 'Precio Unit.', + dataIndex: 'unitPrice', + render: (value) => formatters.currency(value) + }, + { + key: 'totalRevenue', + title: 'Ingresos', + dataIndex: 'totalRevenue', + render: (value) => ( + + {formatters.currency(value)} + + ) + }, + { + key: 'channel', + title: 'Canal', + dataIndex: 'channel' + }, + { + key: 'validated', + title: 'Estado', + dataIndex: 'validated', + render: (value) => ( + + {value ? 'Validado' : 'Pendiente'} + + ) + } + ]; + + // Check if it's a 422 error (API endpoint not available) + const isApiUnavailable = (error: any) => { + return error?.status === 422 || error?.response?.status === 422; }; + // Loading and error states + const isLoading = analyticsLoading || recordsLoading || categoriesLoading || !tenantId; + const hasError = analyticsError || recordsError; + const isApiEndpointUnavailable = isApiUnavailable(analyticsError) && isApiUnavailable(recordsError); + + if (isLoading) { + return ( +
+ +
+ ); + } + + // Special handling for when the sales API endpoints are not available + if (isApiEndpointUnavailable) { + return ( +
+ + + + +

+ Funcionalidad en Desarrollo +

+

+ El módulo de análisis de ventas está siendo desarrollado. + Los endpoints de la API de ventas aún no están disponibles en el backend. +

+
+
+

Funcionalidades Planeadas:

+
    +
  • • Análisis de ingresos y transacciones
  • +
  • • Productos más vendidos
  • +
  • • Análisis por categorías y canales
  • +
  • • Exportación de datos de ventas
  • +
  • • Filtrado por períodos y categorías
  • +
+
+ + Estado: En desarrollo + +
+
+
+ ); + } + + if (hasError) { + // Extract error message properly + const getErrorMessage = (error: any) => { + if (!error) return null; + + // Handle different error formats + if (typeof error === 'string') return error; + if (error.message && typeof error.message === 'string') return error.message; + if (error.detail && typeof error.detail === 'string') return error.detail; + if (error.msg && typeof error.msg === 'string') return error.msg; + + // If it's an array of validation errors + if (Array.isArray(error)) { + return error.map(e => e.msg || e.message || JSON.stringify(e)).join(', '); + } + + return 'Error de conexión con el servidor'; + }; + + const errorMessage = getErrorMessage(analyticsError) || getErrorMessage(recordsError) || 'Ha ocurrido un error inesperado'; + + return ( +
+ +

+ Error al cargar datos de ventas +

+

+ {errorMessage} +

+
+ +

+ Si el problema persiste, es posible que el endpoint de ventas no esté disponible +

+
+
+ ); + } + return ( -
+
- - -
- } + actions={[ + { + id: "export-data", + label: "Exportar", + variant: "outline" as const, + icon: Download, + onClick: () => handleExport('csv'), + tooltip: "Exportar datos a CSV", + disabled: exportLoading || !salesRecords?.length + } + ]} /> {/* Controls */} - +
@@ -149,7 +375,7 @@ const SalesAnalyticsPage: React.FC = () => { setSelectedMetric(e.target.value)} - className="w-full px-3 py-2 border border-[var(--border-secondary)] rounded-md" + value={selectedCategory} + onChange={(e) => setSelectedCategory(e.target.value)} + className="w-full px-3 py-2 border border-[var(--border-secondary)] rounded-md bg-[var(--bg-primary)] text-[var(--text-primary)]" > - {metrics.map(metric => ( - + {categories.map(category => ( + ))}
-
- - +
- {/* Key Metrics */} -
- -
-
-

Ingresos Totales

-

€{salesMetrics.totalRevenue.toLocaleString()}

-
- -
-
- {getGrowthBadge(salesMetrics.growthRate)} -
-
- - -
-
-

Total Pedidos

-

{salesMetrics.totalOrders.toLocaleString()}

-
- -
-
- - -
-
-

Valor Promedio

-

€{salesMetrics.averageOrderValue.toFixed(2)}

-
-
- - - -
-
-
- - -
-
-

Clientes

-

{salesMetrics.customerCount}

-
-
- - - -
-
-
- - -
-
-

Tasa Crecimiento

-

- {salesMetrics.growthRate > 0 ? '+' : ''}{salesMetrics.growthRate.toFixed(1)}% -

-
- -
-
- - -
-
-

Conversión

-

{salesMetrics.conversionRate}%

-
-
- - - -
-
-
-
+ {/* Stats Grid */} + {viewMode === 'overview' ? (
- {/* Sales by Hour Chart */} - -

Ventas por Hora

-
- {salesByHour.map((data, index) => ( -
-
d.sales))) * 200}px`, - minHeight: '4px' - }} - >
- - {data.hour} - -
- ))} -
-
- {/* Top Products */} -

Productos Más Vendidos

+

+ + Productos Más Vendidos +

- {topProducts.slice(0, 5).map((product, index) => ( -
-
- {index + 1}. -
-

{product.name}

-

{product.category} • {product.units} unidades

+ {salesMetrics.topProducts.length === 0 ? ( +
+ +

No hay datos de productos para este período

+
+ ) : ( + salesMetrics.topProducts.slice(0, 5).map((product, index) => ( +
+
+ {index + 1}. +
+

{product.product_name}

+

{product.total_quantity} vendidos • {product.transaction_count} transacciones

+
+
+
+

+ {formatters.currency(product.total_revenue)} +

-
-

€{product.revenue.toLocaleString()}

- {getGrowthBadge(product.growth)} -
-
- ))} + )) + )}
- {/* Customer Segments */} + {/* Revenue by Category */} -

Segmentos de Clientes

+

+ + Ingresos por Categoría +

+
+ {salesMetrics.revenueByCategory.length === 0 ? ( +
+ +

No hay datos de categorías para este período

+
+ ) : ( + salesMetrics.revenueByCategory.map((category, index) => { + const maxRevenue = Math.max(...salesMetrics.revenueByCategory.map(c => c.revenue)); + const percentage = maxRevenue > 0 ? (category.revenue / maxRevenue) * 100 : 0; + + return ( +
+
+ {category.category} + + {formatters.currency(category.revenue)} + +
+
+
+
+
+ {category.quantity} unidades + {percentage.toFixed(1)}% del total +
+
+ ); + }) + )} +
+
+ + {/* Revenue by Channel */} + +

+ + Ingresos por Canal +

+
+ {salesMetrics.revenueByChannel.length === 0 ? ( +
+ +

No hay datos de canales para este período

+
+ ) : ( + salesMetrics.revenueByChannel.map((channel, index) => ( +
+
+
+
+

{channel.channel}

+

{channel.quantity} unidades

+
+
+
+

+ {formatters.currency(channel.revenue)} +

+
+
+ )) + )} +
+
+ + {/* Period Summary */} + +

+ + Resumen del Período +

- {customerSegments.map((segment, index) => ( -
-
- {segment.segment} - {segment.percentage}% -
-
-
-
-
- {segment.count} clientes - €{segment.revenue.toLocaleString()} -
+
+
+

Período Seleccionado

+

+ {periods.find(p => p.value === selectedPeriod)?.label} +

- ))} -
- +
+

Registros Encontrados

+

+ {salesRecords?.length || 0} +

+
+
- {/* Payment Methods */} - -

Métodos de Pago

-
- {paymentMethods.map((method, index) => ( -
-
-
-
-

{method.method}

-

{method.count} transacciones

-
-
-
-

€{method.revenue.toLocaleString()}

-

{method.percentage}%

-
+
+
+ Fecha inicio: + + {new Date(startDate).toLocaleDateString('es-ES')} +
- ))} +
+ Fecha fin: + + {new Date(endDate).toLocaleDateString('es-ES')} + +
+
) : ( -
- {/* Detailed Analytics Dashboard */} - - - {/* Detailed Reports Table */} - -
+ +
+

+ + Registro Detallado de Ventas +

+
+ + {transformedSalesData.length} registros + + +
+
+ + {transformedSalesData.length === 0 ? ( +
+ +

+ No hay registros de ventas +

+

+ No se encontraron registros de ventas para el período y filtros seleccionados +

+
+ ) : ( +
+ {/* Sales Records as Cards */} + {transformedSalesData.slice(0, 50).map((record, index) => ( + +
+ {/* Left Side - Product Info */} +
+
+
+

+ {record.productName} +

+

+ {record.category} • {new Date(record.date).toLocaleDateString('es-ES')} +

+
+ + {record.validated ? 'Validado' : 'Pendiente'} + +
+ + {/* Details Grid */} +
+
+ Cantidad: +

{record.quantity.toLocaleString()}

+
+
+ Precio Unit.: +

{formatters.currency(record.unitPrice)}

+
+
+ Canal: +

{record.channel}

+
+ {record.discount > 0 && ( +
+ Descuento: +

{formatters.currency(record.discount)}

+
+ )} +
+
+ + {/* Right Side - Revenue */} +
+
+

Ingresos

+

+ {formatters.currency(record.totalRevenue)} +

+
+
+ + #{index + 1} +
+
+
+
+ ))} + + {/* Load More / Pagination Info */} + {transformedSalesData.length > 50 && ( + +

+ Mostrando 50 de {transformedSalesData.length} registros +

+ +
+ )} +
+ )} +
)}
); diff --git a/frontend/src/pages/app/operations/inventory/InventoryPage.tsx b/frontend/src/pages/app/operations/inventory/InventoryPage.tsx index cd1f96bb..22e25f63 100644 --- a/frontend/src/pages/app/operations/inventory/InventoryPage.tsx +++ b/frontend/src/pages/app/operations/inventory/InventoryPage.tsx @@ -15,7 +15,7 @@ import { // Import AddStockModal separately since we need it for adding batches import AddStockModal from '../../../../components/domain/inventory/AddStockModal'; import { useIngredients, useStockAnalytics, useStockMovements, useStockByIngredient, useCreateIngredient, useSoftDeleteIngredient, useHardDeleteIngredient, useAddStock, useConsumeStock, useUpdateIngredient, useTransformationsByIngredient } from '../../../../api/hooks/inventory'; -import { useCurrentTenant } from '../../../../stores/tenant.store'; +import { useTenantId } from '../../../../hooks/useTenantId'; import { IngredientResponse, StockCreate, StockMovementCreate, IngredientCreate } from '../../../../api/types/inventory'; const InventoryPage: React.FC = () => { @@ -30,8 +30,7 @@ const InventoryPage: React.FC = () => { const [showDeleteModal, setShowDeleteModal] = useState(false); const [showAddBatch, setShowAddBatch] = useState(false); - const currentTenant = useCurrentTenant(); - const tenantId = currentTenant?.id || ''; + const tenantId = useTenantId(); // Mutations const createIngredientMutation = useCreateIngredient(); diff --git a/frontend/src/pages/app/operations/pos/POSPage.tsx b/frontend/src/pages/app/operations/pos/POSPage.tsx index cf7342e4..317000ca 100644 --- a/frontend/src/pages/app/operations/pos/POSPage.tsx +++ b/frontend/src/pages/app/operations/pos/POSPage.tsx @@ -1,16 +1,24 @@ -import React, { useState } from 'react'; -import { Plus, Minus, ShoppingCart, CreditCard, Banknote, Calculator, User, Receipt } from 'lucide-react'; -import { Button, Input, Card, Badge } from '../../../../components/ui'; +import React, { useState, useMemo } from 'react'; +import { Plus, Minus, ShoppingCart, CreditCard, Banknote, Calculator, User, Receipt, Package, Euro, TrendingUp, Clock } from 'lucide-react'; +import { Button, Input, Card, Badge, StatsGrid, StatusCard, getStatusColor } from '../../../../components/ui'; import { PageHeader } from '../../../../components/layout'; +import { LoadingSpinner } from '../../../../components/shared'; +import { formatters } from '../../../../components/ui/Stats/StatsPresets'; +import { useIngredients } from '../../../../api/hooks/inventory'; +import { useTenantId } from '../../../../hooks/useTenantId'; +import { ProductType, ProductCategory, IngredientResponse } from '../../../../api/types/inventory'; + +interface CartItem { + id: string; + name: string; + price: number; + quantity: number; + category: string; + stock: number; +} const POSPage: React.FC = () => { - const [cart, setCart] = useState>([]); + const [cart, setCart] = useState([]); const [selectedCategory, setSelectedCategory] = useState('all'); const [customerInfo, setCustomerInfo] = useState({ name: '', @@ -20,73 +28,65 @@ const POSPage: React.FC = () => { const [paymentMethod, setPaymentMethod] = useState<'cash' | 'card' | 'transfer'>('cash'); const [cashReceived, setCashReceived] = useState(''); - const products = [ - { - id: '1', - name: 'Pan de Molde Integral', - price: 4.50, - category: 'bread', - stock: 25, - image: '/api/placeholder/100/100', - }, - { - id: '2', - name: 'Croissants de Mantequilla', - price: 1.50, - category: 'pastry', - stock: 32, - image: '/api/placeholder/100/100', - }, - { - id: '3', - name: 'Baguette Francesa', - price: 2.80, - category: 'bread', - stock: 18, - image: '/api/placeholder/100/100', - }, - { - id: '4', - name: 'Tarta de Chocolate', - price: 25.00, - category: 'cake', - stock: 8, - image: '/api/placeholder/100/100', - }, - { - id: '5', - name: 'Magdalenas', - price: 0.75, - category: 'pastry', - stock: 48, - image: '/api/placeholder/100/100', - }, - { - id: '6', - name: 'Empanadas', - price: 2.50, - category: 'other', - stock: 24, - image: '/api/placeholder/100/100', - }, - ]; + const tenantId = useTenantId(); - const categories = [ - { id: 'all', name: 'Todos' }, - { id: 'bread', name: 'Panes' }, - { id: 'pastry', name: 'Bollería' }, - { id: 'cake', name: 'Tartas' }, - { id: 'other', name: 'Otros' }, - ]; + // Fetch finished products from API + const { + data: ingredientsData, + isLoading: productsLoading, + error: productsError + } = useIngredients(tenantId, { + // Filter for finished products only + category: undefined, // We'll filter client-side for now + search: undefined + }); - const filteredProducts = products.filter(product => - selectedCategory === 'all' || product.category === selectedCategory - ); + // Filter for finished products and convert to POS format + const products = useMemo(() => { + if (!ingredientsData) return []; + + return ingredientsData + .filter(ingredient => ingredient.product_type === ProductType.FINISHED_PRODUCT) + .map(ingredient => ({ + id: ingredient.id, + name: ingredient.name, + price: Number(ingredient.average_cost) || 0, + category: ingredient.category.toLowerCase(), + stock: Number(ingredient.current_stock) || 0, + ingredient: ingredient + })) + .filter(product => product.stock > 0); // Only show products in stock + }, [ingredientsData]); + + // Generate categories from actual product data + const categories = useMemo(() => { + const categoryMap = new Map(); + categoryMap.set('all', { id: 'all', name: 'Todos' }); + + products.forEach(product => { + if (!categoryMap.has(product.category)) { + const categoryName = product.category.charAt(0).toUpperCase() + product.category.slice(1); + categoryMap.set(product.category, { id: product.category, name: categoryName }); + } + }); + + return Array.from(categoryMap.values()); + }, [products]); + + const filteredProducts = useMemo(() => { + return products.filter(product => + selectedCategory === 'all' || product.category === selectedCategory + ); + }, [products, selectedCategory]); const addToCart = (product: typeof products[0]) => { setCart(prevCart => { const existingItem = prevCart.find(item => item.id === product.id); if (existingItem) { + // Check if we have enough stock + if (existingItem.quantity >= product.stock) { + return prevCart; // Don't add if no stock available + } return prevCart.map(item => item.id === product.id ? { ...item, quantity: item.quantity + 1 } @@ -99,6 +99,7 @@ const POSPage: React.FC = () => { price: product.price, quantity: 1, category: product.category, + stock: product.stock }]; } }); @@ -109,9 +110,14 @@ const POSPage: React.FC = () => { setCart(prevCart => prevCart.filter(item => item.id !== id)); } else { setCart(prevCart => - prevCart.map(item => - item.id === id ? { ...item, quantity } : item - ) + prevCart.map(item => { + if (item.id === id) { + // Don't allow quantity to exceed stock + const maxQuantity = Math.min(quantity, item.stock); + return { ...item, quantity: maxQuantity }; + } + return item; + }) ); } }; @@ -128,8 +134,8 @@ const POSPage: React.FC = () => { const processPayment = () => { if (cart.length === 0) return; - - // Process payment logic here + + // TODO: Integrate with real POS API endpoint console.log('Processing payment:', { cart, customerInfo, @@ -143,53 +149,205 @@ const POSPage: React.FC = () => { setCart([]); setCustomerInfo({ name: '', email: '', phone: '' }); setCashReceived(''); - + alert('Venta procesada exitosamente'); }; + // Calculate stats for the POS dashboard + const posStats = useMemo(() => { + const totalProducts = products.length; + const totalStock = products.reduce((sum, product) => sum + product.stock, 0); + const cartValue = cart.reduce((sum, item) => sum + (item.price * item.quantity), 0); + const cartItems = cart.reduce((sum, item) => sum + item.quantity, 0); + const lowStockProducts = products.filter(product => product.stock <= 5).length; + const avgProductPrice = totalProducts > 0 ? products.reduce((sum, product) => sum + product.price, 0) / totalProducts : 0; + + return { + totalProducts, + totalStock, + cartValue, + cartItems, + lowStockProducts, + avgProductPrice + }; + }, [products, cart]); + + const stats = [ + { + title: 'Productos Disponibles', + value: posStats.totalProducts, + variant: 'default' as const, + icon: Package, + }, + { + title: 'Stock Total', + value: posStats.totalStock, + variant: 'info' as const, + icon: Package, + }, + { + title: 'Artículos en Carrito', + value: posStats.cartItems, + variant: 'success' as const, + icon: ShoppingCart, + }, + { + title: 'Valor del Carrito', + value: formatters.currency(posStats.cartValue), + variant: 'success' as const, + icon: Euro, + }, + { + title: 'Stock Bajo', + value: posStats.lowStockProducts, + variant: 'warning' as const, + icon: Clock, + }, + { + title: 'Precio Promedio', + value: formatters.currency(posStats.avgProductPrice), + variant: 'info' as const, + icon: TrendingUp, + }, + ]; + + // Loading and error states + if (productsLoading || !tenantId) { + return ( +
+ +
+ ); + } + + if (productsError) { + return ( +
+ +

+ Error al cargar productos +

+

+ {productsError.message || 'Ha ocurrido un error inesperado'} +

+ +
+ ); + } + return ( -
+
-
+ {/* Stats Grid */} + + +
{/* Products Section */}
{/* Categories */} -
- {categories.map(category => ( - - ))} -
+ +
+ {categories.map(category => ( + + ))} +
+
{/* Products Grid */} -
- {filteredProducts.map(product => ( - addToCart(product)} - > - {product.name} + {filteredProducts.map(product => { + const cartItem = cart.find(item => item.id === product.id); + const inCart = !!cartItem; + const cartQuantity = cartItem?.quantity || 0; + const remainingStock = product.stock - cartQuantity; + + const getStockStatusConfig = () => { + if (remainingStock <= 0) { + return { + color: getStatusColor('cancelled'), + text: 'Sin Stock', + icon: Package, + isCritical: true, + isHighlight: false + }; + } else if (remainingStock <= 5) { + return { + color: getStatusColor('pending'), + text: `${remainingStock} disponibles`, + icon: Package, + isCritical: false, + isHighlight: true + }; + } else { + return { + color: getStatusColor('completed'), + text: `${remainingStock} disponibles`, + icon: Package, + isCritical: false, + isHighlight: false + }; + } + }; + + return ( + addToCart(product) + } + ]} /> -

{product.name}

-

€{product.price.toFixed(2)}

-

Stock: {product.stock}

-
- ))} + ); + })}
+ + {/* Empty State */} + {filteredProducts.length === 0 && ( +
+ +

+ No hay productos disponibles +

+

+ {selectedCategory === 'all' + ? 'No hay productos en stock en este momento' + : `No hay productos en la categoría "${categories.find(c => c.id === selectedCategory)?.name}"` + } +

+
+ )}
{/* Cart and Checkout Section */} @@ -212,40 +370,47 @@ const POSPage: React.FC = () => { {cart.length === 0 ? (

Carrito vacío

) : ( - cart.map(item => ( -
-
-

{item.name}

-

€{item.price.toFixed(2)} c/u

+ cart.map(item => { + const product = products.find(p => p.id === item.id); + const maxQuantity = product?.stock || item.stock; + + return ( +
+
+

{item.name}

+

€{item.price.toFixed(2)} c/u

+

Stock: {maxQuantity}

+
+
+ + {item.quantity} + +
+
+

€{(item.price * item.quantity).toFixed(2)}

+
-
- - {item.quantity} - -
-
-

€{(item.price * item.quantity).toFixed(2)}

-
-
- )) + ); + }) )}
@@ -305,7 +470,7 @@ const POSPage: React.FC = () => {