Add new frontend - fix 2
This commit is contained in:
6416
frontend/package-lock.json
generated
6416
frontend/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -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",
|
||||||
|
|||||||
@@ -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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
@@ -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;
|
|
||||||
@@ -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 };
|
|
||||||
}
|
|
||||||
|
|
||||||
71
frontend/src/api/hooks/useSessionTimeout.ts
Normal file
71
frontend/src/api/hooks/useSessionTimeout.ts
Normal 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 };
|
||||||
|
};
|
||||||
83
frontend/src/api/hooks/useTrainingProgress.ts
Normal file
83
frontend/src/api/hooks/useTrainingProgress.ts
Normal 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
|
||||||
|
};
|
||||||
|
};
|
||||||
72
frontend/src/api/hooks/useWebSocket.ts
Normal file
72
frontend/src/api/hooks/useWebSocket.ts
Normal 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)
|
||||||
|
};
|
||||||
|
};
|
||||||
@@ -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';
|
|
||||||
@@ -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);
|
|
||||||
}
|
|
||||||
);
|
|
||||||
}
|
|
||||||
49
frontend/src/api/services/api.ts
Normal file
49
frontend/src/api/services/api.ts
Normal 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'
|
||||||
@@ -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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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 {
|
||||||
@@ -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');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@@ -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 },
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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';
|
|
||||||
@@ -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');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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}</>;
|
||||||
|
};
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -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;
|
|
||||||
@@ -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>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -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);
|
||||||
|
};
|
||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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-6 w-6 text-gray-400" />
|
<Icon className="h-8 w-8 text-pania-blue" /> {/* Changed icon color */}
|
||||||
</div>
|
</div>
|
||||||
<div className="ml-4">
|
<div className="ml-5">
|
||||||
<dt className="text-sm font-medium text-gray-500">{title}</dt>
|
<dt className="text-sm font-medium text-gray-500">{title}</dt>
|
||||||
<dd className="text-2xl font-semibold text-gray-900">{formatValue()}</dd>
|
<dd className="mt-1 text-3xl 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>© {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
@@ -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;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
Reference in New Issue
Block a user