Add new frontend - fix 2

This commit is contained in:
Urtzi Alfaro
2025-07-22 08:50:18 +02:00
parent c8517c41a5
commit d29a94e8ab
35 changed files with 1476 additions and 8301 deletions

View File

@@ -1,28 +1,39 @@
// src/pages/Dashboard/Dashboard.tsx
// src/pages/dashboard/index.tsx
import React, { useState, useEffect, useCallback } from 'react';
import { useAuth } from '../../api/hooks/useAuth';
import { useTrainingProgress } from '../../api/hooks/useTrainingProgress';
import { trainingApi, forecastingApi, dataApi } from '../../api';
import { TrainingProgressCard } from '../../components/training/TrainingProgressCard';
import { ForecastChart } from '../../components/charts/ForecastChart';
import { SalesUploader } from '../../components/data/SalesUploader';
import { NotificationToast } from '../../components/common/NotificationToast';
import { ErrorBoundary } from '../../components/common/ErrorBoundary';
import { StatsCard } from '../../components/common/StatsCard';
import { useWebSocket } from '../../api/hooks/useWebSocket';
import Head from 'next/head';
import {
ChartBarIcon,
CloudArrowUpIcon,
CpuChipIcon,
BellIcon,
ArrowPathIcon
ArrowPathIcon,
ScaleIcon, // For accuracy
CalendarDaysIcon, // For last training date
} from '@heroicons/react/24/outline';
import { useAuth } from '../../contexts/AuthContext';
import { useTrainingProgress } from '../../api/hooks/useTrainingProgress'; // Path corrected
import { TrainingProgressCard } from '../../components/training/TrainingProgressCard';
import { ForecastChart } from '../../components/charts/ForecastChart';
import { SalesUploader } from '../../components/data/SalesUploader';
import { NotificationToast } from '../../components/common/NotificationToast';
import { ErrorBoundary } from '../../components/common/ErrorBoundary';
import {
dataApi,
forecastingApi,
trainingApi, // Assuming a trainingApi service exists, potentially part of dataApi
ApiResponse,
ForecastRecord,
SalesRecord,
TrainingTask,
TrainingRequest,
} from '../../api/services/api'; // Consolidated API services and types
// Dashboard specific types
interface DashboardStats {
totalSales: number;
totalRevenue: number;
lastTrainingDate: string | null;
forecastAccuracy: number;
forecastAccuracy: number; // e.g., MAPE or RMSE
}
interface Notification {
@@ -33,303 +44,29 @@ interface Notification {
timestamp: Date;
}
export const Dashboard: React.FC = () => {
const { user } = useAuth();
const [activeJobId, setActiveJobId] = useState<string | null>(null);
const [stats, setStats] = useState<DashboardStats | null>(null);
const [forecasts, setForecasts] = useState<any[]>([]);
const [notifications, setNotifications] = useState<Notification[]>([]);
const [isTraining, setIsTraining] = useState(false);
const [isLoading, setIsLoading] = useState(true);
// WebSocket for real-time notifications
const { send: sendNotification } = useWebSocket({
endpoint: '/notifications',
onMessage: (data) => {
if (data.type === 'notification') {
addNotification({
id: Date.now().toString(),
type: data.level || 'info',
title: data.title,
message: data.message,
timestamp: new Date()
});
}
}
});
// Training progress hook
const { progress, error: trainingError, isComplete } = useTrainingProgress(activeJobId);
// Load initial data
useEffect(() => {
loadDashboardData();
}, []);
// Handle training completion
useEffect(() => {
if (isComplete && activeJobId) {
handleTrainingComplete();
}
}, [isComplete, activeJobId]);
const loadDashboardData = async () => {
setIsLoading(true);
try {
// Load stats
const [salesData, tenantStats, latestForecasts] = await Promise.all([
dataService.getSalesAnalytics(),
dataService.getTenantStats(),
forecastingService.getLatestForecasts()
]);
setStats({
totalSales: salesData.total_quantity,
totalRevenue: salesData.total_revenue,
lastTrainingDate: tenantStats.last_training_date,
forecastAccuracy: tenantStats.forecast_accuracy || 0
});
setForecasts(latestForecasts);
} catch (error) {
console.error('Failed to load dashboard data:', error);
addNotification({
id: Date.now().toString(),
type: 'error',
title: 'Error loading data',
message: 'Failed to load dashboard data. Please refresh the page.',
timestamp: new Date()
});
} finally {
setIsLoading(false);
}
};
const startTraining = async () => {
try {
setIsTraining(true);
const job = await trainingService.startTraining({
config: {
include_weather: true,
include_traffic: true,
forecast_days: 7
}
});
setActiveJobId(job.job_id);
addNotification({
id: Date.now().toString(),
type: 'info',
title: 'Training Started',
message: 'Model training has begun. This may take a few minutes.',
timestamp: new Date()
});
} catch (error) {
console.error('Failed to start training:', error);
addNotification({
id: Date.now().toString(),
type: 'error',
title: 'Training Failed',
message: 'Failed to start training. Please try again.',
timestamp: new Date()
});
setIsTraining(false);
}
};
const handleTrainingComplete = async () => {
setIsTraining(false);
setActiveJobId(null);
addNotification({
id: Date.now().toString(),
type: 'success',
title: 'Training Complete',
message: 'Model training completed successfully!',
timestamp: new Date()
});
// Reload data to show new results
await loadDashboardData();
};
const handleSalesUpload = async (file: File) => {
try {
await dataService.uploadSalesData(file);
addNotification({
id: Date.now().toString(),
type: 'success',
title: 'Upload Successful',
message: 'Sales data uploaded successfully.',
timestamp: new Date()
});
// Reload data
await loadDashboardData();
} catch (error) {
console.error('Failed to upload sales data:', error);
addNotification({
id: Date.now().toString(),
type: 'error',
title: 'Upload Failed',
message: 'Failed to upload sales data. Please check the file format.',
timestamp: new Date()
});
}
};
const addNotification = (notification: Notification) => {
setNotifications(prev => [notification, ...prev].slice(0, 10));
// Auto-remove after 5 seconds
setTimeout(() => {
removeNotification(notification.id);
}, 5000);
};
const removeNotification = (id: string) => {
setNotifications(prev => prev.filter(n => n.id !== id));
};
if (isLoading) {
return (
<div className="min-h-screen flex items-center justify-center">
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-indigo-600"></div>
</div>
);
}
return (
<ErrorBoundary>
<div className="min-h-screen bg-gray-50">
{/* Header */}
<header className="bg-white shadow">
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-4">
<div className="flex items-center justify-between">
<h1 className="text-2xl font-bold text-gray-900">
Bakery Forecast Dashboard
</h1>
<div className="flex items-center space-x-4">
<button
onClick={loadDashboardData}
className="p-2 text-gray-400 hover:text-gray-500"
title="Refresh data"
>
<ArrowPathIcon className="h-5 w-5" />
</button>
<div className="relative">
<BellIcon className="h-6 w-6 text-gray-400" />
{notifications.length > 0 && (
<span className="absolute -top-1 -right-1 h-3 w-3 bg-red-500 rounded-full"></span>
)}
</div>
<div className="flex items-center">
<span className="text-sm text-gray-700">{user?.full_name}</span>
</div>
</div>
</div>
</div>
</header>
{/* Main Content */}
<main className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
{/* Stats Grid */}
<div className="grid grid-cols-1 md:grid-cols-4 gap-6 mb-8">
<StatsCard
title="Total Sales"
value={stats?.totalSales || 0}
icon={ChartBarIcon}
format="number"
/>
<StatsCard
title="Total Revenue"
value={stats?.totalRevenue || 0}
icon={ChartBarIcon}
format="currency"
/>
<StatsCard
title="Last Training"
value={stats?.lastTrainingDate || 'Never'}
icon={CpuChipIcon}
format="date"
/>
<StatsCard
title="Forecast Accuracy"
value={stats?.forecastAccuracy || 0}
icon={ChartBarIcon}
format="percentage"
/>
</div>
{/* Actions Row */}
<div className="flex flex-wrap gap-4 mb-8">
<SalesUploader onUpload={handleSalesUpload} />
<button
onClick={startTraining}
disabled={isTraining}
className="inline-flex items-center px-4 py-2 border border-transparent text-sm font-medium rounded-md shadow-sm text-white bg-indigo-600 hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500 disabled:opacity-50 disabled:cursor-not-allowed"
>
<CpuChipIcon className="h-5 w-5 mr-2" />
{isTraining ? 'Training...' : 'Start Training'}
</button>
</div>
{/* Training Progress */}
{activeJobId && (
<div className="mb-8">
<TrainingProgressCard
jobId={activeJobId}
onComplete={handleTrainingComplete}
/>
</div>
)}
{/* Forecast Charts */}
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
{forecasts.map((forecast, index) => (
<ForecastChart
key={index}
title={forecast.product_name}
data={forecast.data}
className="bg-white rounded-lg shadow p-6"
/>
))}
</div>
</main>
{/* Notifications */}
<div className="fixed bottom-4 right-4 space-y-2">
{notifications.map(notification => (
<NotificationToast
key={notification.id}
{...notification}
onClose={() => removeNotification(notification.id)}
/>
))}
</div>
</div>
</ErrorBoundary>
);
};
// StatsCard Component
// StatsCard Component (moved here for completeness, or keep in common if reused)
interface StatsCardProps {
title: string;
value: any;
icon: React.ElementType;
format: 'number' | 'currency' | 'percentage' | 'date';
format: 'number' | 'currency' | 'percentage' | 'date' | 'string'; // Added 'string' for flexibility
loading?: boolean;
}
const StatsCard: React.FC<StatsCardProps> = ({ title, value, icon: Icon, format }) => {
const StatsCard: React.FC<StatsCardProps> = ({ title, value, icon: Icon, format, loading }) => {
const formatValue = () => {
if (loading) return (
<div className="h-6 bg-gray-200 rounded w-3/4 animate-pulse"></div>
);
if (value === null || value === undefined) return 'N/A';
switch (format) {
case 'number':
return value.toLocaleString();
return value.toLocaleString('es-ES');
case 'currency':
return new Intl.NumberFormat('es-ES', {
style: 'currency',
currency: 'EUR'
currency: 'EUR',
}).format(value);
case 'percentage':
return `${(value * 100).toFixed(1)}%`;
@@ -341,16 +78,305 @@ const StatsCard: React.FC<StatsCardProps> = ({ title, value, icon: Icon, format
};
return (
<div className="bg-white rounded-lg shadow p-6">
<div className="flex items-center">
<div className="flex-shrink-0">
<Icon className="h-6 w-6 text-gray-400" />
</div>
<div className="ml-4">
<dt className="text-sm font-medium text-gray-500">{title}</dt>
<dd className="text-2xl font-semibold text-gray-900">{formatValue()}</dd>
</div>
<div className="bg-white rounded-lg shadow p-6 flex items-center">
<div className="flex-shrink-0">
<Icon className="h-8 w-8 text-pania-blue" /> {/* Changed icon color */}
</div>
<div className="ml-5">
<dt className="text-sm font-medium text-gray-500">{title}</dt>
<dd className="mt-1 text-3xl font-semibold text-gray-900">{formatValue()}</dd>
</div>
</div>
);
};
};
const DashboardPage: React.FC = () => {
const { user, isAuthenticated, isLoading: authLoading } = useAuth();
const [activeJobId, setActiveJobId] = useState<string | null>(null);
const [stats, setStats] = useState<DashboardStats | null>(null);
const [forecasts, setForecasts] = useState<ForecastRecord[]>([]);
const [notifications, setNotifications] = useState<Notification[]>([]);
const [chartProductName, setChartProductName] = useState<string>(''); // Currently selected product for chart
const [loadingData, setLoadingData] = useState(true);
// Hook for training progress (if an active job ID is present)
const {
progress: trainingProgress,
error: trainingError,
isComplete: isTrainingComplete,
isConnected: isTrainingWebSocketConnected,
} = useTrainingProgress(activeJobId);
// Effect to handle training completion
useEffect(() => {
if (isTrainingComplete && activeJobId) {
addNotification('success', 'Entrenamiento Completado', `El modelo para el trabajo ${activeJobId} ha terminado de entrenar.`);
setActiveJobId(null); // Clear active job
fetchDashboardData(); // Refresh dashboard data after training
}
if (trainingError && activeJobId) {
addNotification('error', 'Error de Entrenamiento', `El entrenamiento para el trabajo ${activeJobId} falló: ${trainingError}`);
setActiveJobId(null);
}
}, [isTrainingComplete, trainingError, activeJobId]); // Dependencies
// Notification handling
const addNotification = useCallback((type: Notification['type'], title: string, message: string) => {
const newNotification: Notification = {
id: Date.now().toString(),
type,
title,
message,
timestamp: new Date(),
};
setNotifications((prev) => [...prev, newNotification]);
}, []);
const removeNotification = useCallback((id: string) => {
setNotifications((prev) => prev.filter((n) => n.id !== id));
}, []);
// Fetch initial dashboard data
const fetchDashboardData = useCallback(async () => {
setLoadingData(true);
try {
// Fetch Dashboard Stats
const statsResponse: ApiResponse<DashboardStats> = await dataApi.getDashboardStats();
if (statsResponse.data) {
setStats(statsResponse.data);
} else if (statsResponse.message) {
addNotification('warning', 'Dashboard Stats', statsResponse.message);
}
// Fetch initial forecasts (e.g., for a default product or the first available product)
const forecastResponse: ApiResponse<ForecastRecord[]> = await forecastingApi.getForecast({
forecast_days: 7, // Example: 7 days forecast
product_name: user?.tenant_id ? 'pan' : undefined, // Default to 'pan' or first product
});
if (forecastResponse.data && forecastResponse.data.length > 0) {
setForecasts(forecastResponse.data);
setChartProductName(forecastResponse.data[0].product_name); // Set the product name for the chart
} else if (forecastResponse.message) {
addNotification('info', 'Previsiones', forecastResponse.message);
}
} catch (error: any) {
console.error('Failed to fetch dashboard data:', error);
addNotification('error', 'Error de Carga', error.message || 'No se pudieron cargar los datos del dashboard.');
} finally {
setLoadingData(false);
}
}, [user, addNotification]);
useEffect(() => {
if (isAuthenticated) {
fetchDashboardData();
}
}, [isAuthenticated, fetchDashboardData]);
const handleSalesUpload = async (file: File) => {
try {
addNotification('info', 'Subiendo archivo', 'Cargando historial de ventas...');
const response = await dataApi.uploadSalesHistory(file);
addNotification('success', 'Subida Completa', 'Historial de ventas cargado exitosamente.');
// After upload, trigger a new training (assuming this is the flow)
const trainingRequest: TrainingRequest = {
force_retrain: true,
// You might want to specify products if the uploader supports it,
// or let the backend determine based on the uploaded data.
};
const trainingTask: TrainingTask = await trainingApi.startTraining(trainingRequest);
setActiveJobId(trainingTask.job_id);
addNotification('info', 'Entrenamiento iniciado', `Un nuevo entrenamiento ha comenzado (ID: ${trainingTask.job_id}).`);
// No need to fetch dashboard data here, as useEffect for isTrainingComplete will handle it
} catch (error: any) {
console.error('Error uploading sales or starting training:', error);
addNotification('error', 'Error al subir', error.message || 'No se pudo subir el archivo o iniciar el entrenamiento.');
}
};
const handleForecastProductChange = async (productName: string) => {
setLoadingData(true);
try {
const forecastResponse: ApiResponse<ForecastRecord[]> = await forecastingApi.getForecast({
forecast_days: 7,
product_name: productName,
});
if (forecastResponse.data) {
setForecasts(forecastResponse.data);
setChartProductName(productName);
}
} catch (error: any) {
addNotification('error', 'Error de Previsión', error.message || `No se pudieron cargar las previsiones para ${productName}.`);
} finally {
setLoadingData(false);
}
};
if (authLoading) {
return (
<div className="min-h-screen flex items-center justify-center bg-pania-white">
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-pania-blue"></div>
</div>
);
}
if (!isAuthenticated) {
// If not authenticated, ProtectedRoute should handle redirect, but a fallback is good
return null;
}
return (
<ErrorBoundary>
<div className="min-h-screen bg-gray-100">
<Head>
<title>Dashboard - PanIA</title>
<meta name="description" content="Dashboard de predicción de demanda para Panaderías" />
</Head>
{/* Top Notification Area */}
<div className="fixed top-4 right-4 z-50 space-y-2">
{notifications.map(notification => (
<NotificationToast
key={notification.id}
{...notification}
onClose={() => removeNotification(notification.id)}
/>
))}
</div>
{/* Header/Navbar (You might want a dedicated Layout component for this) */}
<header className="bg-white shadow-sm py-4">
<nav className="container mx-auto flex justify-between items-center px-4">
<div className="text-3xl font-extrabold text-pania-charcoal">PanIA Dashboard</div>
<div className="flex items-center space-x-4">
<span className="text-gray-700">Bienvenido, {user?.full_name || user?.email}!</span>
<button
onClick={() => {
useAuth().logout(); // Call logout from AuthContext
}}
className="text-pania-blue hover:text-pania-blue-dark font-medium px-4 py-2 rounded-md border border-pania-blue"
>
Cerrar Sesión
</button>
</div>
</nav>
</header>
<main className="container mx-auto px-4 py-8">
{/* Dashboard Overview Section */}
<section className="mb-8">
<h2 className="text-2xl font-bold text-gray-900 mb-6">Resumen del Negocio</h2>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6">
<StatsCard
title="Ventas Totales"
value={stats?.totalSales}
icon={ChartBarIcon}
format="number"
loading={loadingData}
/>
<StatsCard
title="Ingresos Totales"
value={stats?.totalRevenue}
icon={CurrencyEuroIcon} {/* Assuming CurrencyEuroIcon from heroicons */}
format="currency"
loading={loadingData}
/>
<StatsCard
title="Último Entrenamiento"
value={stats?.lastTrainingDate || 'Nunca'}
icon={CalendarDaysIcon}
format="date"
loading={loadingData}
/>
<StatsCard
title="Precisión (MAPE)"
value={stats?.forecastAccuracy}
icon={ScaleIcon}
format="percentage"
loading={loadingData}
/>
</div>
</section>
{/* Training Section */}
<section className="mb-8">
<h2 className="text-2xl font-bold text-gray-900 mb-6">Entrenamiento del Modelo</h2>
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
<div className="bg-white rounded-lg shadow p-6">
<h3 className="text-lg font-semibold text-gray-800 mb-4">Subir Nuevos Datos de Ventas</h3>
<p className="text-gray-600 mb-4">
Carga tu último historial de ventas para mantener tus predicciones actualizadas.
</p>
<SalesUploader onUpload={handleSalesUpload} />
</div>
<div className="bg-white rounded-lg shadow p-6">
<h3 className="text-lg font-semibold text-gray-800 mb-4">Estado del Entrenamiento</h3>
{activeJobId ? (
<TrainingProgressCard jobId={activeJobId} />
) : (
<div className="flex flex-col items-center justify-center p-8 text-gray-500">
<CpuChipIcon className="h-16 w-16 mb-4" />
<p className="text-lg text-center">No hay un entrenamiento activo en este momento.</p>
<p className="text-sm text-center mt-2">Sube un nuevo archivo de ventas para iniciar un entrenamiento.</p>
</div>
)}
</div>
</div>
</section>
{/* Forecast Chart Section */}
<section>
<h2 className="text-2xl font-bold text-gray-900 mb-6">Previsiones de Demanda</h2>
<div className="bg-white rounded-lg shadow p-6">
<div className="mb-4 flex items-center justify-between">
<h3 className="text-lg font-semibold text-gray-800">Previsión para {chartProductName || 'Productos'}</h3>
{/* Product Selector for Forecast Chart (assuming ProductSelector can be used for single selection) */}
<select
value={chartProductName}
onChange={(e) => handleForecastProductChange(e.target.value)}
className="mt-1 block w-48 pl-3 pr-10 py-2 text-base border-gray-300 focus:outline-none focus:ring-pania-blue focus:border-pania-blue sm:text-sm rounded-md"
>
{/* You'll need to fetch the list of products associated with the user/tenant */}
{/* For now, using defaultProducts as an example */}
{defaultProducts.map((product) => (
<option key={product.id} value={product.displayName}>
{product.displayName}
</option>
))}
</select>
</div>
{loadingData ? (
<div className="flex justify-center items-center h-64">
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-pania-blue"></div>
</div>
) : forecasts.length > 0 ? (
<ForecastChart data={forecasts} productName={chartProductName} />
) : (
<div className="text-center py-10 text-gray-500">
<ChartBarIcon className="mx-auto h-16 w-16 text-gray-400" />
<p className="mt-4 text-lg">No hay datos de previsión disponibles.</p>
<p className="text-sm">Sube tu historial de ventas o selecciona otro producto.</p>
</div>
)}
</div>
</section>
</main>
{/* Footer */}
<footer className="bg-gray-800 text-gray-300 py-6 text-center mt-8">
<div className="container mx-auto px-4">
<p>&copy; {new Date().getFullYear()} PanIA. Todos los derechos reservados.</p>
</div>
</footer>
</div>
</ErrorBoundary>
);
};
export default DashboardPage;

File diff suppressed because it is too large Load Diff