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

File diff suppressed because it is too large Load Diff

View File

@@ -22,7 +22,8 @@
"zustand": "^4.4.6", "zustand": "^4.4.6",
"@headlessui/react": "^2.0.0", "@headlessui/react": "^2.0.0",
"@heroicons/react": "^2.0.18", "@heroicons/react": "^2.0.18",
"framer-motion": "^10.16.4" "framer-motion": "^10.16.4",
"jwt-decode": "^4.0.0"
}, },
"devDependencies": { "devDependencies": {
"@types/node": "^20.8.0", "@types/node": "^20.8.0",

View File

@@ -322,128 +322,3 @@ class ApiClient {
export const apiClient = new ApiClient({ export const apiClient = new ApiClient({
baseURL: process.env.REACT_APP_API_URL || 'http://localhost:8000/api' baseURL: process.env.REACT_APP_API_URL || 'http://localhost:8000/api'
}); });
// src/api/base/circuitBreaker.ts
export class CircuitBreaker {
private failures: number = 0;
private lastFailureTime: number = 0;
private state: 'CLOSED' | 'OPEN' | 'HALF_OPEN' = 'CLOSED';
constructor(
private threshold: number = 5,
private timeout: number = 60000 // 1 minute
) {}
async execute<T>(fn: () => Promise<T>): Promise<T> {
if (this.state === 'OPEN') {
if (Date.now() - this.lastFailureTime > this.timeout) {
this.state = 'HALF_OPEN';
} else {
throw new Error('Circuit breaker is OPEN');
}
}
try {
const result = await fn();
this.onSuccess();
return result;
} catch (error) {
this.onFailure();
throw error;
}
}
private onSuccess(): void {
this.failures = 0;
this.state = 'CLOSED';
}
private onFailure(): void {
this.failures++;
this.lastFailureTime = Date.now();
if (this.failures >= this.threshold) {
this.state = 'OPEN';
}
}
getState(): string {
return this.state;
}
}
// src/api/services/index.ts
import { apiClient } from '../base/apiClient';
import { AuthService } from './authService';
import { TrainingService } from './trainingService';
import { ForecastingService } from './forecastingService';
import { DataService } from './dataService';
import { TenantService } from './tenantService';
// Service instances with circuit breakers
export const authService = new AuthService(apiClient);
export const trainingService = new TrainingService(apiClient);
export const forecastingService = new ForecastingService(apiClient);
export const dataService = new DataService(apiClient);
export const tenantService = new TenantService(apiClient);
// Export types
export * from '../types';
// src/components/common/ErrorBoundary.tsx
import React, { Component, ErrorInfo, ReactNode } from 'react';
interface Props {
children: ReactNode;
fallback?: ReactNode;
}
interface State {
hasError: boolean;
error: Error | null;
}
export class ErrorBoundary extends Component<Props, State> {
state: State = {
hasError: false,
error: null
};
static getDerivedStateFromError(error: Error): State {
return { hasError: true, error };
}
componentDidCatch(error: Error, errorInfo: ErrorInfo) {
console.error('ErrorBoundary caught:', error, errorInfo);
// Send error to monitoring service
if (process.env.NODE_ENV === 'production') {
// logErrorToService(error, errorInfo);
}
}
render() {
if (this.state.hasError) {
return this.props.fallback || (
<div className="min-h-screen flex items-center justify-center">
<div className="text-center">
<h1 className="text-2xl font-bold text-gray-900 mb-4">
Algo salió mal
</h1>
<p className="text-gray-600 mb-6">
Ha ocurrido un error inesperado. Por favor, recarga la página.
</p>
<button
onClick={() => window.location.reload()}
className="px-4 py-2 bg-indigo-600 text-white rounded-md hover:bg-indigo-700"
>
Recargar página
</button>
</div>
</div>
);
}
return this.props.children;
}
}

View File

@@ -0,0 +1,48 @@
// src/api/base/circuitBreaker.ts
export class CircuitBreaker {
private failures: number = 0;
private lastFailureTime: number = 0;
private state: 'CLOSED' | 'OPEN' | 'HALF_OPEN' = 'CLOSED';
constructor(
private threshold: number = 5,
private timeout: number = 60000 // 1 minute
) {}
async execute<T>(fn: () => Promise<T>): Promise<T> {
if (this.state === 'OPEN') {
if (Date.now() - this.lastFailureTime > this.timeout) {
this.state = 'HALF_OPEN';
} else {
throw new Error('Circuit breaker is OPEN');
}
}
try {
const result = await fn();
this.onSuccess();
return result;
} catch (error) {
this.onFailure();
throw error;
}
}
private onSuccess(): void {
this.failures = 0;
this.state = 'CLOSED';
}
private onFailure(): void {
this.failures++;
this.lastFailureTime = Date.now();
if (this.failures >= this.threshold) {
this.state = 'OPEN';
}
}
getState(): string {
return this.state;
}
}

View File

@@ -1,76 +0,0 @@
// frontend/dashboard/src/api/cache.ts
/**
* Simple in-memory cache for API responses
*/
interface CacheEntry<T> {
data: T;
timestamp: number;
ttl: number;
}
class ApiCache {
private cache = new Map<string, CacheEntry<any>>();
set<T>(key: string, data: T, ttl: number = 300000): void { // 5 minutes default
this.cache.set(key, {
data,
timestamp: Date.now(),
ttl,
});
}
get<T>(key: string): T | null {
const entry = this.cache.get(key);
if (!entry) {
return null;
}
if (Date.now() - entry.timestamp > entry.ttl) {
this.cache.delete(key);
return null;
}
return entry.data;
}
delete(key: string): void {
this.cache.delete(key);
}
clear(): void {
this.cache.clear();
}
has(key: string): boolean {
const entry = this.cache.get(key);
if (!entry) return false;
if (Date.now() - entry.timestamp > entry.ttl) {
this.cache.delete(key);
return false;
}
return true;
}
}
export const apiCache = new ApiCache();
// Cache helper for API client
export function withCache<T>(
key: string,
fetcher: () => Promise<T>,
ttl?: number
): Promise<T> {
const cached = apiCache.get<T>(key);
if (cached) {
return Promise.resolve(cached);
}
return fetcher().then(data => {
apiCache.set(key, data, ttl);
return data;
});
}

View File

@@ -1,70 +0,0 @@
// frontend/dashboard/src/api/config.ts
/**
* API configuration and constants
*/
export const API_CONFIG = {
BASE_URL: process.env.REACT_APP_API_URL || 'http://localhost:8000',
TIMEOUT: 10000,
RETRY_ATTEMPTS: 3,
RETRY_DELAY: 1000,
} as const;
export const ENDPOINTS = {
AUTH: {
LOGIN: '/auth/login',
REGISTER: '/auth/register',
LOGOUT: '/auth/logout',
REFRESH: '/auth/refresh',
PROFILE: '/auth/me',
CHANGE_PASSWORD: '/auth/change-password',
PASSWORD_RESET: '/auth/password-reset',
},
TRAINING: {
TRAIN: '/training/train',
STATUS: '/training/status',
JOBS: '/training/jobs',
MODELS: '/training/models',
PROGRESS_WS: '/training/progress',
},
FORECASTING: {
FORECASTS: '/forecasting/forecasts',
GENERATE: '/forecasting/generate',
PERFORMANCE: '/forecasting/performance',
},
DATA: {
SALES: '/data/sales',
SALES_UPLOAD: '/data/sales/upload',
SALES_ANALYTICS: '/data/sales/analytics',
WEATHER: '/data/weather',
TRAFFIC: '/data/traffic',
SYNC: '/data/sync',
QUALITY: '/data/quality',
},
TENANTS: {
CURRENT: '/tenants/current',
NOTIFICATIONS: '/tenants/notifications',
STATS: '/tenants/stats',
},
} as const;
export const HTTP_STATUS = {
OK: 200,
CREATED: 201,
NO_CONTENT: 204,
BAD_REQUEST: 400,
UNAUTHORIZED: 401,
FORBIDDEN: 403,
NOT_FOUND: 404,
CONFLICT: 409,
INTERNAL_SERVER_ERROR: 500,
SERVICE_UNAVAILABLE: 503,
} as const;
export const STORAGE_KEYS = {
ACCESS_TOKEN: 'access_token',
REFRESH_TOKEN: 'refresh_token',
USER_PROFILE: 'user_profile',
THEME: 'theme',
LANGUAGE: 'language',
} as const;

View File

@@ -1,92 +0,0 @@
// frontend/dashboard/src/api/hooks/useApi.ts
/**
* React hooks for API state management
*/
import { useState, useEffect, useCallback } from 'react';
import { ApiError } from '../../types/api';
export interface ApiState<T> {
data: T | null;
loading: boolean;
error: ApiError | null;
}
export function useApi<T>(
apiCall: () => Promise<T>,
dependencies: any[] = []
): ApiState<T> & {
refetch: () => Promise<void>;
reset: () => void;
} {
const [state, setState] = useState<ApiState<T>>({
data: null,
loading: false,
error: null,
});
const execute = useCallback(async () => {
setState(prev => ({ ...prev, loading: true, error: null }));
try {
const data = await apiCall();
setState({ data, loading: false, error: null });
} catch (error) {
setState({
data: null,
loading: false,
error: error as ApiError,
});
}
}, dependencies);
const reset = useCallback(() => {
setState({ data: null, loading: false, error: null });
}, []);
useEffect(() => {
execute();
}, [execute]);
return {
...state,
refetch: execute,
reset,
};
}
export function useAsyncAction<T, P extends any[] = []>(
action: (...params: P) => Promise<T>
): {
execute: (...params: P) => Promise<T>;
loading: boolean;
error: ApiError | null;
reset: () => void;
} {
const [loading, setLoading] = useState(false);
const [error, setError] = useState<ApiError | null>(null);
const execute = useCallback(async (...params: P) => {
setLoading(true);
setError(null);
try {
const result = await action(...params);
setLoading(false);
return result;
} catch (err) {
const apiError = err as ApiError;
setError(apiError);
setLoading(false);
throw apiError;
}
}, [action]);
const reset = useCallback(() => {
setLoading(false);
setError(null);
}, []);
return { execute, loading, error, reset };
}

View File

@@ -0,0 +1,71 @@
// src/hooks/useSessionTimeout.ts
import { useEffect, useRef } from 'react';
import { useAuth } from '../contexts/AuthContext';
interface SessionTimeoutOptions {
timeout: number; // milliseconds
onTimeout?: () => void;
warningTime?: number; // Show warning before timeout
onWarning?: () => void;
}
export const useSessionTimeout = ({
timeout = 30 * 60 * 1000, // 30 minutes default
onTimeout,
warningTime = 5 * 60 * 1000, // 5 minutes warning
onWarning
}: SessionTimeoutOptions) => {
const { logout } = useAuth();
const timeoutRef = useRef<NodeJS.Timeout>();
const warningRef = useRef<NodeJS.Timeout>();
const resetTimeout = () => {
// Clear existing timeouts
if (timeoutRef.current) clearTimeout(timeoutRef.current);
if (warningRef.current) clearTimeout(warningRef.current);
// Set warning timeout
if (warningTime && onWarning) {
warningRef.current = setTimeout(() => {
onWarning();
}, timeout - warningTime);
}
// Set session timeout
timeoutRef.current = setTimeout(() => {
if (onTimeout) {
onTimeout();
} else {
logout();
}
}, timeout);
};
useEffect(() => {
// Activity events to reset timeout
const events = ['mousedown', 'keypress', 'scroll', 'touchstart'];
const handleActivity = () => {
resetTimeout();
};
// Add event listeners
events.forEach(event => {
document.addEventListener(event, handleActivity);
});
// Start timeout
resetTimeout();
// Cleanup
return () => {
events.forEach(event => {
document.removeEventListener(event, handleActivity);
});
if (timeoutRef.current) clearTimeout(timeoutRef.current);
if (warningRef.current) clearTimeout(warningRef.current);
};
}, [timeout, warningTime]);
return { resetTimeout };
};

View File

@@ -0,0 +1,83 @@
// src/hooks/useTrainingProgress.ts
import { useState, useEffect } from 'react';
import { useWebSocket } from '../hooks/useWebSocket';
export interface TrainingProgress {
job_id: string;
status: 'pending' | 'running' | 'completed' | 'failed';
progress: number;
current_step: string;
total_steps: number;
estimated_time_remaining?: number;
metrics?: Record<string, any>;
}
export interface TrainingProgressUpdate {
type: 'training_progress' | 'training_completed' | 'training_error';
job_id: string;
progress?: TrainingProgress;
results?: any;
error?: string;
}
export const useTrainingProgress = (jobId: string | null) => {
const [progress, setProgress] = useState<TrainingProgress | null>(null);
const [error, setError] = useState<string | null>(null);
const [isComplete, setIsComplete] = useState(false);
const handleMessage = (data: TrainingProgressUpdate) => {
switch (data.type) {
case 'training_progress':
setProgress(data.progress!);
setError(null);
break;
case 'training_completed':
setProgress(prev => ({
...prev!,
status: 'completed',
progress: 100
}));
setIsComplete(true);
break;
case 'training_error':
setError(data.error || 'Training failed');
setProgress(prev => prev ? { ...prev, status: 'failed' } : null);
break;
}
};
const { isConnected } = useWebSocket({
endpoint: jobId ? `/training/progress/${jobId}` : '',
onMessage: handleMessage,
onError: () => setError('Connection lost'),
autoConnect: !!jobId
});
// Fetch initial status when job ID changes
useEffect(() => {
if (jobId) {
fetchTrainingStatus(jobId);
}
}, [jobId]);
const fetchTrainingStatus = async (id: string) => {
try {
const response = await fetch(`/api/training/status/${id}`);
if (response.ok) {
const data = await response.json();
setProgress(data);
}
} catch (err) {
console.error('Failed to fetch training status:', err);
}
};
return {
progress,
error,
isComplete,
isConnected
};
};

View File

@@ -0,0 +1,72 @@
// src/hooks/useWebSocket.ts
import { useEffect, useRef, useCallback } from 'react';
import { wsManager, WebSocketHandlers } from '../websocket/WebSocketManager';
export interface UseWebSocketOptions {
endpoint: string;
onMessage: (data: any) => void;
onError?: (error: Event) => void;
onConnect?: () => void;
onDisconnect?: () => void;
onReconnect?: () => void;
autoConnect?: boolean;
}
export const useWebSocket = ({
endpoint,
onMessage,
onError,
onConnect,
onDisconnect,
onReconnect,
autoConnect = true
}: UseWebSocketOptions) => {
const wsRef = useRef<WebSocket | null>(null);
const connect = useCallback(async () => {
if (wsRef.current) return;
const handlers: WebSocketHandlers = {
onOpen: onConnect,
onMessage,
onError,
onClose: onDisconnect,
onReconnect
};
try {
wsRef.current = await wsManager.connect(endpoint, handlers);
} catch (error) {
console.error('WebSocket connection failed:', error);
onError?.(new Event('Connection failed'));
}
}, [endpoint, onMessage, onError, onConnect, onDisconnect, onReconnect]);
const disconnect = useCallback(() => {
if (wsRef.current) {
wsManager.disconnect(endpoint);
wsRef.current = null;
}
}, [endpoint]);
const send = useCallback((data: any) => {
wsManager.send(endpoint, data);
}, [endpoint]);
useEffect(() => {
if (autoConnect) {
connect();
}
return () => {
disconnect();
};
}, [autoConnect, connect, disconnect]);
return {
connect,
disconnect,
send,
isConnected: wsManager.isConnected(endpoint)
};
};

View File

@@ -1,35 +0,0 @@
// frontend/dashboard/src/api/index.ts
/**
* Main API exports - centralized access to all services
*/
import { ApiClient, apiClient } from './base/apiClient';
import { AuthApi } from './services/authApi';
import { TrainingApi } from './services/trainingApi';
import { ForecastingApi } from './services/forecastingApi';
import { SalesApi } from './services/salesApi';
import { DataApi } from './services/dataApi';
import { TenantApi } from './services/tenantApi';
// Service instances using the default client
export const authApi = new AuthApi(apiClient);
export const trainingApi = new TrainingApi(apiClient);
export const forecastingApi = new ForecastingApi(apiClient);
export const salesApi = new SalesApi(apiClient);
export const dataApi = new DataApi(apiClient);
export const tenantApi = new TenantApi(apiClient);
// Export everything for flexibility
export * from './base/apiClient';
export * from './services/authApi';
export * from './services/trainingApi';
export * from './services/forecastingApi';
export * from './services/salesApi';
export * from './services/dataApi';
export * from './services/tenantApi';
export * from '../types/api';
// Convenience hooks for React
export { useApi } from './hooks/useApi';
export { useAuth } from './hooks/useAuth';
export { useTraining } from './hooks/useTraining';

View File

@@ -1,96 +0,0 @@
// frontend/dashboard/src/api/interceptors.ts
/**
* Request/Response interceptors for additional functionality
*/
import { AxiosRequestConfig, AxiosResponse } from 'axios';
import { apiCache } from './cache';
export function addLoggingInterceptor(client: any) {
// Request logging
client.interceptors.request.use(
(config: AxiosRequestConfig) => {
if (process.env.NODE_ENV === 'development') {
console.log(`🚀 API Request: ${config.method?.toUpperCase()} ${config.url}`, {
params: config.params,
data: config.data,
});
}
return config;
},
(error: any) => {
if (process.env.NODE_ENV === 'development') {
console.error('❌ API Request Error:', error);
}
return Promise.reject(error);
}
);
// Response logging
client.interceptors.response.use(
(response: AxiosResponse) => {
if (process.env.NODE_ENV === 'development') {
console.log(`✅ API Response: ${response.config.method?.toUpperCase()} ${response.config.url}`, {
status: response.status,
data: response.data,
});
}
return response;
},
(error: any) => {
if (process.env.NODE_ENV === 'development') {
console.error('❌ API Response Error:', {
url: error.config?.url,
status: error.response?.status,
data: error.response?.data,
});
}
return Promise.reject(error);
}
);
}
export function addCacheInterceptor(client: any) {
// Response caching for GET requests
client.interceptors.response.use(
(response: AxiosResponse) => {
const { method, url } = response.config;
if (method === 'get' && url) {
const cacheKey = `${method}:${url}`;
apiCache.set(cacheKey, response.data, 300000); // 5 minutes
}
return response;
},
(error: any) => Promise.reject(error)
);
}
export function addRetryInterceptor(client: any, maxRetries: number = 3) {
client.interceptors.response.use(
(response: AxiosResponse) => response,
async (error: any) => {
const { config } = error;
if (!config || config.__retryCount >= maxRetries) {
return Promise.reject(error);
}
config.__retryCount = config.__retryCount || 0;
config.__retryCount += 1;
// Only retry on network errors or 5xx errors
if (
!error.response ||
(error.response.status >= 500 && error.response.status < 600)
) {
const delay = Math.pow(2, config.__retryCount) * 1000; // Exponential backoff
await new Promise(resolve => setTimeout(resolve, delay));
return client(config);
}
return Promise.reject(error);
}
);
}

View File

@@ -0,0 +1,49 @@
// src/api/services/api.ts
import { apiClient } from '../base/apiClient';
import {
ApiResponse,
LoginRequest,
RegisterRequest,
TokenResponse,
UserProfile,
TenantInfo,
SalesRecord,
TrainingRequest,
TrainedModel,
ForecastRecord,
ForecastRequest,
WeatherData,
TrafficData,
NotificationSettings,
// ... other types from your api.ts file
} from '../types/api'; // This should point to your main types file (api.ts)
// Assuming your api.ts defines these interfaces:
// interface DashboardStats { ... }
// interface ApiResponse<T> { ... }
// Define DashboardStats interface here or ensure it's imported from your main types file
export interface DashboardStats {
totalSales: number;
totalRevenue: number;
lastTrainingDate: string | null;
forecastAccuracy: number; // e.g., MAPE or RMSE
}
export const dataApi = {
uploadSalesHistory: (file: File, additionalData?: Record<string, any>) =>
apiClient.upload<ApiResponse<any>>('/data/upload-sales', file, additionalData),
getDashboardStats: () =>
apiClient.get<ApiResponse<DashboardStats>>('/dashboard/stats'),
};
export const forecastingApi = {
getForecast: (params: ForecastRequest) =>
apiClient.get<ApiResponse<ForecastRecord[]>>('/forecast', { params }),
};
// Re-export all types from the original api.ts file
export * from '../types/api'

View File

@@ -1,98 +0,0 @@
// frontend/dashboard/src/api/services/authApi.ts
/**
* Authentication API service
*/
import { ApiClient } from '../base/apiClient';
import {
LoginRequest,
RegisterRequest,
TokenResponse,
UserProfile,
ApiResponse,
} from '../../types/api';
export class AuthApi {
constructor(private client: ApiClient) {}
async login(credentials: LoginRequest): Promise<TokenResponse> {
const response = await this.client.post<TokenResponse>('/auth/login', credentials);
// Store tokens
localStorage.setItem('access_token', response.access_token);
localStorage.setItem('refresh_token', response.refresh_token);
return response;
}
async register(userData: RegisterRequest): Promise<UserProfile> {
return this.client.post<UserProfile>('/auth/register', userData);
}
async logout(): Promise<void> {
try {
await this.client.post('/auth/logout');
} finally {
// Always clear local storage
localStorage.removeItem('access_token');
localStorage.removeItem('refresh_token');
localStorage.removeItem('user_profile');
}
}
async refreshToken(): Promise<TokenResponse> {
const refreshToken = localStorage.getItem('refresh_token');
if (!refreshToken) {
throw new Error('No refresh token available');
}
const response = await this.client.post<TokenResponse>('/auth/refresh', {
refresh_token: refreshToken,
});
// Update stored tokens
localStorage.setItem('access_token', response.access_token);
localStorage.setItem('refresh_token', response.refresh_token);
return response;
}
async getCurrentUser(): Promise<UserProfile> {
const profile = await this.client.get<UserProfile>('/auth/me');
localStorage.setItem('user_profile', JSON.stringify(profile));
return profile;
}
async updateProfile(updates: Partial<UserProfile>): Promise<UserProfile> {
return this.client.patch<UserProfile>('/auth/profile', updates);
}
async changePassword(currentPassword: string, newPassword: string): Promise<void> {
return this.client.post('/auth/change-password', {
current_password: currentPassword,
new_password: newPassword,
});
}
async requestPasswordReset(email: string): Promise<void> {
return this.client.post('/auth/password-reset', { email });
}
async confirmPasswordReset(token: string, newPassword: string): Promise<void> {
return this.client.post('/auth/password-reset/confirm', {
token,
new_password: newPassword,
});
}
// Helper methods
isAuthenticated(): boolean {
return !!localStorage.getItem('access_token');
}
getStoredUser(): UserProfile | null {
const stored = localStorage.getItem('user_profile');
return stored ? JSON.parse(stored) : null;
}
}

View File

@@ -1,5 +1,5 @@
// src/api/auth/authService.ts // src/api/auth/authService.ts
import { tokenManager } from './tokenManager'; import { tokenManager } from '../auth/tokenManager';
import { apiClient } from '../base/apiClient'; import { apiClient } from '../base/apiClient';
export interface LoginCredentials { export interface LoginCredentials {

View File

@@ -1,53 +0,0 @@
// frontend/dashboard/src/api/services/dataApi.ts
/**
* External data API service (weather, traffic, etc.)
*/
import { ApiClient } from '../base/apiClient';
import { WeatherData, TrafficData } from '../../types/api';
export class DataApi {
constructor(private client: ApiClient) {}
async getWeatherData(
startDate: string,
endDate: string
): Promise<WeatherData[]> {
return this.client.get<WeatherData[]>('/data/weather', {
params: { start_date: startDate, end_date: endDate },
});
}
async getTrafficData(
startDate: string,
endDate: string
): Promise<TrafficData[]> {
return this.client.get<TrafficData[]>('/data/traffic', {
params: { start_date: startDate, end_date: endDate },
});
}
async getCurrentWeather(): Promise<WeatherData> {
return this.client.get<WeatherData>('/data/weather/current');
}
async getWeatherForecast(days: number = 7): Promise<WeatherData[]> {
return this.client.get<WeatherData[]>('/data/weather/forecast', {
params: { days },
});
}
async syncExternalData(): Promise<{ message: string; synced_records: number }> {
return this.client.post('/data/sync');
}
async getDataQuality(): Promise<{
weather_coverage: number;
traffic_coverage: number;
last_sync: string;
issues: string[];
}> {
return this.client.get('/data/quality');
}
}

View File

@@ -1,48 +0,0 @@
// frontend/dashboard/src/api/services/forecastingApi.ts
/**
* Forecasting API service
*/
import { ApiClient } from '../base/apiClient';
import {
ForecastRecord,
ForecastRequest,
ApiResponse,
} from '../../types/api';
export class ForecastingApi {
constructor(private client: ApiClient) {}
async getForecasts(request: ForecastRequest = {}): Promise<ForecastRecord[]> {
return this.client.get<ForecastRecord[]>('/forecasting/forecasts', {
params: request,
});
}
async getForecastByProduct(
productName: string,
daysAhead: number = 7
): Promise<ForecastRecord[]> {
return this.client.get<ForecastRecord[]>(`/forecasting/forecasts/${productName}`, {
params: { days_ahead: daysAhead },
});
}
async generateForecast(request: ForecastRequest): Promise<ForecastRecord[]> {
return this.client.post<ForecastRecord[]>('/forecasting/generate', request);
}
async updateForecast(forecastId: string, adjustments: Partial<ForecastRecord>): Promise<ForecastRecord> {
return this.client.patch<ForecastRecord>(`/forecasting/forecasts/${forecastId}`, adjustments);
}
async deleteForecast(forecastId: string): Promise<void> {
return this.client.delete(`/forecasting/forecasts/${forecastId}`);
}
async getProductPerformance(productName: string, days: number = 30): Promise<any> {
return this.client.get(`/forecasting/performance/${productName}`, {
params: { days },
});
}
}

View File

@@ -1,17 +0,0 @@
// src/api/services/index.ts
import { apiClient } from '../base/apiClient';
import { AuthService } from './authService';
import { TrainingService } from './trainingService';
import { ForecastingService } from './forecastingService';
import { DataService } from './dataService';
import { TenantService } from './tenantService';
// Service instances with circuit breakers
export const authService = new AuthService(apiClient);
export const trainingService = new TrainingService(apiClient);
export const forecastingService = new ForecastingService(apiClient);
export const dataService = new DataService(apiClient);
export const tenantService = new TenantService(apiClient);
// Export types
export * from '../types';

View File

@@ -1,70 +0,0 @@
// frontend/dashboard/src/api/services/salesApi.ts
/**
* Sales data API service
*/
import { ApiClient } from '../base/apiClient';
import {
SalesRecord,
CreateSalesRequest,
ApiResponse,
} from '../../types/api';
export interface SalesQuery {
start_date?: string;
end_date?: string;
product_name?: string;
limit?: number;
offset?: number;
}
export class SalesApi {
constructor(private client: ApiClient) {}
async getSales(query: SalesQuery = {}): Promise<SalesRecord[]> {
return this.client.get<SalesRecord[]>('/data/sales', {
params: query,
});
}
async createSalesRecord(salesData: CreateSalesRequest): Promise<SalesRecord> {
return this.client.post<SalesRecord>('/data/sales', salesData);
}
async updateSalesRecord(id: string, updates: Partial<CreateSalesRequest>): Promise<SalesRecord> {
return this.client.patch<SalesRecord>(`/data/sales/${id}`, updates);
}
async deleteSalesRecord(id: string): Promise<void> {
return this.client.delete(`/data/sales/${id}`);
}
async bulkCreateSales(salesData: CreateSalesRequest[]): Promise<SalesRecord[]> {
return this.client.post<SalesRecord[]>('/data/sales/bulk', salesData);
}
async uploadSalesFile(
file: File,
onProgress?: (progress: number) => void
): Promise<{ imported: number; errors: any[] }> {
return this.client.uploadFile('/data/sales/upload', file, onProgress);
}
async getSalesAnalytics(
startDate: string,
endDate: string
): Promise<{
totalRevenue: number;
totalQuantity: number;
topProducts: Array<{ product_name: string; quantity: number; revenue: number }>;
dailyTrends: Array<{ date: string; quantity: number; revenue: number }>;
}> {
return this.client.get('/data/sales/analytics', {
params: { start_date: startDate, end_date: endDate },
});
}
async getProductList(): Promise<string[]> {
return this.client.get<string[]>('/data/sales/products');
}
}

View File

@@ -1,41 +0,0 @@
// frontend/dashboard/src/api/services/tenantApi.ts
/**
* Tenant management API service
*/
import { ApiClient } from '../base/apiClient';
import { TenantInfo, NotificationSettings } from '../../types/api';
export class TenantApi {
constructor(private client: ApiClient) {}
async getCurrentTenant(): Promise<TenantInfo> {
return this.client.get<TenantInfo>('/tenants/current');
}
async updateTenant(updates: Partial<TenantInfo>): Promise<TenantInfo> {
return this.client.patch<TenantInfo>('/tenants/current', updates);
}
async getNotificationSettings(): Promise<NotificationSettings> {
return this.client.get<NotificationSettings>('/tenants/notifications');
}
async updateNotificationSettings(settings: Partial<NotificationSettings>): Promise<NotificationSettings> {
return this.client.patch<NotificationSettings>('/tenants/notifications', settings);
}
async testNotification(type: 'email' | 'whatsapp'): Promise<{ sent: boolean; message: string }> {
return this.client.post(`/tenants/notifications/test/${type}`);
}
async getTenantStats(): Promise<{
total_sales_records: number;
total_forecasts: number;
active_models: number;
last_training: string;
data_quality_score: number;
}> {
return this.client.get('/tenants/stats');
}
}

View File

@@ -1,62 +0,0 @@
// frontend/dashboard/src/api/services/trainingApi.ts
/**
* Training API service
*/
import { ApiClient } from '../base/apiClient';
import {
TrainingJobStatus,
TrainingRequest,
TrainedModel,
ApiResponse,
} from '../../types/api';
export class TrainingApi {
constructor(private client: ApiClient) {}
async startTraining(request: TrainingRequest = {}): Promise<TrainingJobStatus> {
return this.client.post<TrainingJobStatus>('/training/train', request);
}
async getTrainingStatus(jobId: string): Promise<TrainingJobStatus> {
return this.client.get<TrainingJobStatus>(`/training/status/${jobId}`);
}
async getTrainingJobs(limit: number = 10, offset: number = 0): Promise<TrainingJobStatus[]> {
return this.client.get<TrainingJobStatus[]>('/training/jobs', {
params: { limit, offset },
});
}
async getTrainedModels(): Promise<TrainedModel[]> {
return this.client.get<TrainedModel[]>('/training/models');
}
async cancelTraining(jobId: string): Promise<void> {
return this.client.delete(`/training/jobs/${jobId}`);
}
// WebSocket for real-time training progress
subscribeToTrainingProgress(
jobId: string,
onProgress: (progress: TrainingJobStatus) => void,
onError?: (error: Error) => void
): WebSocket {
const ws = this.client.createWebSocket(`/training/progress/${jobId}`);
ws.onmessage = (event) => {
try {
const data = JSON.parse(event.data);
onProgress(data);
} catch (error) {
onError?.(new Error('Failed to parse progress data'));
}
};
ws.onerror = (event) => {
onError?.(new Error('WebSocket connection error'));
};
return ws;
}
}

View File

@@ -0,0 +1,33 @@
// src/components/auth/ProtectedRoute.tsx
import React from 'react';
import { Navigate, useLocation } from 'react-router-dom';
import { useAuth } from '../../contexts/AuthContext';
interface ProtectedRouteProps {
children: React.ReactNode;
requireAuth?: boolean;
redirectTo?: string;
}
export const ProtectedRoute: React.FC<ProtectedRouteProps> = ({
children,
requireAuth = true,
redirectTo = '/login'
}) => {
const { isAuthenticated, isLoading } = useAuth();
const location = useLocation();
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>
);
}
if (requireAuth && !isAuthenticated) {
return <Navigate to={redirectTo} state={{ from: location }} replace />;
}
return <>{children}</>;
};

View File

@@ -0,0 +1,57 @@
// src/components/common/ErrorBoundary.tsx
import React, { Component, ErrorInfo, ReactNode } from 'react';
interface Props {
children: ReactNode;
fallback?: ReactNode;
}
interface State {
hasError: boolean;
error: Error | null;
}
export class ErrorBoundary extends Component<Props, State> {
state: State = {
hasError: false,
error: null
};
static getDerivedStateFromError(error: Error): State {
return { hasError: true, error };
}
componentDidCatch(error: Error, errorInfo: ErrorInfo) {
console.error('ErrorBoundary caught:', error, errorInfo);
// Send error to monitoring service
if (process.env.NODE_ENV === 'production') {
// logErrorToService(error, errorInfo);
}
}
render() {
if (this.state.hasError) {
return this.props.fallback || (
<div className="min-h-screen flex items-center justify-center">
<div className="text-center">
<h1 className="text-2xl font-bold text-gray-900 mb-4">
Algo salió mal
</h1>
<p className="text-gray-600 mb-6">
Ha ocurrido un error inesperado. Por favor, recarga la página.
</p>
<button
onClick={() => window.location.reload()}
className="px-4 py-2 bg-indigo-600 text-white rounded-md hover:bg-indigo-700"
>
Recargar página
</button>
</div>
</div>
);
}
return this.props.children;
}
}

View File

@@ -0,0 +1,64 @@
// src/components/common/NotificationToast.tsx
import React, { useEffect } from 'react';
import { XMarkIcon } from '@heroicons/react/24/outline';
import {
CheckCircleIcon,
ExclamationCircleIcon,
ExclamationTriangleIcon,
InformationCircleIcon
} from '@heroicons/react/24/solid';
interface NotificationToastProps {
id: string;
type: 'success' | 'error' | 'warning' | 'info';
title: string;
message: string;
onClose: () => void;
}
export const NotificationToast: React.FC<NotificationToastProps> = ({
type,
title,
message,
onClose
}) => {
const icons = {
success: CheckCircleIcon,
error: ExclamationCircleIcon,
warning: ExclamationTriangleIcon,
info: InformationCircleIcon
};
const colors = {
success: 'text-green-400',
error: 'text-red-400',
warning: 'text-yellow-400',
info: 'text-blue-400'
};
const Icon = icons[type];
return (
<div className="max-w-sm w-full bg-white shadow-lg rounded-lg pointer-events-auto ring-1 ring-black ring-opacity-5 overflow-hidden">
<div className="p-4">
<div className="flex items-start">
<div className="flex-shrink-0">
<Icon className={`h-6 w-6 ${colors[type]}`} />
</div>
<div className="ml-3 w-0 flex-1 pt-0.5">
<p className="text-sm font-medium text-gray-900">{title}</p>
<p className="mt-1 text-sm text-gray-500">{message}</p>
</div>
<div className="ml-4 flex-shrink-0 flex">
<button
className="bg-white rounded-md inline-flex text-gray-400 hover:text-gray-500 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500"
onClick={onClose}
>
<XMarkIcon className="h-5 w-5" />
</button>
</div>
</div>
</div>
</div>
);
};

View File

@@ -1,31 +0,0 @@
"use client";
import React from 'react';
import { useRouter } from 'next/router';
import { useAuth } from '../../contexts/AuthContext';
interface PrivateRouteProps {
children: React.ReactNode;
}
const PrivateRoute: React.FC<PrivateRouteProps> = ({ children }) => {
const { user, loading } = useAuth();
const router = useRouter();
if (loading) {
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-orange-600"></div>
</div>
);
}
if (!user) {
router.push('/login');
return null;
}
return <>{children}</>;
};
export default PrivateRoute;

View File

@@ -0,0 +1,47 @@
// src/components/data/SalesUploader.tsx
import React, { useRef, useState } from 'react';
import { CloudArrowUpIcon } from '@heroicons/react/24/outline';
interface SalesUploaderProps {
onUpload: (file: File) => Promise<void>;
}
export const SalesUploader: React.FC<SalesUploaderProps> = ({ onUpload }) => {
const fileInputRef = useRef<HTMLInputElement>(null);
const [isUploading, setIsUploading] = useState(false);
const handleFileSelect = async (event: React.ChangeEvent<HTMLInputElement>) => {
const file = event.target.files?.[0];
if (!file) return;
setIsUploading(true);
try {
await onUpload(file);
} finally {
setIsUploading(false);
if (fileInputRef.current) {
fileInputRef.current.value = '';
}
}
};
return (
<>
<input
ref={fileInputRef}
type="file"
accept=".csv,.xlsx,.xls"
onChange={handleFileSelect}
className="hidden"
/>
<button
onClick={() => fileInputRef.current?.click()}
disabled={isUploading}
className="inline-flex items-center px-4 py-2 border border-gray-300 shadow-sm text-sm font-medium rounded-md text-gray-700 bg-white hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500 disabled:opacity-50 disabled:cursor-not-allowed"
>
<CloudArrowUpIcon className="h-5 w-5 mr-2" />
{isUploading ? 'Uploading...' : 'Upload Sales Data'}
</button>
</>
);
};

View File

@@ -0,0 +1,113 @@
// src/components/training/TrainingProgressCard.tsx
import React from 'react';
import { useTrainingProgress } from '../../api/hooks/useTrainingProgress';
import { CheckCircleIcon, XCircleIcon } from '@heroicons/react/24/outline';
interface TrainingProgressCardProps {
jobId: string;
onComplete?: () => void;
}
export const TrainingProgressCard: React.FC<TrainingProgressCardProps> = ({
jobId,
onComplete
}) => {
const { progress, error, isComplete, isConnected } = useTrainingProgress(jobId);
React.useEffect(() => {
if (isComplete && onComplete) {
onComplete();
}
}, [isComplete, onComplete]);
if (!progress) {
return (
<div className="bg-white rounded-lg shadow p-6">
<div className="animate-pulse">
<div className="h-4 bg-gray-200 rounded w-3/4 mb-4"></div>
<div className="h-2 bg-gray-200 rounded"></div>
</div>
</div>
);
}
return (
<div className="bg-white rounded-lg shadow p-6">
<div className="flex items-center justify-between mb-4">
<h3 className="text-lg font-semibold">Training Progress</h3>
<div className="flex items-center space-x-2">
{isConnected && (
<span className="flex items-center text-sm text-green-600">
<span className="w-2 h-2 bg-green-600 rounded-full mr-1 animate-pulse"></span>
Live
</span>
)}
{progress.status === 'completed' && (
<CheckCircleIcon className="w-5 h-5 text-green-600" />
)}
{progress.status === 'failed' && (
<XCircleIcon className="w-5 h-5 text-red-600" />
)}
</div>
</div>
{error && (
<div className="mb-4 p-3 bg-red-50 text-red-700 rounded-md text-sm">
{error}
</div>
)}
<div className="space-y-3">
<div>
<div className="flex justify-between text-sm text-gray-600 mb-1">
<span>{progress.current_step}</span>
<span>{Math.round(progress.progress)}%</span>
</div>
<div className="w-full bg-gray-200 rounded-full h-2">
<div
className="bg-indigo-600 h-2 rounded-full transition-all duration-300 ease-out"
style={{ width: `${progress.progress}%` }}
/>
</div>
</div>
{progress.estimated_time_remaining && (
<p className="text-sm text-gray-600">
Tiempo estimado: {formatTime(progress.estimated_time_remaining)}
</p>
)}
{progress.metrics && (
<div className="mt-4 grid grid-cols-2 gap-4">
{Object.entries(progress.metrics).map(([key, value]) => (
<div key={key} className="text-sm">
<span className="text-gray-600">{formatMetricName(key)}:</span>
<span className="ml-2 font-medium">{formatMetricValue(value)}</span>
</div>
))}
</div>
)}
</div>
</div>
);
};
// Utility functions
const formatTime = (seconds: number): string => {
if (seconds < 60) return `${seconds}s`;
const minutes = Math.floor(seconds / 60);
if (minutes < 60) return `${minutes}m`;
const hours = Math.floor(minutes / 60);
return `${hours}h ${minutes % 60}m`;
};
const formatMetricName = (name: string): string => {
return name.replace(/_/g, ' ').replace(/\b\w/g, l => l.toUpperCase());
};
const formatMetricValue = (value: any): string => {
if (typeof value === 'number') {
return value.toFixed(2);
}
return String(value);
};

View File

@@ -1,6 +1,6 @@
// src/contexts/AuthContext.tsx // src/contexts/AuthContext.tsx
import React, { createContext, useContext, useEffect, useState, useCallback } from 'react'; import React, { createContext, useContext, useEffect, useState, useCallback } from 'react';
import { authService, UserProfile } from '../api/auth/authService'; import { authService, UserProfile } from '../api/services/authService';
import { tokenManager } from '../api/auth/tokenManager'; import { tokenManager } from '../api/auth/tokenManager';
interface AuthContextType { interface AuthContextType {

View File

@@ -1,28 +1,39 @@
// src/pages/Dashboard/Dashboard.tsx // src/pages/dashboard/index.tsx
import React, { useState, useEffect, useCallback } from 'react'; import React, { useState, useEffect, useCallback } from 'react';
import { useAuth } from '../../api/hooks/useAuth'; import Head from 'next/head';
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 { import {
ChartBarIcon, ChartBarIcon,
CloudArrowUpIcon, CloudArrowUpIcon,
CpuChipIcon, CpuChipIcon,
BellIcon, BellIcon,
ArrowPathIcon ArrowPathIcon,
ScaleIcon, // For accuracy
CalendarDaysIcon, // For last training date
} from '@heroicons/react/24/outline'; } 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 { interface DashboardStats {
totalSales: number; totalSales: number;
totalRevenue: number; totalRevenue: number;
lastTrainingDate: string | null; lastTrainingDate: string | null;
forecastAccuracy: number; forecastAccuracy: number; // e.g., MAPE or RMSE
} }
interface Notification { interface Notification {
@@ -33,303 +44,29 @@ interface Notification {
timestamp: Date; timestamp: Date;
} }
export const Dashboard: React.FC = () => { // StatsCard Component (moved here for completeness, or keep in common if reused)
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
interface StatsCardProps { interface StatsCardProps {
title: string; title: string;
value: any; value: any;
icon: React.ElementType; 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 = () => { 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) { switch (format) {
case 'number': case 'number':
return value.toLocaleString(); return value.toLocaleString('es-ES');
case 'currency': case 'currency':
return new Intl.NumberFormat('es-ES', { return new Intl.NumberFormat('es-ES', {
style: 'currency', style: 'currency',
currency: 'EUR' currency: 'EUR',
}).format(value); }).format(value);
case 'percentage': case 'percentage':
return `${(value * 100).toFixed(1)}%`; return `${(value * 100).toFixed(1)}%`;
@@ -341,16 +78,305 @@ const StatsCard: React.FC<StatsCardProps> = ({ title, value, icon: Icon, format
}; };
return ( return (
<div className="bg-white rounded-lg shadow p-6"> <div className="bg-white rounded-lg shadow p-6 flex items-center">
<div className="flex items-center"> <div className="flex-shrink-0">
<div className="flex-shrink-0"> <Icon className="h-8 w-8 text-pania-blue" /> {/* Changed icon color */}
<Icon className="h-6 w-6 text-gray-400" /> </div>
</div> <div className="ml-5">
<div className="ml-4"> <dt className="text-sm font-medium text-gray-500">{title}</dt>
<dt className="text-sm font-medium text-gray-500">{title}</dt> <dd className="mt-1 text-3xl font-semibold text-gray-900">{formatValue()}</dd>
<dd className="text-2xl font-semibold text-gray-900">{formatValue()}</dd>
</div>
</div> </div>
</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

View File

@@ -1,80 +0,0 @@
// frontend/dashboard/src/utils/apiHelpers.ts
/**
* Utility functions for API operations
*/
export function formatApiError(error: any): string {
if (error?.detail) {
return error.detail;
}
if (error?.message) {
return error.message;
}
return 'An unexpected error occurred';
}
export function isApiError(error: any): boolean {
return error && (error.detail || error.error_code);
}
export function formatDate(date: Date | string): string {
const d = typeof date === 'string' ? new Date(date) : date;
return d.toISOString().split('T')[0];
}
export function formatDateTime(date: Date | string): string {
const d = typeof date === 'string' ? new Date(date) : date;
return d.toISOString();
}
export function buildQueryParams(params: Record<string, any>): URLSearchParams {
const searchParams = new URLSearchParams();
Object.entries(params).forEach(([key, value]) => {
if (value !== null && value !== undefined && value !== '') {
if (Array.isArray(value)) {
value.forEach(item => searchParams.append(key, String(item)));
} else {
searchParams.append(key, String(value));
}
}
});
return searchParams;
}
export function debounce<T extends (...args: any[]) => any>(
func: T,
wait: number
): (...args: Parameters<T>) => void {
let timeout: NodeJS.Timeout | null = null;
return (...args: Parameters<T>) => {
if (timeout) {
clearTimeout(timeout);
}
timeout = setTimeout(() => {
func.apply(null, args);
}, wait);
};
}
export function retryWithDelay<T>(
fn: () => Promise<T>,
retries: number = 3,
delay: number = 1000
): Promise<T> {
return fn().catch((error) => {
if (retries > 0) {
return new Promise((resolve) => {
setTimeout(() => {
resolve(retryWithDelay(fn, retries - 1, delay * 2));
}, delay);
});
}
throw error;
});
}