Add new frontend - fix 2
This commit is contained in:
@@ -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>© {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
Reference in New Issue
Block a user