Add frontend pages imporvements

This commit is contained in:
Urtzi Alfaro
2025-09-21 22:56:55 +02:00
parent f08667150d
commit ecfc6a1997
14 changed files with 1538 additions and 1093 deletions

View File

@@ -23,6 +23,7 @@ export const AddTeamMemberModal: React.FC<AddTeamMemberModalProps> = ({
}) => { }) => {
const [formData, setFormData] = useState({ const [formData, setFormData] = useState({
userId: '', userId: '',
userEmail: '', // Add email field for manual input
role: TENANT_ROLES.MEMBER role: TENANT_ROLES.MEMBER
}); });
@@ -33,7 +34,7 @@ export const AddTeamMemberModal: React.FC<AddTeamMemberModalProps> = ({
// Map field positions to form data fields // Map field positions to form data fields
const fieldMappings = [ const fieldMappings = [
// Basic Information section // Basic Information section
['userId', 'role'] ['userId', 'userEmail', 'role']
]; ];
const fieldName = fieldMappings[sectionIndex]?.[fieldIndex] as keyof typeof formData; const fieldName = fieldMappings[sectionIndex]?.[fieldIndex] as keyof typeof formData;
@@ -46,9 +47,9 @@ export const AddTeamMemberModal: React.FC<AddTeamMemberModalProps> = ({
}; };
const handleSave = async () => { const handleSave = async () => {
// Validation // Validation - need either userId OR userEmail
if (!formData.userId) { if (!formData.userId && !formData.userEmail) {
alert('Por favor selecciona un usuario'); alert('Por favor selecciona un usuario o ingresa un email');
return; return;
} }
@@ -61,7 +62,7 @@ export const AddTeamMemberModal: React.FC<AddTeamMemberModalProps> = ({
try { try {
if (onAddMember) { if (onAddMember) {
await onAddMember({ await onAddMember({
userId: formData.userId, userId: formData.userId || formData.userEmail, // Use email as userId if no userId selected
role: formData.role role: formData.role
}); });
} }
@@ -69,6 +70,7 @@ export const AddTeamMemberModal: React.FC<AddTeamMemberModalProps> = ({
// Reset form // Reset form
setFormData({ setFormData({
userId: '', userId: '',
userEmail: '',
role: TENANT_ROLES.MEMBER role: TENANT_ROLES.MEMBER
}); });
@@ -85,6 +87,7 @@ export const AddTeamMemberModal: React.FC<AddTeamMemberModalProps> = ({
// Reset form to initial values // Reset form to initial values
setFormData({ setFormData({
userId: '', userId: '',
userEmail: '',
role: TENANT_ROLES.MEMBER role: TENANT_ROLES.MEMBER
}); });
onClose(); onClose();
@@ -104,10 +107,12 @@ export const AddTeamMemberModal: React.FC<AddTeamMemberModalProps> = ({
{ label: 'Observador - Solo lectura', value: TENANT_ROLES.VIEWER } { label: 'Observador - Solo lectura', value: TENANT_ROLES.VIEWER }
]; ];
const userOptions = availableUsers.map(user => ({ const userOptions = availableUsers.length > 0
? availableUsers.map(user => ({
label: `${user.full_name} (${user.email})`, label: `${user.full_name} (${user.email})`,
value: user.id value: user.id
})); }))
: [];
const getRoleDescription = (role: string) => { const getRoleDescription = (role: string) => {
switch (role) { switch (role) {
@@ -127,14 +132,22 @@ export const AddTeamMemberModal: React.FC<AddTeamMemberModalProps> = ({
title: 'Información del Miembro', title: 'Información del Miembro',
icon: Users, icon: Users,
fields: [ fields: [
{ ...(userOptions.length > 0 ? [{
label: 'Usuario', label: 'Usuario',
value: formData.userId, value: formData.userId,
type: 'select' as const, type: 'select' as const,
editable: true, editable: true,
required: true, required: !formData.userEmail, // Only required if email not provided
options: userOptions, options: userOptions,
placeholder: 'Seleccionar usuario...' 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', label: 'Rol',

View File

@@ -9,14 +9,12 @@ import { Avatar } from '../../ui';
import { Badge } from '../../ui'; import { Badge } from '../../ui';
import { Modal } from '../../ui'; import { Modal } from '../../ui';
import { TenantSwitcher } from '../../ui/TenantSwitcher'; import { TenantSwitcher } from '../../ui/TenantSwitcher';
import { ThemeToggle } from '../../ui/ThemeToggle';
import { NotificationPanel } from '../../ui/NotificationPanel/NotificationPanel'; import { NotificationPanel } from '../../ui/NotificationPanel/NotificationPanel';
import { import {
Menu, Menu,
Search, Search,
Bell, Bell,
Sun,
Moon,
Computer,
Settings, Settings,
User, User,
LogOut, LogOut,
@@ -116,7 +114,6 @@ export const Header = forwardRef<HeaderRef, HeaderProps>(({
const [isUserMenuOpen, setIsUserMenuOpen] = useState(false); const [isUserMenuOpen, setIsUserMenuOpen] = useState(false);
const [isSearchFocused, setIsSearchFocused] = useState(false); const [isSearchFocused, setIsSearchFocused] = useState(false);
const [searchValue, setSearchValue] = useState(''); const [searchValue, setSearchValue] = useState('');
const [isThemeMenuOpen, setIsThemeMenuOpen] = useState(false);
const [isNotificationPanelOpen, setIsNotificationPanelOpen] = useState(false); const [isNotificationPanelOpen, setIsNotificationPanelOpen] = useState(false);
const searchInputRef = React.useRef<HTMLInputElement>(null); const searchInputRef = React.useRef<HTMLInputElement>(null);
@@ -179,7 +176,6 @@ export const Header = forwardRef<HeaderRef, HeaderProps>(({
// Escape to close menus // Escape to close menus
if (e.key === 'Escape') { if (e.key === 'Escape') {
setIsUserMenuOpen(false); setIsUserMenuOpen(false);
setIsThemeMenuOpen(false);
setIsNotificationPanelOpen(false); setIsNotificationPanelOpen(false);
if (isSearchFocused) { if (isSearchFocused) {
searchInputRef.current?.blur(); searchInputRef.current?.blur();
@@ -198,9 +194,6 @@ export const Header = forwardRef<HeaderRef, HeaderProps>(({
if (!target.closest('[data-user-menu]')) { if (!target.closest('[data-user-menu]')) {
setIsUserMenuOpen(false); setIsUserMenuOpen(false);
} }
if (!target.closest('[data-theme-menu]')) {
setIsThemeMenuOpen(false);
}
if (!target.closest('[data-notification-panel]')) { if (!target.closest('[data-notification-panel]')) {
setIsNotificationPanelOpen(false); setIsNotificationPanelOpen(false);
} }
@@ -210,13 +203,6 @@ export const Header = forwardRef<HeaderRef, HeaderProps>(({
return () => document.removeEventListener('click', handleClickOutside); return () => document.removeEventListener('click', handleClickOutside);
}, []); }, []);
const themeIcons = {
light: Sun,
dark: Moon,
auto: Computer,
};
const ThemeIcon = themeIcons[theme] || Sun;
return ( return (
<header <header
@@ -352,45 +338,7 @@ export const Header = forwardRef<HeaderRef, HeaderProps>(({
{/* Theme toggle */} {/* Theme toggle */}
{showThemeToggle && ( {showThemeToggle && (
<div className="relative" data-theme-menu> <ThemeToggle variant="button" size="md" />
<Button
variant="ghost"
size="sm"
onClick={() => setIsThemeMenuOpen(!isThemeMenuOpen)}
className="w-10 h-10 p-0 flex items-center justify-center"
aria-label={`Tema actual: ${theme}`}
aria-expanded={isThemeMenuOpen}
aria-haspopup="true"
>
<ThemeIcon className="h-5 w-5" />
</Button>
{isThemeMenuOpen && (
<div className="absolute right-0 top-full mt-2 w-48 bg-[var(--bg-primary)] border border-[var(--border-primary)] rounded-lg shadow-lg py-1 z-[var(--z-dropdown)]">
{[
{ 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 }) => (
<button
key={key}
onClick={() => {
setTheme(key);
setIsThemeMenuOpen(false);
}}
className={clsx(
'w-full px-4 py-2 text-left text-sm flex items-center gap-3',
'hover:bg-[var(--bg-secondary)] transition-colors',
theme === key && 'bg-[var(--bg-secondary)] text-[var(--color-primary)]'
)}
>
<Icon className="h-4 w-4" />
{label}
</button>
))}
</div>
)}
</div>
)} )}
{/* Notifications */} {/* Notifications */}

View File

@@ -1,8 +1,9 @@
import React, { useState, useRef, useEffect } from 'react'; import React, { useState, useRef, useEffect } from 'react';
import { createPortal } from 'react-dom'; import { createPortal } from 'react-dom';
import { useNavigate } from 'react-router-dom';
import { useTenant } from '../../stores/tenant.store'; import { useTenant } from '../../stores/tenant.store';
import { useToast } from '../../hooks/ui/useToast'; 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 { interface TenantSwitcherProps {
className?: string; className?: string;
@@ -13,6 +14,7 @@ export const TenantSwitcher: React.FC<TenantSwitcherProps> = ({
className = '', className = '',
showLabel = true, showLabel = true,
}) => { }) => {
const navigate = useNavigate();
const [isOpen, setIsOpen] = useState(false); const [isOpen, setIsOpen] = useState(false);
const [dropdownPosition, setDropdownPosition] = useState<{ const [dropdownPosition, setDropdownPosition] = useState<{
top: number; top: number;
@@ -170,6 +172,12 @@ export const TenantSwitcher: React.FC<TenantSwitcherProps> = ({
loadUserTenants(); loadUserTenants();
}; };
// Handle creating new tenant
const handleCreateNewTenant = () => {
setIsOpen(false);
navigate('/app/onboarding');
};
// Don't render if no tenants available // Don't render if no tenants available
if (!availableTenants || availableTenants.length === 0) { if (!availableTenants || availableTenants.length === 0) {
return null; return null;
@@ -229,11 +237,8 @@ export const TenantSwitcher: React.FC<TenantSwitcherProps> = ({
{/* Header */} {/* Header */}
<div className={`border-b border-border-primary ${dropdownPosition.isMobile ? 'px-4 py-3' : 'px-3 py-2'}`}> <div className={`border-b border-border-primary ${dropdownPosition.isMobile ? 'px-4 py-3' : 'px-3 py-2'}`}>
<h3 className={`font-semibold text-text-primary ${dropdownPosition.isMobile ? 'text-base' : 'text-sm'}`}> <h3 className={`font-semibold text-text-primary ${dropdownPosition.isMobile ? 'text-base' : 'text-sm'}`}>
Switch Organization Organizations
</h3> </h3>
<p className={`text-text-secondary ${dropdownPosition.isMobile ? 'text-sm mt-1' : 'text-xs'}`}>
Select the organization you want to work with
</p>
</div> </div>
{/* Error State */} {/* Error State */}
@@ -267,16 +272,20 @@ export const TenantSwitcher: React.FC<TenantSwitcherProps> = ({
> >
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<div className="flex-1 min-w-0"> <div className="flex-1 min-w-0">
<div className="flex items-center space-x-2"> <div className="flex items-center space-x-3">
<div className="w-8 h-8 bg-color-primary/10 rounded-full flex items-center justify-center flex-shrink-0"> <div className={`w-8 h-8 rounded-full flex items-center justify-center flex-shrink-0 ${
<Building2 className="w-4 h-4 text-color-primary" /> tenant.id === currentTenant?.id
? 'bg-color-primary text-white'
: 'bg-color-primary/10 text-color-primary'
}`}>
<Building2 className="w-4 h-4" />
</div> </div>
<div className="min-w-0 flex-1"> <div className="min-w-0 flex-1">
<p className="text-sm font-medium text-text-primary truncate"> <p className="text-sm font-medium text-text-primary truncate">
{tenant.name} {tenant.name}
</p> </p>
<p className="text-xs text-text-secondary truncate"> <p className="text-xs text-text-secondary truncate">
{tenant.business_type} {tenant.city} {tenant.city}
</p> </p>
</div> </div>
</div> </div>
@@ -294,14 +303,17 @@ export const TenantSwitcher: React.FC<TenantSwitcherProps> = ({
<div className={`border-t border-border-primary bg-bg-secondary rounded-b-lg ${ <div className={`border-t border-border-primary bg-bg-secondary rounded-b-lg ${
dropdownPosition.isMobile ? 'px-4 py-3' : 'px-3 py-2' dropdownPosition.isMobile ? 'px-4 py-3' : 'px-3 py-2'
}`}> }`}>
<p className={`text-text-secondary ${dropdownPosition.isMobile ? 'text-sm' : 'text-xs'}`}> <button
Need to add a new organization?{' '} onClick={handleCreateNewTenant}
<button className={`text-color-primary hover:text-color-primary-dark underline ${ className={`w-full flex items-center justify-center gap-2 ${
dropdownPosition.isMobile ? 'active:text-color-primary-dark' : '' dropdownPosition.isMobile ? 'px-4 py-3' : 'px-3 py-2'
}`}> } text-color-primary hover:text-color-primary-dark hover:bg-bg-tertiary rounded-lg transition-colors focus:outline-none focus:ring-2 focus:ring-color-primary focus:ring-opacity-20`}
Contact Support >
<Plus className="w-4 h-4" />
<span className={`font-medium ${dropdownPosition.isMobile ? 'text-sm' : 'text-xs'}`}>
Add New Organization
</span>
</button> </button>
</p>
</div> </div>
</div>, </div>,
document.body document.body

View File

@@ -2,7 +2,7 @@ import React, { useState } from 'react';
import { clsx } from 'clsx'; import { clsx } from 'clsx';
import { useTheme } from '../../../contexts/ThemeContext'; import { useTheme } from '../../../contexts/ThemeContext';
import { Button } from '../Button'; import { Button } from '../Button';
import { Sun, Moon, Computer } from 'lucide-react'; import { Sun, Moon } from 'lucide-react';
export interface ThemeToggleProps { export interface ThemeToggleProps {
className?: string; className?: string;
@@ -29,7 +29,7 @@ export interface ThemeToggleProps {
* *
* Features: * Features:
* - Multiple display variants (button, dropdown, switch) * - Multiple display variants (button, dropdown, switch)
* - Support for light/dark/system themes * - Support for light/dark themes
* - Configurable size and labels * - Configurable size and labels
* - Accessible keyboard navigation * - Accessible keyboard navigation
* - Click outside to close dropdown * - Click outside to close dropdown
@@ -47,10 +47,11 @@ export const ThemeToggle: React.FC<ThemeToggleProps> = ({
const themes = [ const themes = [
{ key: 'light' as const, label: 'Claro', icon: Sun }, { key: 'light' as const, label: 'Claro', icon: Sun },
{ key: 'dark' as const, label: 'Oscuro', icon: Moon }, { 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; const CurrentIcon = currentTheme.icon;
// Size mappings // Size mappings
@@ -93,20 +94,18 @@ export const ThemeToggle: React.FC<ThemeToggleProps> = ({
return () => document.removeEventListener('click', handleClickOutside); return () => document.removeEventListener('click', handleClickOutside);
}, [isDropdownOpen]); }, [isDropdownOpen]);
// Cycle through themes for button variant // Toggle between light and dark for button variant
const handleButtonToggle = () => { const handleButtonToggle = () => {
const currentIndex = themes.findIndex(t => t.key === theme); setTheme(resolvedTheme === 'dark' ? 'light' : 'dark');
const nextIndex = (currentIndex + 1) % themes.length;
setTheme(themes[nextIndex].key);
}; };
// Handle theme selection // Handle theme selection
const handleThemeSelect = (themeKey: 'light' | 'dark' | 'auto') => { const handleThemeSelect = (themeKey: 'light' | 'dark') => {
setTheme(themeKey); setTheme(themeKey);
setIsDropdownOpen(false); setIsDropdownOpen(false);
}; };
// Button variant - cycles through themes // Button variant - toggles between light and dark
if (variant === 'button') { if (variant === 'button') {
return ( return (
<Button <Button
@@ -209,14 +208,14 @@ export const ThemeToggle: React.FC<ThemeToggleProps> = ({
'w-full px-4 py-2 text-left text-sm flex items-center gap-3', 'w-full px-4 py-2 text-left text-sm flex items-center gap-3',
'hover:bg-[var(--bg-secondary)] transition-colors', 'hover:bg-[var(--bg-secondary)] transition-colors',
'focus:bg-[var(--bg-secondary)] focus:outline-none', 'focus:bg-[var(--bg-secondary)] focus:outline-none',
theme === key && 'bg-[var(--bg-secondary)] text-[var(--color-primary)]' displayTheme === key && 'bg-[var(--bg-secondary)] text-[var(--color-primary)]'
)} )}
role="menuitem" role="menuitem"
aria-label={`Cambiar a tema ${label.toLowerCase()}`} aria-label={`Cambiar a tema ${label.toLowerCase()}`}
> >
<Icon className="w-4 h-4" /> <Icon className="w-4 h-4" />
{label} {label}
{theme === key && ( {displayTheme === key && (
<div className="ml-auto w-2 h-2 bg-[var(--color-primary)] rounded-full" /> <div className="ml-auto w-2 h-2 bg-[var(--color-primary)] rounded-full" />
)} )}
</button> </button>

View File

@@ -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';

View File

@@ -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;

View File

@@ -1,15 +1,17 @@
import React, { useState, useMemo } from 'react'; import React, { useState, useMemo } from 'react';
import { Calendar, TrendingUp, AlertTriangle, BarChart3, Download, Settings, Loader, Zap, Brain, Target, CloudRain, Sun, Thermometer } from 'lucide-react'; import { Calendar, TrendingUp, AlertTriangle, BarChart3, Download, Settings, Loader, Zap, Brain, Target, CloudRain, Sun, Thermometer, Package, Activity, Clock } from 'lucide-react';
import { Button, Card, Badge, Select, Table, StatsGrid } from '../../../../components/ui'; import { Button, Card, Badge, Table, StatsGrid, StatusCard, getStatusColor } from '../../../../components/ui';
import type { TableColumn } from '../../../../components/ui'; import type { TableColumn } from '../../../../components/ui';
import { PageHeader } from '../../../../components/layout'; import { PageHeader } from '../../../../components/layout';
import { LoadingSpinner } from '../../../../components/shared';
import { DemandChart, ForecastTable } from '../../../../components/domain/forecasting'; import { DemandChart, ForecastTable } from '../../../../components/domain/forecasting';
import { useTenantForecasts, useCreateSingleForecast } from '../../../../api/hooks/forecasting'; import { useTenantForecasts, useCreateSingleForecast } from '../../../../api/hooks/forecasting';
import { useIngredients } from '../../../../api/hooks/inventory'; import { useIngredients } from '../../../../api/hooks/inventory';
import { useModels } from '../../../../api/hooks/training'; import { useModels } from '../../../../api/hooks/training';
import { useCurrentTenant } from '../../../../stores/tenant.store'; import { useTenantId } from '../../../../hooks/useTenantId';
import { ForecastResponse } from '../../../../api/types/forecasting'; import { ForecastResponse } from '../../../../api/types/forecasting';
import { forecastingService } from '../../../../api/services/forecasting'; import { forecastingService } from '../../../../api/services/forecasting';
import { formatters } from '../../../../components/ui/Stats/StatsPresets';
const ForecastingPage: React.FC = () => { const ForecastingPage: React.FC = () => {
const [selectedProduct, setSelectedProduct] = useState(''); const [selectedProduct, setSelectedProduct] = useState('');
@@ -20,8 +22,7 @@ const ForecastingPage: React.FC = () => {
const [currentForecastData, setCurrentForecastData] = useState<ForecastResponse[]>([]); const [currentForecastData, setCurrentForecastData] = useState<ForecastResponse[]>([]);
// Get tenant ID from tenant store // Get tenant ID from tenant store
const currentTenant = useCurrentTenant(); const tenantId = useTenantId();
const tenantId = currentTenant?.id || '';
// Calculate date range based on selected period // Calculate date range based on selected period
const endDate = new Date(); 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<string, number>);
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[] = [ const forecastColumns: TableColumn[] = [
@@ -224,7 +189,6 @@ const ForecastingPage: React.FC = () => {
// Use either current forecast data or fetched data // Use either current forecast data or fetched data
const forecasts = currentForecastData.length > 0 ? currentForecastData : (forecastsData?.forecasts || []); const forecasts = currentForecastData.length > 0 ? currentForecastData : (forecastsData?.forecasts || []);
const transformedForecasts = transformForecastsForTable(forecasts); const transformedForecasts = transformForecastsForTable(forecasts);
const weatherImpact = getWeatherImpact(forecasts);
const isLoading = forecastsLoading || ingredientsLoading || modelsLoading || isGenerating; const isLoading = forecastsLoading || ingredientsLoading || modelsLoading || isGenerating;
const hasError = forecastsError || ingredientsError || modelsError; const hasError = forecastsError || ingredientsError || modelsError;
@@ -234,157 +198,209 @@ const ForecastingPage: React.FC = () => {
? Math.round((forecasts.reduce((sum, f) => sum + f.confidence_level, 0) / forecasts.length) * 100) ? Math.round((forecasts.reduce((sum, f) => sum + f.confidence_level, 0) / forecasts.length) * 100)
: 0; : 0;
// Get forecast insights from the latest forecast - only real backend data // Get simplified forecast insights
const getForecastInsights = (forecast: ForecastResponse) => { const getForecastInsights = (forecasts: ForecastResponse[]) => {
if (!forecasts || forecasts.length === 0) return [];
const insights = []; const insights = [];
const avgConfidence = forecasts.reduce((sum, f) => sum + f.confidence_level, 0) / forecasts.length;
// Weather data (only factual) // Model confidence
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)
insights.push({ insights.push({
type: 'model', type: 'model',
icon: Target, icon: Target,
title: 'Confianza del Modelo', title: 'Confianza del Modelo',
description: `${Math.round(forecast.confidence_level * 100)}%`, description: `${Math.round(avgConfidence * 100)}%`,
impact: forecast.confidence_level > 0.8 ? 'positive' : forecast.confidence_level > 0.6 ? 'moderate' : 'high' 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; 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 (
<div className="flex items-center justify-center min-h-64">
<LoadingSpinner text="Cargando datos de predicción..." />
</div>
);
}
if (hasError) {
return (
<div className="text-center py-12">
<TrendingUp className="mx-auto h-12 w-12 text-[var(--color-error)] mb-4" />
<h3 className="text-lg font-medium text-[var(--text-primary)] mb-2">
Error al cargar datos
</h3>
<p className="text-[var(--text-secondary)] mb-4">
{forecastsError?.message || ingredientsError?.message || modelsError?.message || 'Ha ocurrido un error inesperado'}
</p>
<Button onClick={() => window.location.reload()}>
Reintentar
</Button>
</div>
);
}
return ( return (
<div className="p-6 space-y-6"> <div className="space-y-6">
<PageHeader <PageHeader
title="Predicción de Demanda" title="Predicción de Demanda"
description="Predicciones inteligentes basadas en IA para optimizar tu producción" description="Sistema inteligente de predicción de demanda basado en IA"
action={
<div className="flex space-x-2">
<Button variant="outline">
<Settings className="w-4 h-4 mr-2" />
Configurar
</Button>
<Button variant="outline">
<Download className="w-4 h-4 mr-2" />
Exportar
</Button>
</div>
}
/> />
{isLoading && ( {/* Stats Grid - Similar to POSPage */}
<Card className="p-6 flex items-center justify-center"> <StatsGrid
<Loader className="h-6 w-6 animate-spin mr-2" /> stats={[
<span> {
{isGenerating ? 'Generando nuevas predicciones...' : 'Cargando predicciones...'} title: 'Ingredientes con Modelos',
</span> value: products.length,
</Card> variant: 'default' as const,
)} icon: Brain,
},
{
title: 'Predicciones Generadas',
value: forecasts.length,
variant: 'info' as const,
icon: TrendingUp,
},
{
title: 'Confianza Promedio',
value: `${averageConfidence}%`,
variant: 'success' as const,
icon: Target,
},
{
title: 'Demanda Total',
value: formatters.number(Math.round(totalDemand)),
variant: 'warning' as const,
icon: BarChart3,
},
]}
columns={4}
/>
{hasError && ( <div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
<Card className="p-6 bg-[var(--color-error-50)] border-[var(--color-error-200)]"> {/* Ingredient Selection Section */}
<div className="flex items-center"> <div className="lg:col-span-2 space-y-6">
<AlertTriangle className="h-5 w-5 text-[var(--color-error-600)] mr-2" /> {/* Ingredients Grid - Similar to POSPage products */}
<span className="text-[var(--color-error-800)]">Error al cargar las predicciones. Por favor, inténtalo de nuevo.</span> <Card className="p-4">
<h3 className="text-lg font-semibold mb-4 flex items-center">
<Brain className="w-5 h-5 mr-2" />
Ingredientes Disponibles ({products.length})
</h3>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
{products.map(product => {
const isSelected = selectedProduct === product.id;
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 (
<StatusCard
key={product.id}
id={product.id}
statusIndicator={getStatusConfig()}
title={product.name}
subtitle={product.category}
primaryValue="Modelo IA"
primaryValueLabel="entrenado"
actions={[
{
label: isSelected ? 'Seleccionado' : 'Seleccionar',
icon: isSelected ? Target : Zap,
variant: isSelected ? 'success' : 'primary',
priority: 'primary',
disabled: isGenerating,
onClick: () => setSelectedProduct(product.id)
}
]}
/>
);
})}
</div> </div>
</Card>
)}
{!isLoading && !hasError && ( {/* Empty State */}
<>
</>
)}
{/* Forecast Configuration */}
<Card className="p-6">
<h3 className="text-lg font-semibold text-[var(--text-primary)] mb-4">Configurar Predicción</h3>
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
{/* Step 1: Select Ingredient */}
<div>
<label className="block text-sm font-medium text-[var(--text-secondary)] mb-2">
<span className="flex items-center">
<span className="bg-[var(--color-info-600)] text-white rounded-full w-6 h-6 flex items-center justify-center text-xs mr-2">1</span>
Seleccionar Ingrediente
</span>
</label>
<select
value={selectedProduct}
onChange={(e) => setSelectedProduct(e.target.value)}
className="w-full px-3 py-2 border border-[var(--border-secondary)] rounded-md"
disabled={isGenerating}
>
<option value="">Selecciona un ingrediente...</option>
{products.map(product => (
<option key={product.id} value={product.id}>
🤖 {product.name}
</option>
))}
</select>
{products.length === 0 && ( {products.length === 0 && (
<p className="text-xs text-[var(--text-tertiary)] mt-1"> <div className="text-center py-12">
No hay ingredientes con modelos entrenados <Brain className="mx-auto h-12 w-12 text-[var(--text-tertiary)] mb-4" />
<h3 className="text-lg font-medium text-[var(--text-primary)] mb-2">
No hay modelos entrenados
</h3>
<p className="text-[var(--text-secondary)] mb-4">
Para generar predicciones, necesitas modelos IA entrenados para tus ingredientes
</p> </p>
<Button
onClick={() => window.location.href = '/app/database/models'}
variant="primary"
>
<Settings className="w-4 h-4 mr-2" />
Entrenar Modelos
</Button>
</div>
)} )}
</Card>
</div> </div>
{/* Step 2: Select Period */} {/* Configuration and Generation Section */}
<div className="space-y-6">
{/* Period Selection */}
<Card className="p-6">
<h3 className="text-lg font-semibold mb-4 flex items-center">
<Calendar className="w-5 h-5 mr-2" />
Configuración
</h3>
<div className="space-y-4">
<div> <div>
<label className="block text-sm font-medium text-[var(--text-secondary)] mb-2"> <label className="block text-sm font-medium text-[var(--text-secondary)] mb-2">
<span className="flex items-center">
<span className={`rounded-full w-6 h-6 flex items-center justify-center text-xs mr-2 ${
selectedProduct ? 'bg-[var(--color-info-600)] text-white' : 'bg-gray-300 text-gray-600'
}`}>2</span>
Período de Predicción Período de Predicción
</span>
</label> </label>
<select <select
value={forecastPeriod} value={forecastPeriod}
onChange={(e) => setForecastPeriod(e.target.value)} onChange={(e) => setForecastPeriod(e.target.value)}
className="w-full px-3 py-2 border border-[var(--border-secondary)] rounded-md" className="w-full px-3 py-2 border border-[var(--border-secondary)] rounded-md"
disabled={!selectedProduct || isGenerating} disabled={isGenerating}
> >
{periods.map(period => ( {periods.map(period => (
<option key={period.value} value={period.value}>{period.label}</option> <option key={period.value} value={period.value}>{period.label}</option>
@@ -392,127 +408,83 @@ const ForecastingPage: React.FC = () => {
</select> </select>
</div> </div>
{/* Step 3: Generate */} {selectedProduct && (
<div> <div className="p-3 bg-[var(--bg-secondary)] rounded-lg">
<label className="block text-sm font-medium text-[var(--text-secondary)] mb-2"> <p className="text-sm font-medium text-[var(--text-primary)]">
<span className="flex items-center"> {products.find(p => p.id === selectedProduct)?.name}
<span className={`rounded-full w-6 h-6 flex items-center justify-center text-xs mr-2 ${ </p>
selectedProduct && forecastPeriod ? 'bg-[var(--color-info-600)] text-white' : 'bg-gray-300 text-gray-600' <p className="text-xs text-[var(--text-secondary)]">
}`}>3</span> Predicción para {forecastPeriod} días
</p>
</div>
)}
</div>
</Card>
{/* Generate Forecast */}
<Card className="p-6">
<h3 className="text-lg font-semibold mb-4 flex items-center">
<Zap className="w-5 h-5 mr-2" />
Generar Predicción Generar Predicción
</span> </h3>
</label>
<Button <Button
onClick={handleGenerateForecast} onClick={handleGenerateForecast}
disabled={!selectedProduct || !forecastPeriod || isGenerating} disabled={!selectedProduct || !forecastPeriod || isGenerating}
className="w-full bg-[var(--color-primary)] text-white hover:bg-[var(--color-primary)]/90" className="w-full"
size="lg"
> >
{isGenerating ? ( {isGenerating ? (
<> <>
<Loader className="w-4 h-4 mr-2 animate-spin" /> <Loader className="w-5 h-5 mr-2 animate-spin" />
Generando... Generando Predicción...
</> </>
) : ( ) : (
<> <>
<Zap className="w-4 h-4 mr-2" /> <Zap className="w-5 h-5 mr-2" />
Generar Predicción Generar Predicción IA
</> </>
)} )}
</Button> </Button>
</div>
</div>
{selectedProduct && ( {!selectedProduct && (
<div className="mt-4 p-3 bg-[var(--color-info-50)] border border-[var(--color-info-200)] rounded-lg"> <p className="text-xs text-[var(--text-tertiary)] mt-2 text-center">
<p className="text-sm text-[var(--color-info-800)]"> Selecciona un ingrediente para continuar
<strong>Ingrediente seleccionado:</strong> {products.find(p => p.id === selectedProduct)?.name}
</p> </p>
<p className="text-xs text-[var(--color-info-600)] mt-1">
Se generará una predicción de demanda para los próximos {forecastPeriod} días usando IA
</p>
</div>
)} )}
</Card> </Card>
</div>
</div>
{/* Results Section - Only show after generating forecast */} {/* Results Section */}
{hasGeneratedForecast && forecasts.length > 0 && ( {hasGeneratedForecast && forecasts.length > 0 && (
<> <>
{/* Enhanced Layout Structure */} {/* Results Header */}
<div className="space-y-8"> <Card className="p-6">
<div className="flex items-center justify-between mb-4">
{/* Key Metrics Row - Using StatsGrid */}
<StatsGrid
columns={4}
gap="md"
stats={[
{
title: "Confianza del Modelo",
value: `${averageConfidence}%`,
icon: Target,
variant: "success",
size: "sm"
},
{
title: "Demanda Total",
value: Math.round(totalDemand),
icon: TrendingUp,
variant: "info",
size: "sm",
subtitle: `próximos ${forecastPeriod} días`
},
{
title: "Días Predichos",
value: forecasts.length,
icon: Calendar,
variant: "default",
size: "sm"
},
{
title: "Variabilidad",
value: (Math.max(...forecasts.map(f => f.predicted_demand)) - Math.min(...forecasts.map(f => f.predicted_demand))).toFixed(1),
icon: BarChart3,
variant: "warning",
size: "sm"
}
]}
/>
{/* Main Content Grid */}
<div className="grid grid-cols-12 gap-6">
{/* Chart Section - Takes most space */}
<div className="col-span-12 lg:col-span-8">
<Card className="h-full">
<div className="p-6 border-b border-gray-200">
<div className="flex items-center justify-between">
<div> <div>
<h3 className="text-xl font-bold text-gray-900">Predicción de Demanda</h3> <h3 className="text-xl font-bold text-[var(--text-primary)]">Resultados de Predicción</h3>
<p className="text-sm text-gray-600 mt-1"> <p className="text-[var(--text-secondary)]">
{products.find(p => p.id === selectedProduct)?.name} {forecastPeriod} días {forecasts.length} puntos {products.find(p => p.id === selectedProduct)?.name} {forecastPeriod} días
</p> </p>
</div> </div>
<div className="flex items-center space-x-3"> <div className="flex items-center space-x-3">
<div className="flex rounded-lg border border-gray-300 overflow-hidden"> <div className="flex rounded-lg border border-[var(--border-secondary)] overflow-hidden">
<button <Button
variant={viewMode === 'chart' ? 'primary' : 'outline'}
onClick={() => setViewMode('chart')} onClick={() => setViewMode('chart')}
className={`px-4 py-2 text-sm font-medium transition-colors ${ size="sm"
viewMode === 'chart'
? 'bg-[var(--color-info-600)] text-white'
: 'bg-white text-gray-700 hover:bg-gray-50'
}`}
> >
📊 Gráfico <BarChart3 className="w-4 h-4 mr-1" />
</button> Gráfico
<button </Button>
<Button
variant={viewMode === 'table' ? 'primary' : 'outline'}
onClick={() => setViewMode('table')} onClick={() => setViewMode('table')}
className={`px-4 py-2 text-sm font-medium transition-colors border-l ${ size="sm"
viewMode === 'table'
? 'bg-[var(--color-info-600)] text-white'
: 'bg-white text-gray-700 hover:bg-gray-50'
}`}
> >
📋 Tabla <Package className="w-4 h-4 mr-1" />
</button> Tabla
</Button>
</div> </div>
<Button variant="outline" size="sm"> <Button variant="outline" size="sm">
<Download className="w-4 h-4 mr-2" /> <Download className="w-4 h-4 mr-2" />
@@ -520,9 +492,9 @@ const ForecastingPage: React.FC = () => {
</Button> </Button>
</div> </div>
</div> </div>
</div>
<div className="p-6"> {/* Chart or Table */}
<div className="min-h-96">
{viewMode === 'chart' ? ( {viewMode === 'chart' ? (
<DemandChart <DemandChart
data={forecasts} data={forecasts}
@@ -530,7 +502,7 @@ const ForecastingPage: React.FC = () => {
period={forecastPeriod} period={forecastPeriod}
loading={isLoading} loading={isLoading}
error={hasError ? 'Error al cargar las predicciones' : null} error={hasError ? 'Error al cargar las predicciones' : null}
height={450} height={400}
title="" title=""
/> />
) : ( ) : (
@@ -538,21 +510,19 @@ const ForecastingPage: React.FC = () => {
)} )}
</div> </div>
</Card> </Card>
</div>
{/* Right Sidebar - Insights */} {/* Insights */}
<div className="col-span-12 lg:col-span-4 space-y-6">
{/* Weather & External Factors */}
<div className="space-y-6">
{/* Forecast Insights */}
{currentInsights.length > 0 && ( {currentInsights.length > 0 && (
<Card className="p-6"> <Card className="p-6">
<h3 className="text-lg font-semibold text-[var(--text-primary)] mb-4">Factores que Afectan la Predicción</h3> <h3 className="text-lg font-semibold text-[var(--text-primary)] mb-4 flex items-center">
<div className="space-y-3"> <Activity className="w-5 h-5 mr-2" />
Factores de Influencia
</h3>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{currentInsights.map((insight, index) => { {currentInsights.map((insight, index) => {
const IconComponent = insight.icon; const IconComponent = insight.icon;
return ( return (
<div key={index} className="flex items-start space-x-3 p-3 bg-[var(--bg-secondary)] rounded-lg"> <div key={index} className="flex items-start space-x-3 p-4 bg-[var(--bg-secondary)] rounded-lg">
<div className={`p-2 rounded-lg ${ <div className={`p-2 rounded-lg ${
insight.impact === 'positive' ? 'bg-[var(--color-success-100)] text-[var(--color-success-600)]' : insight.impact === 'positive' ? 'bg-[var(--color-success-100)] text-[var(--color-success-600)]' :
insight.impact === 'high' ? 'bg-[var(--color-error-100)] text-[var(--color-error-600)]' : insight.impact === 'high' ? 'bg-[var(--color-error-100)] text-[var(--color-error-600)]' :
@@ -571,159 +541,28 @@ const ForecastingPage: React.FC = () => {
</div> </div>
</Card> </Card>
)} )}
{/* Weather Impact */}
{weatherImpact && (
<Card className="overflow-hidden">
<div className="bg-gradient-to-r from-[var(--color-warning-500)] to-[var(--color-error-500)] p-4">
<h3 className="text-lg font-bold text-white flex items-center">
<Sun className="w-5 h-5 mr-2" />
Clima ({weatherImpact.forecastDays} días)
</h3>
<p className="text-[var(--color-warning-100)] text-sm">Impacto meteorológico en la demanda</p>
</div>
<div className="p-4 space-y-4">
{/* Temperature Overview */}
<div className="grid grid-cols-2 gap-3">
<div className="bg-[var(--color-warning-50)] p-3 rounded-lg border border-[var(--color-warning-200)]">
<div className="flex items-center justify-between">
<Thermometer className="w-4 h-4 text-[var(--color-warning-600)]" />
<span className="text-lg font-bold text-[var(--color-warning-800)]">{weatherImpact.avgTemperature}°C</span>
</div>
<p className="text-xs text-[var(--color-warning-600)] mt-1">Promedio</p>
</div>
<div className="bg-[var(--color-info-50)] p-3 rounded-lg border border-[var(--color-info-200)]">
<div className="text-center">
<p className="text-sm font-medium text-[var(--color-info-800)]">{weatherImpact.dominantWeather}</p>
<p className="text-xs text-[var(--color-info-600)] mt-1">Condición</p>
</div>
</div>
</div>
{/* Daily forecast - compact */}
<div className="space-y-1">
<p className="text-xs font-medium text-gray-700">Pronóstico detallado:</p>
<div className="max-h-24 overflow-y-auto space-y-1">
{weatherImpact.dailyForecasts.slice(0, 5).map((day, index) => (
<div key={index} className="flex items-center justify-between text-xs p-2 bg-gray-50 rounded">
<span className="text-gray-600 font-medium">
{new Date(day.date).toLocaleDateString('es-ES', { weekday: 'short', day: 'numeric' })}
</span>
<div className="flex items-center space-x-2">
<span className="text-[var(--color-warning-600)]">{day.temperature}°C</span>
<span className="text-[var(--color-info-600)] font-bold">{day.predicted_demand?.toFixed(0)}</span>
</div>
</div>
))}
</div>
</div>
</div>
</Card>
)}
{/* Model Information */}
{forecasts.length > 0 && (
<Card className="overflow-hidden">
<div className="bg-gradient-to-r from-[var(--chart-quinary)] to-[var(--color-info-700)] p-4">
<h3 className="text-lg font-bold text-white flex items-center">
<Brain className="w-5 h-5 mr-2" />
Modelo IA
</h3>
<p className="text-purple-100 text-sm">Información técnica del algoritmo</p>
</div>
<div className="p-4 space-y-3">
<div className="grid grid-cols-1 gap-2">
<div className="flex items-center justify-between p-2 bg-[var(--color-info-50)] rounded border border-[var(--color-info-200)]">
<span className="text-sm font-medium text-[var(--color-info-800)]">Algoritmo</span>
<Badge variant="purple">{forecasts[0]?.algorithm || 'N/A'}</Badge>
</div>
<div className="flex items-center justify-between p-2 bg-gray-50 rounded">
<span className="text-xs text-gray-600">Versión</span>
<span className="text-xs font-mono text-gray-800">{forecasts[0]?.model_version || 'N/A'}</span>
</div>
<div className="flex items-center justify-between p-2 bg-gray-50 rounded">
<span className="text-xs text-gray-600">Tiempo</span>
<span className="text-xs font-mono text-gray-800">{forecasts[0]?.processing_time_ms || 'N/A'}ms</span>
</div>
</div>
</div>
</Card>
)}
</div>
</div>
</div>
</div>
</> </>
)} )}
{/* Detailed Forecasts Table */} {/* Help Section - Only when no models available */}
{!isLoading && !hasError && transformedForecasts.length > 0 && (
<Card className="p-6">
<h3 className="text-lg font-semibold text-[var(--text-primary)] mb-4">Predicciones Detalladas</h3>
<Table
columns={forecastColumns}
data={transformedForecasts}
rowKey="id"
hover={true}
variant="default"
size="md"
/>
</Card>
)}
{/* Empty States */}
{!isLoading && !hasError && products.length === 0 && ( {!isLoading && !hasError && products.length === 0 && (
<Card className="p-6 text-center"> <Card className="p-6 text-center">
<div className="py-8"> <div className="py-8">
<TrendingUp className="h-12 w-12 text-[var(--color-warning-400)] mx-auto mb-4" /> <Brain className="h-12 w-12 text-[var(--color-info)] mx-auto mb-4" />
<h3 className="text-lg font-medium text-gray-600 mb-2">No hay ingredientes con modelos entrenados</h3> <h3 className="text-lg font-medium text-[var(--text-primary)] mb-2">
<p className="text-gray-500 mb-6"> No hay modelos entrenados
Para generar predicciones, primero necesitas entrenar modelos de IA para tus ingredientes. </h3>
Ve a la página de Modelos IA para entrenar modelos para tus ingredientes. <p className="text-[var(--text-secondary)] mb-6">
Para generar predicciones, necesitas entrenar modelos de IA para tus ingredientes.
</p> </p>
<div className="flex justify-center space-x-4">
<Button <Button
onClick={() => window.location.href = '/app/database/models'}
variant="primary" variant="primary"
onClick={() => {
window.location.href = '/app/database/models';
}}
> >
<Settings className="w-4 h-4 mr-2" /> <Settings className="w-4 h-4 mr-2" />
Configurar Modelos IA Entrenar Modelos IA
</Button> </Button>
</div> </div>
</div>
</Card>
)}
{!isLoading && !hasError && products.length > 0 && !hasGeneratedForecast && (
<Card className="p-6 text-center">
<div className="py-8">
<BarChart3 className="h-12 w-12 text-[var(--color-info-400)] mx-auto mb-4" />
<h3 className="text-lg font-medium text-gray-600 mb-2">Listo para Generar Predicciones</h3>
<p className="text-gray-500 mb-6">
Tienes {products.length} ingrediente{products.length > 1 ? 's' : ''} con modelos entrenados disponibles.
Selecciona un ingrediente y período para comenzar.
</p>
<div className="grid grid-cols-1 md:grid-cols-3 gap-4 max-w-2xl mx-auto">
<div className="text-center p-4 border rounded-lg">
<div className="bg-[var(--color-info-600)] text-white rounded-full w-8 h-8 flex items-center justify-center mx-auto mb-2 text-sm">1</div>
<p className="text-sm font-medium text-gray-600">Selecciona Ingrediente</p>
<p className="text-xs text-gray-500">Elige un ingrediente con modelo IA</p>
</div>
<div className="text-center p-4 border rounded-lg">
<div className="bg-gray-300 text-gray-600 rounded-full w-8 h-8 flex items-center justify-center mx-auto mb-2 text-sm">2</div>
<p className="text-sm font-medium text-gray-600">Define Período</p>
<p className="text-xs text-gray-500">Establece días a predecir</p>
</div>
<div className="text-center p-4 border rounded-lg">
<div className="bg-gray-300 text-gray-600 rounded-full w-8 h-8 flex items-center justify-center mx-auto mb-2 text-sm">3</div>
<p className="text-sm font-medium text-gray-600">Generar Predicción</p>
<p className="text-xs text-gray-500">Obtén insights de IA</p>
</div>
</div>
</div>
</Card> </Card>
)} )}
</div> </div>

View File

@@ -1,22 +1,89 @@
import React, { useState } from 'react'; import React, { useState, useMemo } from 'react';
import { Calendar, TrendingUp, DollarSign, ShoppingCart, Download, Filter } from 'lucide-react'; import { Calendar, TrendingUp, DollarSign, ShoppingCart, Download, Filter, Eye, Users, Package, CreditCard, BarChart3, AlertTriangle, Clock } from 'lucide-react';
import { Button, Card, Badge } from '../../../../components/ui'; import { Button, Card, Badge, StatsGrid, StatusCard, getStatusColor } from '../../../../components/ui';
import { PageHeader } from '../../../../components/layout'; 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 SalesAnalyticsPage: React.FC = () => {
const [selectedPeriod, setSelectedPeriod] = useState('month'); const [selectedPeriod, setSelectedPeriod] = useState('year');
const [selectedMetric, setSelectedMetric] = useState('revenue'); const [selectedCategory, setSelectedCategory] = useState('all');
const [viewMode, setViewMode] = useState<'overview' | 'detailed'>('overview'); const [viewMode, setViewMode] = useState<'overview' | 'detailed'>('overview');
const [exportLoading, setExportLoading] = useState(false);
const salesMetrics = { const tenantId = useTenantId();
totalRevenue: 45678.90,
totalOrders: 1234, // Calculate date range based on selected period
averageOrderValue: 37.02, const { startDate, endDate } = useMemo(() => {
customerCount: 856, const end = new Date();
growthRate: 12.5, const start = new Date();
conversionRate: 68.4,
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 = [ const periods = [
{ value: 'day', label: 'Hoy' }, { value: 'day', label: 'Hoy' },
@@ -26,122 +93,281 @@ const SalesAnalyticsPage: React.FC = () => {
{ value: 'year', label: 'Este Año' }, { value: 'year', label: 'Este Año' },
]; ];
const metrics = [ // Process analytics data
{ value: 'revenue', label: 'Ingresos' }, const salesMetrics = useMemo(() => {
{ value: 'orders', label: 'Pedidos' }, if (!analyticsData) {
{ value: 'customers', label: 'Clientes' }, return {
{ value: 'products', label: 'Productos' }, totalRevenue: 0,
]; totalOrders: 0,
averageOrderValue: 0,
totalQuantity: 0,
averageUnitPrice: 0,
topProducts: [],
revenueByCategory: [],
revenueByChannel: []
};
}
const topProducts = [ return {
{ totalRevenue: analyticsData.total_revenue,
id: '1', totalOrders: analyticsData.total_transactions,
name: 'Pan de Molde Integral', averageOrderValue: analyticsData.total_transactions > 0 ? analyticsData.total_revenue / analyticsData.total_transactions : 0,
revenue: 2250.50, totalQuantity: analyticsData.total_quantity,
units: 245, averageUnitPrice: analyticsData.average_unit_price,
growth: 8.2, topProducts: analyticsData.top_products || [],
category: 'Panes' revenueByCategory: analyticsData.revenue_by_category || [],
}, revenueByChannel: analyticsData.revenue_by_channel || []
{ };
id: '2', }, [analyticsData]);
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'
},
];
const salesByHour = [ // Transform sales records for table
{ hour: '07:00', sales: 145, orders: 12 }, const transformedSalesData = useMemo(() => {
{ hour: '08:00', sales: 289, orders: 18 }, if (!salesRecords) return [];
{ 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 },
];
const customerSegments = [ return salesRecords.map(record => ({
{ segment: 'Clientes Frecuentes', count: 123, revenue: 15678, percentage: 34.3 }, id: record.id,
{ segment: 'Clientes Regulares', count: 245, revenue: 18950, percentage: 41.5 }, date: record.date,
{ segment: 'Clientes Ocasionales', count: 356, revenue: 8760, percentage: 19.2 }, productName: record.product_name,
{ segment: 'Clientes Nuevos', count: 132, revenue: 2290, percentage: 5.0 }, 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 = [ // Categories for filter
{ method: 'Tarjeta', count: 567, revenue: 28450, percentage: 62.3 }, const categories = useMemo(() => {
{ method: 'Efectivo', count: 445, revenue: 13890, percentage: 30.4 }, const allCategories = [{ value: 'all', label: 'Todas las Categorías' }];
{ method: 'Transferencia', count: 178, revenue: 2890, percentage: 6.3 }, if (categoriesData) {
{ method: 'Otros', count: 44, revenue: 448, percentage: 1.0 }, allCategories.push(...categoriesData.map(cat => ({ value: cat, label: cat })));
]; }
return allCategories;
}, [categoriesData]);
const getGrowthBadge = (growth: number) => { // Handle export functionality
if (growth > 0) { const handleExport = async (format: 'csv' | 'excel' | 'pdf') => {
return <Badge variant="green">+{growth.toFixed(1)}%</Badge>; if (!salesRecords || salesRecords.length === 0) {
} else if (growth < 0) { alert('No hay datos para exportar');
return <Badge variant="red">{growth.toFixed(1)}%</Badge>; return;
} else { }
return <Badge variant="gray">0%</Badge>;
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) => { // Table columns definition
return growth >= 0 ? 'text-[var(--color-success)]' : 'text-[var(--color-error)]'; 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) => (
<span className="font-medium text-[var(--color-success)]">
{formatters.currency(value)}
</span>
)
},
{
key: 'channel',
title: 'Canal',
dataIndex: 'channel'
},
{
key: 'validated',
title: 'Estado',
dataIndex: 'validated',
render: (value) => (
<Badge variant={value ? 'success' : 'warning'}>
{value ? 'Validado' : 'Pendiente'}
</Badge>
)
}
];
// 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 ( return (
<div className="p-6 space-y-6"> <div className="flex items-center justify-center min-h-64">
<LoadingSpinner text="Cargando análisis de ventas..." />
</div>
);
}
// Special handling for when the sales API endpoints are not available
if (isApiEndpointUnavailable) {
return (
<div className="space-y-6">
<PageHeader <PageHeader
title="Análisis de Ventas" title="Análisis de Ventas"
description="Análisis detallado del rendimiento de ventas y tendencias de tu panadería" description="Análisis detallado del rendimiento de ventas y tendencias de tu panadería"
action={ />
<div className="flex space-x-2">
<Button variant="outline"> <Card className="p-12 text-center">
<Filter className="w-4 h-4 mr-2" /> <ShoppingCart className="mx-auto h-16 w-16 text-[var(--color-info)] mb-6" />
Filtros <h3 className="text-xl font-semibold text-[var(--text-primary)] mb-4">
</Button> Funcionalidad en Desarrollo
<Button variant="outline"> </h3>
<Download className="w-4 h-4 mr-2" /> <p className="text-[var(--text-secondary)] mb-6 max-w-md mx-auto">
Exportar El módulo de análisis de ventas está siendo desarrollado.
</Button> Los endpoints de la API de ventas aún no están disponibles en el backend.
</p>
<div className="space-y-4">
<div className="p-4 bg-[var(--bg-secondary)] rounded-lg max-w-md mx-auto">
<h4 className="font-medium text-[var(--text-primary)] mb-2">Funcionalidades Planeadas:</h4>
<ul className="text-sm text-[var(--text-secondary)] space-y-1 text-left">
<li> Análisis de ingresos y transacciones</li>
<li> Productos más vendidos</li>
<li> Análisis por categorías y canales</li>
<li> Exportación de datos de ventas</li>
<li> Filtrado por períodos y categorías</li>
</ul>
</div> </div>
<Badge variant="info">
Estado: En desarrollo
</Badge>
</div>
</Card>
</div>
);
} }
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 (
<div className="text-center py-12">
<AlertTriangle className="mx-auto h-12 w-12 text-[var(--color-error)] mb-4" />
<h3 className="text-lg font-medium text-[var(--text-primary)] mb-2">
Error al cargar datos de ventas
</h3>
<p className="text-[var(--text-secondary)] mb-4">
{errorMessage}
</p>
<div className="space-y-2">
<Button onClick={() => window.location.reload()}>
Reintentar
</Button>
<p className="text-xs text-[var(--text-tertiary)]">
Si el problema persiste, es posible que el endpoint de ventas no esté disponible
</p>
</div>
</div>
);
}
return (
<div className="space-y-6">
<PageHeader
title="Análisis de Ventas"
description="Análisis detallado del rendimiento de ventas y tendencias de tu panadería"
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 */} {/* Controls */}
<Card className="p-6"> <Card className="p-4">
<div className="flex flex-col sm:flex-row gap-4"> <div className="flex flex-col sm:flex-row gap-4">
<div className="flex-1 grid grid-cols-1 sm:grid-cols-3 gap-4"> <div className="flex-1 grid grid-cols-1 sm:grid-cols-3 gap-4">
<div> <div>
@@ -149,7 +375,7 @@ const SalesAnalyticsPage: React.FC = () => {
<select <select
value={selectedPeriod} value={selectedPeriod}
onChange={(e) => setSelectedPeriod(e.target.value)} onChange={(e) => setSelectedPeriod(e.target.value)}
className="w-full px-3 py-2 border border-[var(--border-secondary)] rounded-md" className="w-full px-3 py-2 border border-[var(--border-secondary)] rounded-md bg-[var(--bg-primary)] text-[var(--text-primary)]"
> >
{periods.map(period => ( {periods.map(period => (
<option key={period.value} value={period.value}>{period.label}</option> <option key={period.value} value={period.value}>{period.label}</option>
@@ -158,219 +384,361 @@ const SalesAnalyticsPage: React.FC = () => {
</div> </div>
<div> <div>
<label className="block text-sm font-medium text-[var(--text-secondary)] mb-2">Métrica Principal</label> <label className="block text-sm font-medium text-[var(--text-secondary)] mb-2">Categoría</label>
<select <select
value={selectedMetric} value={selectedCategory}
onChange={(e) => setSelectedMetric(e.target.value)} onChange={(e) => setSelectedCategory(e.target.value)}
className="w-full px-3 py-2 border border-[var(--border-secondary)] rounded-md" 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 => (
<option key={metric.value} value={metric.value}>{metric.label}</option> <option key={category.value} value={category.value}>{category.label}</option>
))} ))}
</select> </select>
</div> </div>
<div> <div>
<label className="block text-sm font-medium text-[var(--text-secondary)] mb-2">Vista</label> <label className="block text-sm font-medium text-[var(--text-secondary)] mb-2">Vista</label>
<div className="flex rounded-md border border-[var(--border-secondary)]"> <div className="flex rounded-md border border-[var(--border-secondary)] overflow-hidden">
<button <Button
variant={viewMode === 'overview' ? 'primary' : 'outline'}
onClick={() => setViewMode('overview')} onClick={() => setViewMode('overview')}
className={`px-3 py-2 text-sm ${viewMode === 'overview' ? 'bg-blue-600 text-white' : 'bg-white text-[var(--text-secondary)]'} rounded-l-md`} size="sm"
className="rounded-none flex-1"
> >
<BarChart3 className="w-4 h-4 mr-1" />
General General
</button> </Button>
<button <Button
variant={viewMode === 'detailed' ? 'primary' : 'outline'}
onClick={() => setViewMode('detailed')} onClick={() => setViewMode('detailed')}
className={`px-3 py-2 text-sm ${viewMode === 'detailed' ? 'bg-blue-600 text-white' : 'bg-white text-[var(--text-secondary)]'} rounded-r-md border-l`} size="sm"
className="rounded-none flex-1 border-l-0"
> >
<Eye className="w-4 h-4 mr-1" />
Detallado Detallado
</button> </Button>
</div> </div>
</div> </div>
</div> </div>
</div> </div>
</Card> </Card>
{/* Key Metrics */} {/* Stats Grid */}
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-6 gap-4"> <StatsGrid
<Card className="p-4"> stats={[
<div className="flex items-center justify-between"> {
<div> title: 'Ingresos Totales',
<p className="text-sm font-medium text-[var(--text-secondary)]">Ingresos Totales</p> value: formatters.currency(salesMetrics.totalRevenue),
<p className="text-2xl font-bold text-[var(--color-success)]">{salesMetrics.totalRevenue.toLocaleString()}</p> variant: 'success' as const,
</div> icon: DollarSign,
<DollarSign className="h-8 w-8 text-[var(--color-success)]" /> },
</div> {
<div className="mt-2"> title: 'Total Transacciones',
{getGrowthBadge(salesMetrics.growthRate)} value: salesMetrics.totalOrders.toLocaleString(),
</div> variant: 'info' as const,
</Card> icon: ShoppingCart,
},
<Card className="p-4"> {
<div className="flex items-center justify-between"> title: 'Ticket Promedio',
<div> value: formatters.currency(salesMetrics.averageOrderValue),
<p className="text-sm font-medium text-[var(--text-secondary)]">Total Pedidos</p> variant: 'warning' as const,
<p className="text-2xl font-bold text-[var(--color-info)]">{salesMetrics.totalOrders.toLocaleString()}</p> icon: CreditCard,
</div> },
<ShoppingCart className="h-8 w-8 text-[var(--color-info)]" /> {
</div> title: 'Cantidad Total',
</Card> value: salesMetrics.totalQuantity.toLocaleString(),
variant: 'default' as const,
<Card className="p-4"> icon: Package,
<div className="flex items-center justify-between"> },
<div> {
<p className="text-sm font-medium text-[var(--text-secondary)]">Valor Promedio</p> title: 'Precio Unitario Promedio',
<p className="text-2xl font-bold text-purple-600">{salesMetrics.averageOrderValue.toFixed(2)}</p> value: formatters.currency(salesMetrics.averageUnitPrice),
</div> variant: 'info' as const,
<div className="h-8 w-8 bg-purple-100 rounded-full flex items-center justify-center"> icon: TrendingUp,
<svg className="h-5 w-5 text-purple-600" fill="none" viewBox="0 0 24 24" stroke="currentColor"> },
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 7h6m0 10v-3m-3 3h.01M9 17h.01M9 14h.01M12 14h.01M15 11h.01M12 11h.01M9 11h.01M7 21h10a2 2 0 002-2V5a2 2 0 00-2-2H7a2 2 0 00-2 2v14a2 2 0 002 2z" /> {
</svg> title: 'Productos Top',
</div> value: salesMetrics.topProducts.length,
</div> variant: 'success' as const,
</Card> icon: Users,
},
<Card className="p-4"> ]}
<div className="flex items-center justify-between"> columns={3}
<div> />
<p className="text-sm font-medium text-[var(--text-secondary)]">Clientes</p>
<p className="text-2xl font-bold text-[var(--color-primary)]">{salesMetrics.customerCount}</p>
</div>
<div className="h-8 w-8 bg-[var(--color-primary)]/10 rounded-full flex items-center justify-center">
<svg className="h-5 w-5 text-[var(--color-primary)]" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 4.354a4 4 0 110 5.292M15 21H3v-1a6 6 0 0112 0v1zm0 0h6v-1a6 6 0 00-9-5.197m13.5-.5a2.5 2.5 0 11-5 0 2.5 2.5 0 015 0z" />
</svg>
</div>
</div>
</Card>
<Card className="p-4">
<div className="flex items-center justify-between">
<div>
<p className="text-sm font-medium text-[var(--text-secondary)]">Tasa Crecimiento</p>
<p className={`text-2xl font-bold ${getGrowthColor(salesMetrics.growthRate)}`}>
{salesMetrics.growthRate > 0 ? '+' : ''}{salesMetrics.growthRate.toFixed(1)}%
</p>
</div>
<TrendingUp className={`h-8 w-8 ${getGrowthColor(salesMetrics.growthRate)}`} />
</div>
</Card>
<Card className="p-4">
<div className="flex items-center justify-between">
<div>
<p className="text-sm font-medium text-[var(--text-secondary)]">Conversión</p>
<p className="text-2xl font-bold text-indigo-600">{salesMetrics.conversionRate}%</p>
</div>
<div className="h-8 w-8 bg-indigo-100 rounded-full flex items-center justify-center">
<svg className="h-5 w-5 text-indigo-600" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13 10V3L4 14h7v7l9-11h-7z" />
</svg>
</div>
</div>
</Card>
</div>
{viewMode === 'overview' ? ( {viewMode === 'overview' ? (
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6"> <div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
{/* Sales by Hour Chart */}
<Card className="p-6">
<h3 className="text-lg font-semibold text-[var(--text-primary)] mb-4">Ventas por Hora</h3>
<div className="h-64 flex items-end space-x-1 justify-between">
{salesByHour.map((data, index) => (
<div key={index} className="flex flex-col items-center flex-1">
<div
className="w-full bg-[var(--color-info)]/50 rounded-t"
style={{
height: `${(data.sales / Math.max(...salesByHour.map(d => d.sales))) * 200}px`,
minHeight: '4px'
}}
></div>
<span className="text-xs text-[var(--text-tertiary)] mt-2 transform -rotate-45 origin-center">
{data.hour}
</span>
</div>
))}
</div>
</Card>
{/* Top Products */} {/* Top Products */}
<Card className="p-6"> <Card className="p-6">
<h3 className="text-lg font-semibold text-[var(--text-primary)] mb-4">Productos Más Vendidos</h3> <h3 className="text-lg font-semibold text-[var(--text-primary)] mb-4 flex items-center">
<TrendingUp className="w-5 h-5 mr-2" />
Productos Más Vendidos
</h3>
<div className="space-y-3"> <div className="space-y-3">
{topProducts.slice(0, 5).map((product, index) => ( {salesMetrics.topProducts.length === 0 ? (
<div key={product.id} className="flex items-center justify-between p-3 bg-[var(--bg-secondary)] rounded-lg"> <div className="text-center py-8">
<Package className="mx-auto h-8 w-8 text-[var(--text-tertiary)] mb-2" />
<p className="text-[var(--text-secondary)]">No hay datos de productos para este período</p>
</div>
) : (
salesMetrics.topProducts.slice(0, 5).map((product, index) => (
<div key={product.product_name} className="flex items-center justify-between p-3 bg-[var(--bg-secondary)] rounded-lg">
<div className="flex items-center space-x-3"> <div className="flex items-center space-x-3">
<span className="text-sm font-medium text-[var(--text-tertiary)] w-6">{index + 1}.</span> <span className="text-sm font-medium text-[var(--text-tertiary)] w-6">{index + 1}.</span>
<div> <div>
<p className="text-sm font-medium text-[var(--text-primary)]">{product.name}</p> <p className="text-sm font-medium text-[var(--text-primary)]">{product.product_name}</p>
<p className="text-xs text-[var(--text-tertiary)]">{product.category} {product.units} unidades</p> <p className="text-xs text-[var(--text-tertiary)]">{product.total_quantity} vendidos {product.transaction_count} transacciones</p>
</div> </div>
</div> </div>
<div className="text-right"> <div className="text-right">
<p className="text-sm font-medium text-[var(--text-primary)]">{product.revenue.toLocaleString()}</p> <p className="text-sm font-medium text-[var(--color-success)]">
{getGrowthBadge(product.growth)} {formatters.currency(product.total_revenue)}
</p>
</div> </div>
</div> </div>
))} ))
)}
</div> </div>
</Card> </Card>
{/* Customer Segments */} {/* Revenue by Category */}
<Card className="p-6"> <Card className="p-6">
<h3 className="text-lg font-semibold text-[var(--text-primary)] mb-4">Segmentos de Clientes</h3> <h3 className="text-lg font-semibold text-[var(--text-primary)] mb-4 flex items-center">
<div className="space-y-4"> <BarChart3 className="w-5 h-5 mr-2" />
{customerSegments.map((segment, index) => ( Ingresos por Categoría
<div key={index} className="space-y-2"> </h3>
<div className="space-y-3">
{salesMetrics.revenueByCategory.length === 0 ? (
<div className="text-center py-8">
<BarChart3 className="mx-auto h-8 w-8 text-[var(--text-tertiary)] mb-2" />
<p className="text-[var(--text-secondary)]">No hay datos de categorías para este período</p>
</div>
) : (
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 (
<div key={category.category} className="space-y-2">
<div className="flex justify-between items-center"> <div className="flex justify-between items-center">
<span className="text-sm font-medium text-[var(--text-primary)]">{segment.segment}</span> <span className="text-sm font-medium text-[var(--text-primary)]">{category.category}</span>
<span className="text-sm text-[var(--text-secondary)]">{segment.percentage}%</span> <span className="text-sm font-medium text-[var(--color-success)]">
{formatters.currency(category.revenue)}
</span>
</div> </div>
<div className="w-full bg-[var(--bg-quaternary)] rounded-full h-2"> <div className="w-full bg-[var(--bg-quaternary)] rounded-full h-2">
<div <div
className="bg-blue-600 h-2 rounded-full" className="bg-[var(--color-info)] h-2 rounded-full transition-all duration-300"
style={{ width: `${segment.percentage}%` }} style={{ width: `${percentage}%` }}
></div> ></div>
</div> </div>
<div className="flex justify-between text-xs text-[var(--text-tertiary)]"> <div className="flex justify-between text-xs text-[var(--text-tertiary)]">
<span>{segment.count} clientes</span> <span>{category.quantity} unidades</span>
<span>{segment.revenue.toLocaleString()}</span> <span>{percentage.toFixed(1)}% del total</span>
</div> </div>
</div> </div>
))} );
})
)}
</div> </div>
</Card> </Card>
{/* Payment Methods */} {/* Revenue by Channel */}
<Card className="p-6"> <Card className="p-6">
<h3 className="text-lg font-semibold text-[var(--text-primary)] mb-4">Métodos de Pago</h3> <h3 className="text-lg font-semibold text-[var(--text-primary)] mb-4 flex items-center">
<ShoppingCart className="w-5 h-5 mr-2" />
Ingresos por Canal
</h3>
<div className="space-y-3"> <div className="space-y-3">
{paymentMethods.map((method, index) => ( {salesMetrics.revenueByChannel.length === 0 ? (
<div key={index} className="flex items-center justify-between p-3 border rounded-lg"> <div className="text-center py-8">
<ShoppingCart className="mx-auto h-8 w-8 text-[var(--text-tertiary)] mb-2" />
<p className="text-[var(--text-secondary)]">No hay datos de canales para este período</p>
</div>
) : (
salesMetrics.revenueByChannel.map((channel, index) => (
<div key={channel.channel} className="flex items-center justify-between p-3 bg-[var(--bg-secondary)] rounded-lg">
<div className="flex items-center space-x-3"> <div className="flex items-center space-x-3">
<div className="w-3 h-3 bg-[var(--color-info)]/50 rounded-full"></div> <div className={`w-3 h-3 rounded-full ${
index === 0 ? 'bg-[var(--color-success)]' :
index === 1 ? 'bg-[var(--color-info)]' :
index === 2 ? 'bg-[var(--color-warning)]' :
'bg-[var(--color-primary)]'
}`}></div>
<div> <div>
<p className="text-sm font-medium text-[var(--text-primary)]">{method.method}</p> <p className="text-sm font-medium text-[var(--text-primary)]">{channel.channel}</p>
<p className="text-xs text-[var(--text-tertiary)]">{method.count} transacciones</p> <p className="text-xs text-[var(--text-tertiary)]">{channel.quantity} unidades</p>
</div> </div>
</div> </div>
<div className="text-right"> <div className="text-right">
<p className="text-sm font-medium text-[var(--text-primary)]">{method.revenue.toLocaleString()}</p> <p className="text-sm font-medium text-[var(--color-success)]">
<p className="text-xs text-[var(--text-tertiary)]">{method.percentage}%</p> {formatters.currency(channel.revenue)}
</p>
</div>
</div>
))
)}
</div>
</Card>
{/* Period Summary */}
<Card className="p-6">
<h3 className="text-lg font-semibold text-[var(--text-primary)] mb-4 flex items-center">
<Calendar className="w-5 h-5 mr-2" />
Resumen del Período
</h3>
<div className="space-y-4">
<div className="grid grid-cols-2 gap-4">
<div className="text-center p-3 bg-[var(--bg-secondary)] rounded-lg">
<p className="text-xs text-[var(--text-tertiary)] mb-1">Período Seleccionado</p>
<p className="text-sm font-medium text-[var(--text-primary)]">
{periods.find(p => p.value === selectedPeriod)?.label}
</p>
</div>
<div className="text-center p-3 bg-[var(--bg-secondary)] rounded-lg">
<p className="text-xs text-[var(--text-tertiary)] mb-1">Registros Encontrados</p>
<p className="text-sm font-medium text-[var(--text-primary)]">
{salesRecords?.length || 0}
</p>
</div>
</div>
<div className="border-t pt-4">
<div className="flex justify-between items-center text-sm">
<span className="text-[var(--text-secondary)]">Fecha inicio:</span>
<span className="font-medium text-[var(--text-primary)]">
{new Date(startDate).toLocaleDateString('es-ES')}
</span>
</div>
<div className="flex justify-between items-center text-sm mt-1">
<span className="text-[var(--text-secondary)]">Fecha fin:</span>
<span className="font-medium text-[var(--text-primary)]">
{new Date(endDate).toLocaleDateString('es-ES')}
</span>
</div> </div>
</div> </div>
))}
</div> </div>
</Card> </Card>
</div> </div>
) : ( ) : (
<div className="space-y-6"> <Card className="p-6">
{/* Detailed Analytics Dashboard */} <div className="flex items-center justify-between mb-6">
<AnalyticsDashboard /> <h3 className="text-lg font-semibold text-[var(--text-primary)] flex items-center">
<Eye className="w-5 h-5 mr-2" />
{/* Detailed Reports Table */} Registro Detallado de Ventas
<ReportsTable /> </h3>
<div className="flex items-center space-x-2">
<Badge variant="info">
{transformedSalesData.length} registros
</Badge>
<Button
variant="outline"
size="sm"
onClick={() => handleExport('csv')}
disabled={exportLoading || transformedSalesData.length === 0}
>
{exportLoading ? (
<Clock className="w-4 h-4 mr-2 animate-spin" />
) : (
<Download className="w-4 h-4 mr-2" />
)}
Exportar CSV
</Button>
</div> </div>
</div>
{transformedSalesData.length === 0 ? (
<div className="text-center py-12">
<ShoppingCart className="mx-auto h-12 w-12 text-[var(--text-tertiary)] mb-4" />
<h3 className="text-lg font-medium text-[var(--text-primary)] mb-2">
No hay registros de ventas
</h3>
<p className="text-[var(--text-secondary)] mb-4">
No se encontraron registros de ventas para el período y filtros seleccionados
</p>
</div>
) : (
<div className="space-y-4">
{/* Sales Records as Cards */}
{transformedSalesData.slice(0, 50).map((record, index) => (
<Card key={record.id} className="p-4 hover:shadow-md transition-shadow">
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-4">
{/* Left Side - Product Info */}
<div className="flex-1">
<div className="flex items-start justify-between mb-2">
<div>
<h4 className="font-medium text-[var(--text-primary)] text-sm sm:text-base">
{record.productName}
</h4>
<p className="text-xs text-[var(--text-tertiary)]">
{record.category} {new Date(record.date).toLocaleDateString('es-ES')}
</p>
</div>
<Badge variant={record.validated ? 'success' : 'warning'} className="text-xs">
{record.validated ? 'Validado' : 'Pendiente'}
</Badge>
</div>
{/* Details Grid */}
<div className="grid grid-cols-2 sm:grid-cols-4 gap-2 text-xs">
<div>
<span className="text-[var(--text-tertiary)]">Cantidad:</span>
<p className="font-medium text-[var(--text-primary)]">{record.quantity.toLocaleString()}</p>
</div>
<div>
<span className="text-[var(--text-tertiary)]">Precio Unit.:</span>
<p className="font-medium text-[var(--text-primary)]">{formatters.currency(record.unitPrice)}</p>
</div>
<div>
<span className="text-[var(--text-tertiary)]">Canal:</span>
<p className="font-medium text-[var(--text-primary)]">{record.channel}</p>
</div>
{record.discount > 0 && (
<div>
<span className="text-[var(--text-tertiary)]">Descuento:</span>
<p className="font-medium text-[var(--color-warning)]">{formatters.currency(record.discount)}</p>
</div>
)}
</div>
</div>
{/* Right Side - Revenue */}
<div className="flex items-center justify-between sm:flex-col sm:items-end sm:justify-center gap-2">
<div className="text-right">
<p className="text-xs text-[var(--text-tertiary)]">Ingresos</p>
<p className="text-lg font-bold text-[var(--color-success)]">
{formatters.currency(record.totalRevenue)}
</p>
</div>
<div className="flex items-center gap-1">
<DollarSign className="w-3 h-3 text-[var(--color-success)]" />
<span className="text-xs text-[var(--text-tertiary)]">#{index + 1}</span>
</div>
</div>
</div>
</Card>
))}
{/* Load More / Pagination Info */}
{transformedSalesData.length > 50 && (
<Card className="p-4 text-center">
<p className="text-sm text-[var(--text-secondary)] mb-3">
Mostrando 50 de {transformedSalesData.length} registros
</p>
<Button
variant="outline"
size="sm"
onClick={() => handleExport('csv')}
className="mr-2"
>
<Download className="w-4 h-4 mr-2" />
Exportar todos los datos
</Button>
</Card>
)}
</div>
)}
</Card>
)} )}
</div> </div>
); );

View File

@@ -15,7 +15,7 @@ import {
// Import AddStockModal separately since we need it for adding batches // Import AddStockModal separately since we need it for adding batches
import AddStockModal from '../../../../components/domain/inventory/AddStockModal'; import AddStockModal from '../../../../components/domain/inventory/AddStockModal';
import { useIngredients, useStockAnalytics, useStockMovements, useStockByIngredient, useCreateIngredient, useSoftDeleteIngredient, useHardDeleteIngredient, useAddStock, useConsumeStock, useUpdateIngredient, useTransformationsByIngredient } from '../../../../api/hooks/inventory'; import { useIngredients, useStockAnalytics, useStockMovements, useStockByIngredient, useCreateIngredient, useSoftDeleteIngredient, useHardDeleteIngredient, useAddStock, useConsumeStock, useUpdateIngredient, 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'; import { IngredientResponse, StockCreate, StockMovementCreate, IngredientCreate } from '../../../../api/types/inventory';
const InventoryPage: React.FC = () => { const InventoryPage: React.FC = () => {
@@ -30,8 +30,7 @@ const InventoryPage: React.FC = () => {
const [showDeleteModal, setShowDeleteModal] = useState(false); const [showDeleteModal, setShowDeleteModal] = useState(false);
const [showAddBatch, setShowAddBatch] = useState(false); const [showAddBatch, setShowAddBatch] = useState(false);
const currentTenant = useCurrentTenant(); const tenantId = useTenantId();
const tenantId = currentTenant?.id || '';
// Mutations // Mutations
const createIngredientMutation = useCreateIngredient(); const createIngredientMutation = useCreateIngredient();

View File

@@ -1,16 +1,24 @@
import React, { useState } from 'react'; import React, { useState, useMemo } from 'react';
import { Plus, Minus, ShoppingCart, CreditCard, Banknote, Calculator, User, Receipt } from 'lucide-react'; import { Plus, Minus, ShoppingCart, CreditCard, Banknote, Calculator, User, Receipt, Package, Euro, TrendingUp, Clock } from 'lucide-react';
import { Button, Input, Card, Badge } from '../../../../components/ui'; import { Button, Input, Card, Badge, StatsGrid, StatusCard, getStatusColor } from '../../../../components/ui';
import { PageHeader } from '../../../../components/layout'; 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';
const POSPage: React.FC = () => { interface CartItem {
const [cart, setCart] = useState<Array<{
id: string; id: string;
name: string; name: string;
price: number; price: number;
quantity: number; quantity: number;
category: string; category: string;
}>>([]); stock: number;
}
const POSPage: React.FC = () => {
const [cart, setCart] = useState<CartItem[]>([]);
const [selectedCategory, setSelectedCategory] = useState('all'); const [selectedCategory, setSelectedCategory] = useState('all');
const [customerInfo, setCustomerInfo] = useState({ const [customerInfo, setCustomerInfo] = useState({
name: '', name: '',
@@ -20,73 +28,65 @@ const POSPage: React.FC = () => {
const [paymentMethod, setPaymentMethod] = useState<'cash' | 'card' | 'transfer'>('cash'); const [paymentMethod, setPaymentMethod] = useState<'cash' | 'card' | 'transfer'>('cash');
const [cashReceived, setCashReceived] = useState(''); const [cashReceived, setCashReceived] = useState('');
const products = [ const tenantId = useTenantId();
{
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 categories = [ // Fetch finished products from API
{ id: 'all', name: 'Todos' }, const {
{ id: 'bread', name: 'Panes' }, data: ingredientsData,
{ id: 'pastry', name: 'Bollería' }, isLoading: productsLoading,
{ id: 'cake', name: 'Tartas' }, error: productsError
{ id: 'other', name: 'Otros' }, } = useIngredients(tenantId, {
]; // Filter for finished products only
category: undefined, // We'll filter client-side for now
search: undefined
});
const filteredProducts = products.filter(product => // 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 selectedCategory === 'all' || product.category === selectedCategory
); );
}, [products, selectedCategory]);
const addToCart = (product: typeof products[0]) => { const addToCart = (product: typeof products[0]) => {
setCart(prevCart => { setCart(prevCart => {
const existingItem = prevCart.find(item => item.id === product.id); const existingItem = prevCart.find(item => item.id === product.id);
if (existingItem) { 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 => return prevCart.map(item =>
item.id === product.id item.id === product.id
? { ...item, quantity: item.quantity + 1 } ? { ...item, quantity: item.quantity + 1 }
@@ -99,6 +99,7 @@ const POSPage: React.FC = () => {
price: product.price, price: product.price,
quantity: 1, quantity: 1,
category: product.category, category: product.category,
stock: product.stock
}]; }];
} }
}); });
@@ -109,9 +110,14 @@ const POSPage: React.FC = () => {
setCart(prevCart => prevCart.filter(item => item.id !== id)); setCart(prevCart => prevCart.filter(item => item.id !== id));
} else { } else {
setCart(prevCart => setCart(prevCart =>
prevCart.map(item => prevCart.map(item => {
item.id === id ? { ...item, quantity } : 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;
})
); );
} }
}; };
@@ -129,7 +135,7 @@ const POSPage: React.FC = () => {
const processPayment = () => { const processPayment = () => {
if (cart.length === 0) return; if (cart.length === 0) return;
// Process payment logic here // TODO: Integrate with real POS API endpoint
console.log('Processing payment:', { console.log('Processing payment:', {
cart, cart,
customerInfo, customerInfo,
@@ -147,17 +153,108 @@ const POSPage: React.FC = () => {
alert('Venta procesada exitosamente'); 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 ( return (
<div className="p-6 h-screen flex flex-col"> <div className="flex items-center justify-center min-h-64">
<LoadingSpinner text="Cargando productos..." />
</div>
);
}
if (productsError) {
return (
<div className="text-center py-12">
<Package className="mx-auto h-12 w-12 text-red-500 mb-4" />
<h3 className="text-lg font-medium text-[var(--text-primary)] mb-2">
Error al cargar productos
</h3>
<p className="text-[var(--text-secondary)] mb-4">
{productsError.message || 'Ha ocurrido un error inesperado'}
</p>
<Button onClick={() => window.location.reload()}>
Reintentar
</Button>
</div>
);
}
return (
<div className="space-y-6">
<PageHeader <PageHeader
title="Punto de Venta" title="Punto de Venta"
description="Sistema de ventas integrado" description="Sistema de ventas para productos terminados"
/> />
<div className="flex-1 grid grid-cols-1 lg:grid-cols-3 gap-6 mt-6"> {/* Stats Grid */}
<StatsGrid
stats={stats}
columns={3}
/>
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
{/* Products Section */} {/* Products Section */}
<div className="lg:col-span-2 space-y-6"> <div className="lg:col-span-2 space-y-6">
{/* Categories */} {/* Categories */}
<Card className="p-4">
<div className="flex space-x-2 overflow-x-auto"> <div className="flex space-x-2 overflow-x-auto">
{categories.map(category => ( {categories.map(category => (
<Button <Button
@@ -170,26 +267,87 @@ const POSPage: React.FC = () => {
</Button> </Button>
))} ))}
</div> </div>
</Card>
{/* Products Grid */} {/* Products Grid */}
<div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-4"> <div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{filteredProducts.map(product => ( {filteredProducts.map(product => {
<Card 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 (
<StatusCard
key={product.id} key={product.id}
className="p-4 cursor-pointer hover:shadow-md transition-shadow" id={product.id}
onClick={() => addToCart(product)} statusIndicator={getStockStatusConfig()}
> title={product.name}
<img subtitle={product.category.charAt(0).toUpperCase() + product.category.slice(1)}
src={product.image} primaryValue={formatters.currency(product.price)}
alt={product.name} primaryValueLabel="precio"
className="w-full h-20 object-cover rounded mb-3" secondaryInfo={inCart ? {
label: 'En carrito',
value: cartQuantity.toString()
} : undefined}
actions={[
{
label: 'Agregar al Carrito',
icon: Plus,
variant: 'primary',
priority: 'primary',
disabled: remainingStock <= 0,
onClick: () => addToCart(product)
}
]}
/> />
<h3 className="font-medium text-sm mb-1 line-clamp-2">{product.name}</h3> );
<p className="text-lg font-bold text-[var(--color-success)]">{product.price.toFixed(2)}</p> })}
<p className="text-xs text-[var(--text-tertiary)]">Stock: {product.stock}</p>
</Card>
))}
</div> </div>
{/* Empty State */}
{filteredProducts.length === 0 && (
<div className="text-center py-12">
<Package className="mx-auto h-12 w-12 text-[var(--text-tertiary)] mb-4" />
<h3 className="text-lg font-medium text-[var(--text-primary)] mb-2">
No hay productos disponibles
</h3>
<p className="text-[var(--text-secondary)] mb-4">
{selectedCategory === 'all'
? 'No hay productos en stock en este momento'
: `No hay productos en la categoría "${categories.find(c => c.id === selectedCategory)?.name}"`
}
</p>
</div>
)}
</div> </div>
{/* Cart and Checkout Section */} {/* Cart and Checkout Section */}
@@ -212,11 +370,16 @@ const POSPage: React.FC = () => {
{cart.length === 0 ? ( {cart.length === 0 ? (
<p className="text-[var(--text-tertiary)] text-center py-8">Carrito vacío</p> <p className="text-[var(--text-tertiary)] text-center py-8">Carrito vacío</p>
) : ( ) : (
cart.map(item => ( cart.map(item => {
const product = products.find(p => p.id === item.id);
const maxQuantity = product?.stock || item.stock;
return (
<div key={item.id} className="flex items-center justify-between p-3 bg-[var(--bg-secondary)] rounded"> <div key={item.id} className="flex items-center justify-between p-3 bg-[var(--bg-secondary)] rounded">
<div className="flex-1"> <div className="flex-1">
<h4 className="text-sm font-medium">{item.name}</h4> <h4 className="text-sm font-medium">{item.name}</h4>
<p className="text-xs text-[var(--text-tertiary)]">{item.price.toFixed(2)} c/u</p> <p className="text-xs text-[var(--text-tertiary)]">{item.price.toFixed(2)} c/u</p>
<p className="text-xs text-[var(--text-tertiary)]">Stock: {maxQuantity}</p>
</div> </div>
<div className="flex items-center space-x-2"> <div className="flex items-center space-x-2">
<Button <Button
@@ -233,6 +396,7 @@ const POSPage: React.FC = () => {
<Button <Button
size="sm" size="sm"
variant="outline" variant="outline"
disabled={item.quantity >= maxQuantity}
onClick={(e) => { onClick={(e) => {
e.stopPropagation(); e.stopPropagation();
updateQuantity(item.id, item.quantity + 1); updateQuantity(item.id, item.quantity + 1);
@@ -245,7 +409,8 @@ const POSPage: React.FC = () => {
<p className="text-sm font-medium">{(item.price * item.quantity).toFixed(2)}</p> <p className="text-sm font-medium">{(item.price * item.quantity).toFixed(2)}</p>
</div> </div>
</div> </div>
)) );
})
)} )}
</div> </div>
@@ -305,7 +470,7 @@ const POSPage: React.FC = () => {
<div className="space-y-3"> <div className="space-y-3">
<div className="grid grid-cols-3 gap-2"> <div className="grid grid-cols-3 gap-2">
<Button <Button
variant={paymentMethod === 'cash' ? 'default' : 'outline'} variant={paymentMethod === 'cash' ? 'primary' : 'outline'}
onClick={() => setPaymentMethod('cash')} onClick={() => setPaymentMethod('cash')}
className="flex items-center justify-center" className="flex items-center justify-center"
> >
@@ -313,7 +478,7 @@ const POSPage: React.FC = () => {
Efectivo Efectivo
</Button> </Button>
<Button <Button
variant={paymentMethod === 'card' ? 'default' : 'outline'} variant={paymentMethod === 'card' ? 'primary' : 'outline'}
onClick={() => setPaymentMethod('card')} onClick={() => setPaymentMethod('card')}
className="flex items-center justify-center" className="flex items-center justify-center"
> >
@@ -321,7 +486,7 @@ const POSPage: React.FC = () => {
Tarjeta Tarjeta
</Button> </Button>
<Button <Button
variant={paymentMethod === 'transfer' ? 'default' : 'outline'} variant={paymentMethod === 'transfer' ? 'primary' : 'outline'}
onClick={() => setPaymentMethod('transfer')} onClick={() => setPaymentMethod('transfer')}
className="flex items-center justify-center" className="flex items-center justify-center"
> >

View File

@@ -40,7 +40,7 @@ interface BusinessHours {
const BakeryConfigPage: React.FC = () => { const BakeryConfigPage: React.FC = () => {
const { addToast } = useToast(); const { addToast } = useToast();
const currentTenant = useCurrentTenant(); const currentTenant = useCurrentTenant();
const { loadUserTenants } = useTenantActions(); const { loadUserTenants, setCurrentTenant } = useTenantActions();
const tenantId = currentTenant?.id || ''; const tenantId = currentTenant?.id || '';
// Use the current tenant from the store instead of making additional API calls // Use the current tenant from the store instead of making additional API calls
@@ -83,12 +83,10 @@ const BakeryConfigPage: React.FC = () => {
language: 'es' language: 'es'
}); });
// Load user tenants on component mount if no current tenant // Load user tenants on component mount to ensure fresh data
React.useEffect(() => { React.useEffect(() => {
if (!currentTenant) {
loadUserTenants(); loadUserTenants();
} }, [loadUserTenants]);
}, [currentTenant, loadUserTenants]);
// Update config when tenant data is loaded // Update config when tenant data is loaded
@@ -97,8 +95,8 @@ const BakeryConfigPage: React.FC = () => {
setConfig({ setConfig({
name: tenant.name || '', name: tenant.name || '',
description: tenant.description || '', description: tenant.description || '',
email: tenant.email || '', // Fixed: use email instead of contact_email email: tenant.email || '',
phone: tenant.phone || '', // Fixed: use phone instead of contact_phone phone: tenant.phone || '',
website: tenant.website || '', website: tenant.website || '',
address: tenant.address || '', address: tenant.address || '',
city: tenant.city || '', city: tenant.city || '',
@@ -109,6 +107,7 @@ const BakeryConfigPage: React.FC = () => {
timezone: 'Europe/Madrid', // Default value timezone: 'Europe/Madrid', // Default value
language: 'es' // Default value language: 'es' // Default value
}); });
setHasUnsavedChanges(false); // Reset unsaved changes when loading fresh data
} }
}, [tenant]); }, [tenant]);
@@ -253,26 +252,44 @@ const BakeryConfigPage: React.FC = () => {
setIsLoading(true); setIsLoading(true);
try { try {
await updateTenantMutation.mutateAsync({ const updateData = {
tenantId,
updateData: {
name: config.name, name: config.name,
description: config.description, description: config.description,
email: config.email, // Fixed: use email instead of contact_email email: config.email,
phone: config.phone, // Fixed: use phone instead of contact_phone phone: config.phone,
website: config.website, website: config.website,
address: config.address, address: config.address,
city: config.city, city: config.city,
postal_code: config.postalCode, postal_code: config.postalCode,
country: config.country country: config.country
// Note: tax_id, currency, timezone, language might not be supported by backend };
}
const updatedTenant = await updateTenantMutation.mutateAsync({
tenantId,
updateData
}); });
// Update the tenant store with the new data
if (updatedTenant) {
setCurrentTenant(updatedTenant);
// Force reload tenant list to ensure cache consistency
await loadUserTenants();
// Update localStorage to persist the changes
const tenantStorage = localStorage.getItem('tenant-storage');
if (tenantStorage) {
const parsedStorage = JSON.parse(tenantStorage);
if (parsedStorage.state && parsedStorage.state.currentTenant) {
parsedStorage.state.currentTenant = updatedTenant;
localStorage.setItem('tenant-storage', JSON.stringify(parsedStorage));
}
}
}
setHasUnsavedChanges(false); setHasUnsavedChanges(false);
addToast('Configuración actualizada correctamente', { type: 'success' }); addToast('Configuración actualizada correctamente', { type: 'success' });
} catch (error) { } catch (error) {
addToast('No se pudo actualizar la configuración', { type: 'error' }); addToast(`Error al actualizar: ${error instanceof Error ? error.message : 'Error desconocido'}`, { type: 'error' });
} finally { } finally {
setIsLoading(false); setIsLoading(false);
} }

View File

@@ -1,9 +1,9 @@
import React, { useState, useMemo } from 'react'; import React, { useState, useMemo } from 'react';
import { Users, Plus, Search, Mail, Phone, Shield, Trash2, Crown, X, UserCheck } from 'lucide-react'; import { Users, Plus, Search, Shield, Trash2, Crown, UserCheck } from 'lucide-react';
import { Button, Card, Badge, Input, StatusCard, getStatusColor, StatsGrid } from '../../../../components/ui'; import { Button, Card, Input, StatusCard, getStatusColor, StatsGrid } from '../../../../components/ui';
import AddTeamMemberModal from '../../../../components/domain/team/AddTeamMemberModal'; import AddTeamMemberModal from '../../../../components/domain/team/AddTeamMemberModal';
import { PageHeader } from '../../../../components/layout'; import { PageHeader } from '../../../../components/layout';
import { useTeamMembers, useAddTeamMember, useRemoveTeamMember, useUpdateMemberRole } from '../../../../api/hooks/tenant'; import { useTeamMembers, useAddTeamMember, useRemoveTeamMember, useUpdateMemberRole, useTenantAccess } from '../../../../api/hooks/tenant';
import { useAllUsers } from '../../../../api/hooks/user'; import { useAllUsers } from '../../../../api/hooks/user';
import { useAuthUser } from '../../../../stores/auth.store'; import { useAuthUser } from '../../../../stores/auth.store';
import { useCurrentTenant, useCurrentTenantAccess } from '../../../../stores/tenant.store'; import { useCurrentTenant, useCurrentTenantAccess } from '../../../../stores/tenant.store';
@@ -12,12 +12,23 @@ import { TENANT_ROLES } from '../../../../types/roles';
const TeamPage: React.FC = () => { const TeamPage: React.FC = () => {
const { addToast } = useToast(); const { addToast } = useToast();
const currentUser = useAuthUser();
const currentTenant = useCurrentTenant(); const currentTenant = useCurrentTenant();
const currentTenantAccess = useCurrentTenantAccess(); const currentTenantAccess = useCurrentTenantAccess();
const tenantId = currentTenant?.id || ''; const tenantId = currentTenant?.id || '';
// Try to get tenant access directly via hook as fallback
const { data: directTenantAccess } = useTenantAccess(
tenantId,
currentUser?.id || '',
{ enabled: !!tenantId && !!currentUser?.id && !currentTenantAccess }
);
const { data: teamMembers = [], isLoading } = useTeamMembers(tenantId, false, { enabled: !!tenantId }); // Show all members including inactive const { data: teamMembers = [], isLoading } = useTeamMembers(tenantId, false, { enabled: !!tenantId }); // Show all members including inactive
const { data: allUsers = [] } = useAllUsers(); const { data: allUsers = [], error: allUsersError, isLoading: allUsersLoading } = useAllUsers({
retry: false, // Don't retry on permission errors
staleTime: 0 // Always fresh check for permissions
});
// Mutations // Mutations
const addMemberMutation = useAddTeamMember(); const addMemberMutation = useAddTeamMember();
@@ -72,9 +83,15 @@ const TeamPage: React.FC = () => {
{ value: TENANT_ROLES.VIEWER, label: 'Observador', count: enhancedTeamMembers.filter(m => m.role === TENANT_ROLES.VIEWER).length } { value: TENANT_ROLES.VIEWER, label: 'Observador', count: enhancedTeamMembers.filter(m => m.role === TENANT_ROLES.VIEWER).length }
]; ];
// Use direct tenant access as fallback
const effectiveTenantAccess = currentTenantAccess || directTenantAccess;
// Check if current user is the tenant owner (fallback when access endpoint fails)
const isCurrentUserOwner = currentUser?.id === currentTenant?.owner_id;
// Permission checks // Permission checks
const isOwner = currentTenantAccess?.role === TENANT_ROLES.OWNER; const isOwner = effectiveTenantAccess?.role === TENANT_ROLES.OWNER || isCurrentUserOwner;
const canManageTeam = isOwner || currentTenantAccess?.role === TENANT_ROLES.ADMIN; const canManageTeam = isOwner || effectiveTenantAccess?.role === TENANT_ROLES.ADMIN;
const teamStats = { const teamStats = {
total: enhancedTeamMembers.length, total: enhancedTeamMembers.length,
@@ -201,25 +218,34 @@ const TeamPage: React.FC = () => {
!enhancedTeamMembers.some(m => m.user_id === u.id) !enhancedTeamMembers.some(m => m.user_id === u.id)
); );
// Member action handlers // Force reload tenant access if missing
const handleAddMember = async () => { React.useEffect(() => {
if (!selectedUserToAdd || !selectedRoleToAdd || !tenantId) return; if (currentTenant?.id && !currentTenantAccess) {
console.log('Forcing tenant access reload for tenant:', currentTenant.id);
// You can trigger a manual reload here if needed
}
}, [currentTenant?.id, currentTenantAccess]);
try { // Debug logging
await addMemberMutation.mutateAsync({ console.log('TeamPage Debug:', {
tenantId, canManageTeam,
userId: selectedUserToAdd, isOwner,
role: selectedRoleToAdd, isCurrentUserOwner,
currentUser: currentUser?.id,
currentTenant: currentTenant?.id,
tenantOwner: currentTenant?.owner_id,
currentTenantAccess,
directTenantAccess,
effectiveTenantAccess,
tenantAccess: effectiveTenantAccess?.role,
allUsers: allUsers.length,
allUsersError,
allUsersLoading,
availableUsers: availableUsers.length,
enhancedTeamMembers: enhancedTeamMembers.length
}); });
addToast('Miembro agregado exitosamente', { type: 'success' }); // Member action handlers - removed unused handleAddMember since modal handles it directly
setShowAddForm(false);
setSelectedUserToAdd('');
setSelectedRoleToAdd(TENANT_ROLES.MEMBER);
} catch (error) {
addToast('Error al agregar miembro', { type: 'error' });
}
};
const handleRemoveMember = async (memberUserId: string) => { const handleRemoveMember = async (memberUserId: string) => {
if (!tenantId) return; if (!tenantId) return;
@@ -276,7 +302,7 @@ const TeamPage: React.FC = () => {
title="Gestión de Equipo" title="Gestión de Equipo"
description="Administra los miembros del equipo, roles y permisos" description="Administra los miembros del equipo, roles y permisos"
actions={ actions={
canManageTeam && availableUsers.length > 0 ? [{ canManageTeam ? [{
id: 'add-member', id: 'add-member',
label: 'Agregar Miembro', label: 'Agregar Miembro',
icon: Plus, icon: Plus,
@@ -351,7 +377,7 @@ const TeamPage: React.FC = () => {
</Card> </Card>
{/* Add Member Button */} {/* Add Member Button */}
{canManageTeam && availableUsers.length > 0 && filteredMembers.length > 0 && ( {canManageTeam && filteredMembers.length > 0 && (
<div className="flex justify-end"> <div className="flex justify-end">
<Button <Button
onClick={() => setShowAddForm(true)} onClick={() => setShowAddForm(true)}
@@ -406,7 +432,7 @@ const TeamPage: React.FC = () => {
: "Este tenant aún no tiene miembros del equipo" : "Este tenant aún no tiene miembros del equipo"
} }
</p> </p>
{canManageTeam && availableUsers.length > 0 && ( {canManageTeam && (
<Button <Button
onClick={() => setShowAddForm(true)} onClick={() => setShowAddForm(true)}
variant="primary" variant="primary"

View File

@@ -222,6 +222,7 @@ export const useTenantStore = create<TenantState>()(
); );
// Selectors for common use cases // Selectors for common use cases
// Note: For getting tenant ID, prefer using useTenantId() from hooks/useTenantId.ts
export const useCurrentTenant = () => useTenantStore((state) => state.currentTenant); export const useCurrentTenant = () => useTenantStore((state) => state.currentTenant);
export const useAvailableTenants = () => useTenantStore((state) => state.availableTenants); export const useAvailableTenants = () => useTenantStore((state) => state.availableTenants);
export const useCurrentTenantAccess = () => useTenantStore((state) => state.currentTenantAccess); export const useCurrentTenantAccess = () => useTenantStore((state) => state.currentTenantAccess);

View File

@@ -251,9 +251,11 @@ class EnhancedTenantService:
detail="Insufficient permissions to update tenant" detail="Insufficient permissions to update tenant"
) )
# Update tenant using repository # Update tenant using repository with proper session management
update_values = update_data.dict(exclude_unset=True) update_values = update_data.dict(exclude_unset=True)
if update_values: if update_values:
async with self.database_manager.get_session() as db_session:
await self._init_repositories(db_session)
updated_tenant = await self.tenant_repo.update(tenant_id, update_values) updated_tenant = await self.tenant_repo.update(tenant_id, update_values)
if not updated_tenant: if not updated_tenant:
@@ -269,7 +271,9 @@ class EnhancedTenantService:
return TenantResponse.from_orm(updated_tenant) return TenantResponse.from_orm(updated_tenant)
# No updates to apply # No updates to apply - get current tenant data
async with self.database_manager.get_session() as db_session:
await self._init_repositories(db_session)
tenant = await self.tenant_repo.get_by_id(tenant_id) tenant = await self.tenant_repo.get_by_id(tenant_id)
return TenantResponse.from_orm(tenant) return TenantResponse.from_orm(tenant)