first commit
This commit is contained in:
@@ -1,5 +1,4 @@
|
|||||||
# docker-compose.yml - Development Environment
|
# docker-compose.yml - Development Environment
|
||||||
version: '3.8'
|
|
||||||
|
|
||||||
services:
|
services:
|
||||||
# Message Broker
|
# Message Broker
|
||||||
@@ -420,8 +419,8 @@ services:
|
|||||||
# Dashboard Frontend
|
# Dashboard Frontend
|
||||||
dashboard:
|
dashboard:
|
||||||
build:
|
build:
|
||||||
context: ./frontend/dashboard
|
context: ./frontend
|
||||||
dockerfile: Dockerfile
|
dockerfile: Dockerfile.dev
|
||||||
container_name: bakery-dashboard
|
container_name: bakery-dashboard
|
||||||
environment:
|
environment:
|
||||||
- REACT_APP_API_URL=http://localhost:8000
|
- REACT_APP_API_URL=http://localhost:8000
|
||||||
@@ -434,27 +433,10 @@ services:
|
|||||||
networks:
|
networks:
|
||||||
- bakery-network
|
- bakery-network
|
||||||
volumes:
|
volumes:
|
||||||
- ./frontend/dashboard:/app
|
- ./frontend:/app
|
||||||
- /app/node_modules
|
- /app/node_modules
|
||||||
|
|
||||||
# Marketing Site
|
|
||||||
marketing:
|
|
||||||
build:
|
|
||||||
context: ./frontend/marketing
|
|
||||||
dockerfile: Dockerfile
|
|
||||||
container_name: bakery-marketing
|
|
||||||
environment:
|
|
||||||
- NEXT_PUBLIC_API_URL=http://localhost:8000
|
|
||||||
- NEXT_PUBLIC_SITE_URL=http://localhost:3001
|
|
||||||
ports:
|
|
||||||
- "3001:3000"
|
|
||||||
depends_on:
|
|
||||||
- gateway
|
|
||||||
networks:
|
|
||||||
- bakery-network
|
|
||||||
volumes:
|
|
||||||
- ./frontend/marketing:/app
|
|
||||||
- /app/node_modules
|
|
||||||
|
|
||||||
# Monitoring - Prometheus
|
# Monitoring - Prometheus
|
||||||
prometheus:
|
prometheus:
|
||||||
|
|||||||
18
frontend/Dockerfile.dev
Normal file
18
frontend/Dockerfile.dev
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
FROM node:18-alpine
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
# Copy package files
|
||||||
|
COPY package*.json ./
|
||||||
|
|
||||||
|
# Install dependencies
|
||||||
|
RUN npm ci
|
||||||
|
|
||||||
|
# Copy application files
|
||||||
|
COPY . .
|
||||||
|
|
||||||
|
# Expose port
|
||||||
|
EXPOSE 3000
|
||||||
|
|
||||||
|
# Start development server
|
||||||
|
CMD ["npm", "run", "dev"]
|
||||||
5
frontend/next-env.d.ts
vendored
Normal file
5
frontend/next-env.d.ts
vendored
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
/// <reference types="next" />
|
||||||
|
/// <reference types="next/image-types/global" />
|
||||||
|
|
||||||
|
// NOTE: This file should not be edited
|
||||||
|
// see https://nextjs.org/docs/basic-features/typescript for more information.
|
||||||
14
frontend/next.config.js
Normal file
14
frontend/next.config.js
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
/** @type {import('next').NextConfig} */
|
||||||
|
const nextConfig = {
|
||||||
|
reactStrictMode: true,
|
||||||
|
swcMinify: true,
|
||||||
|
i18n: {
|
||||||
|
locales: ['es', 'en'],
|
||||||
|
defaultLocale: 'es',
|
||||||
|
},
|
||||||
|
images: {
|
||||||
|
domains: ['bakeryforecast.es'],
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = nextConfig
|
||||||
6416
frontend/package-lock.json
generated
Normal file
6416
frontend/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
38
frontend/package.json
Normal file
38
frontend/package.json
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
{
|
||||||
|
"name": "bakery-dashboard",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"private": true,
|
||||||
|
"scripts": {
|
||||||
|
"dev": "next dev",
|
||||||
|
"build": "next build",
|
||||||
|
"start": "next start",
|
||||||
|
"lint": "next lint",
|
||||||
|
"type-check": "tsc --noEmit"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"next": "14.0.0",
|
||||||
|
"react": "^18.2.0",
|
||||||
|
"react-dom": "^18.2.0",
|
||||||
|
"chart.js": "^4.4.0",
|
||||||
|
"react-chartjs-2": "^5.2.0",
|
||||||
|
"axios": "^1.6.0",
|
||||||
|
"@tailwindcss/forms": "^0.5.7",
|
||||||
|
"date-fns": "^2.30.0",
|
||||||
|
"react-hook-form": "^7.47.0",
|
||||||
|
"zustand": "^4.4.6",
|
||||||
|
"@headlessui/react": "^2.0.0",
|
||||||
|
"@heroicons/react": "^2.0.18",
|
||||||
|
"framer-motion": "^10.16.4"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@types/node": "^20.8.0",
|
||||||
|
"@types/react": "^18.2.0",
|
||||||
|
"@types/react-dom": "^18.2.0",
|
||||||
|
"autoprefixer": "^10.4.16",
|
||||||
|
"eslint": "^8.52.0",
|
||||||
|
"eslint-config-next": "14.0.0",
|
||||||
|
"postcss": "^8.4.31",
|
||||||
|
"tailwindcss": "^3.3.5",
|
||||||
|
"typescript": "^5.2.2"
|
||||||
|
}
|
||||||
|
}
|
||||||
7
frontend/postcss.config.js
Normal file
7
frontend/postcss.config.js
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
// postcss.config.js
|
||||||
|
module.exports = {
|
||||||
|
plugins: {
|
||||||
|
tailwindcss: {},
|
||||||
|
autoprefixer: {},
|
||||||
|
},
|
||||||
|
};
|
||||||
212
frontend/src/api/base/apiClient.ts
Normal file
212
frontend/src/api/base/apiClient.ts
Normal file
@@ -0,0 +1,212 @@
|
|||||||
|
// frontend/dashboard/src/api/base/apiClient.ts
|
||||||
|
/**
|
||||||
|
* Base API client with authentication and error handling
|
||||||
|
*/
|
||||||
|
|
||||||
|
import axios, { AxiosInstance, AxiosRequestConfig, AxiosResponse } from 'axios';
|
||||||
|
import { ApiError, TokenResponse } from '../../types/api';
|
||||||
|
|
||||||
|
export interface ApiClientConfig {
|
||||||
|
baseURL?: string;
|
||||||
|
timeout?: number;
|
||||||
|
enableAuth?: boolean;
|
||||||
|
enableRetry?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class ApiClient {
|
||||||
|
private client: AxiosInstance;
|
||||||
|
private enableAuth: boolean;
|
||||||
|
private refreshPromise: Promise<string> | null = null;
|
||||||
|
|
||||||
|
constructor(config: ApiClientConfig = {}) {
|
||||||
|
const {
|
||||||
|
baseURL = process.env.REACT_APP_API_URL || 'http://localhost:8000',
|
||||||
|
timeout = 10000,
|
||||||
|
enableAuth = true,
|
||||||
|
enableRetry = true,
|
||||||
|
} = config;
|
||||||
|
|
||||||
|
this.enableAuth = enableAuth;
|
||||||
|
|
||||||
|
this.client = axios.create({
|
||||||
|
baseURL: `${baseURL}/api/v1`,
|
||||||
|
timeout,
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
this.setupInterceptors(enableRetry);
|
||||||
|
}
|
||||||
|
|
||||||
|
private setupInterceptors(enableRetry: boolean) {
|
||||||
|
// Request interceptor - add auth token
|
||||||
|
this.client.interceptors.request.use(
|
||||||
|
(config) => {
|
||||||
|
if (this.enableAuth) {
|
||||||
|
const token = this.getStoredToken();
|
||||||
|
if (token) {
|
||||||
|
config.headers.Authorization = `Bearer ${token}`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return config;
|
||||||
|
},
|
||||||
|
(error) => Promise.reject(error)
|
||||||
|
);
|
||||||
|
|
||||||
|
// Response interceptor - handle auth errors and retries
|
||||||
|
this.client.interceptors.response.use(
|
||||||
|
(response) => response,
|
||||||
|
async (error) => {
|
||||||
|
const originalRequest = error.config;
|
||||||
|
|
||||||
|
// Handle 401 errors with token refresh
|
||||||
|
if (
|
||||||
|
error.response?.status === 401 &&
|
||||||
|
this.enableAuth &&
|
||||||
|
!originalRequest._retry
|
||||||
|
) {
|
||||||
|
originalRequest._retry = true;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const newToken = await this.refreshToken();
|
||||||
|
originalRequest.headers.Authorization = `Bearer ${newToken}`;
|
||||||
|
return this.client(originalRequest);
|
||||||
|
} catch (refreshError) {
|
||||||
|
this.handleAuthFailure();
|
||||||
|
return Promise.reject(refreshError);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle other errors
|
||||||
|
return Promise.reject(this.formatError(error));
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async refreshToken(): Promise<string> {
|
||||||
|
// Prevent multiple simultaneous refresh requests
|
||||||
|
if (this.refreshPromise) {
|
||||||
|
return this.refreshPromise;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.refreshPromise = this.performTokenRefresh();
|
||||||
|
|
||||||
|
try {
|
||||||
|
const token = await this.refreshPromise;
|
||||||
|
this.refreshPromise = null;
|
||||||
|
return token;
|
||||||
|
} catch (error) {
|
||||||
|
this.refreshPromise = null;
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async performTokenRefresh(): Promise<string> {
|
||||||
|
const refreshToken = localStorage.getItem('refresh_token');
|
||||||
|
|
||||||
|
if (!refreshToken) {
|
||||||
|
throw new Error('No refresh token available');
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await axios.post<TokenResponse>(
|
||||||
|
`${this.client.defaults.baseURL}/auth/refresh`,
|
||||||
|
{ refresh_token: refreshToken }
|
||||||
|
);
|
||||||
|
|
||||||
|
const { access_token, refresh_token: newRefreshToken } = response.data;
|
||||||
|
|
||||||
|
localStorage.setItem('access_token', access_token);
|
||||||
|
localStorage.setItem('refresh_token', newRefreshToken);
|
||||||
|
|
||||||
|
return access_token;
|
||||||
|
} catch (error) {
|
||||||
|
throw new Error('Token refresh failed');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private getStoredToken(): string | null {
|
||||||
|
return localStorage.getItem('access_token');
|
||||||
|
}
|
||||||
|
|
||||||
|
private handleAuthFailure(): void {
|
||||||
|
localStorage.removeItem('access_token');
|
||||||
|
localStorage.removeItem('refresh_token');
|
||||||
|
localStorage.removeItem('user_profile');
|
||||||
|
|
||||||
|
// Redirect to login
|
||||||
|
window.location.href = '/login';
|
||||||
|
}
|
||||||
|
|
||||||
|
private formatError(error: any): ApiError {
|
||||||
|
if (error.response?.data) {
|
||||||
|
return {
|
||||||
|
detail: error.response.data.detail || 'An error occurred',
|
||||||
|
service: error.response.data.service,
|
||||||
|
error_code: error.response.data.error_code,
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
detail: error.message || 'Network error occurred',
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// HTTP methods
|
||||||
|
async get<T>(url: string, config?: AxiosRequestConfig): Promise<T> {
|
||||||
|
const response = await this.client.get<T>(url, config);
|
||||||
|
return response.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
async post<T>(url: string, data?: any, config?: AxiosRequestConfig): Promise<T> {
|
||||||
|
const response = await this.client.post<T>(url, data, config);
|
||||||
|
return response.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
async put<T>(url: string, data?: any, config?: AxiosRequestConfig): Promise<T> {
|
||||||
|
const response = await this.client.put<T>(url, data, config);
|
||||||
|
return response.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
async patch<T>(url: string, data?: any, config?: AxiosRequestConfig): Promise<T> {
|
||||||
|
const response = await this.client.patch<T>(url, data, config);
|
||||||
|
return response.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
async delete<T>(url: string, config?: AxiosRequestConfig): Promise<T> {
|
||||||
|
const response = await this.client.delete<T>(url, config);
|
||||||
|
return response.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
// File upload
|
||||||
|
async uploadFile<T>(url: string, file: File, onProgress?: (progress: number) => void): Promise<T> {
|
||||||
|
const formData = new FormData();
|
||||||
|
formData.append('file', file);
|
||||||
|
|
||||||
|
const response = await this.client.post<T>(url, formData, {
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'multipart/form-data',
|
||||||
|
},
|
||||||
|
onUploadProgress: (progressEvent) => {
|
||||||
|
if (onProgress && progressEvent.total) {
|
||||||
|
const progress = Math.round((progressEvent.loaded * 100) / progressEvent.total);
|
||||||
|
onProgress(progress);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return response.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
// WebSocket connection helper
|
||||||
|
createWebSocket(path: string): WebSocket {
|
||||||
|
const wsUrl = this.client.defaults.baseURL?.replace('http', 'ws') + path;
|
||||||
|
return new WebSocket(wsUrl);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Default client instance
|
||||||
|
export const apiClient = new ApiClient();
|
||||||
76
frontend/src/api/cache.ts
Normal file
76
frontend/src/api/cache.ts
Normal file
@@ -0,0 +1,76 @@
|
|||||||
|
// 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;
|
||||||
|
});
|
||||||
|
}
|
||||||
70
frontend/src/api/config.ts
Normal file
70
frontend/src/api/config.ts
Normal file
@@ -0,0 +1,70 @@
|
|||||||
|
// 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;
|
||||||
92
frontend/src/api/hooks/useApi.ts
Normal file
92
frontend/src/api/hooks/useApi.ts
Normal file
@@ -0,0 +1,92 @@
|
|||||||
|
// 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 };
|
||||||
|
}
|
||||||
|
|
||||||
0
frontend/src/api/hooks/useAuth.ts
Normal file
0
frontend/src/api/hooks/useAuth.ts
Normal file
67
frontend/src/api/hooks/useTraining.ts
Normal file
67
frontend/src/api/hooks/useTraining.ts
Normal file
@@ -0,0 +1,67 @@
|
|||||||
|
// frontend/dashboard/src/api/hooks/useTraining.ts
|
||||||
|
/**
|
||||||
|
* Training-specific hooks
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { useState, useEffect, useRef } from 'react';
|
||||||
|
import { TrainingJobStatus, TrainingRequest } from '../../types/api';
|
||||||
|
import { trainingApi } from '../index';
|
||||||
|
import { useApi, useAsyncAction } from './useApi';
|
||||||
|
|
||||||
|
export function useTraining() {
|
||||||
|
const { data: jobs, loading, error, refetch } = useApi(() => trainingApi.getTrainingJobs());
|
||||||
|
const { data: models, refetch: refetchModels } = useApi(() => trainingApi.getTrainedModels());
|
||||||
|
|
||||||
|
const { execute: startTraining, loading: startingTraining } = useAsyncAction(
|
||||||
|
trainingApi.startTraining.bind(trainingApi)
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
jobs: jobs || [],
|
||||||
|
models: models || [],
|
||||||
|
loading,
|
||||||
|
error,
|
||||||
|
startingTraining,
|
||||||
|
startTraining: async (request: TrainingRequest) => {
|
||||||
|
const job = await startTraining(request);
|
||||||
|
await refetch();
|
||||||
|
return job;
|
||||||
|
},
|
||||||
|
refresh: refetch,
|
||||||
|
refreshModels: refetchModels,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useTrainingProgress(jobId: string | null) {
|
||||||
|
const [progress, setProgress] = useState<TrainingJobStatus | null>(null);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const wsRef = useRef<WebSocket | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!jobId) return;
|
||||||
|
|
||||||
|
// Initial status fetch
|
||||||
|
trainingApi.getTrainingStatus(jobId).then(setProgress).catch(setError);
|
||||||
|
|
||||||
|
// Set up WebSocket for real-time updates
|
||||||
|
wsRef.current = trainingApi.subscribeToTrainingProgress(
|
||||||
|
jobId,
|
||||||
|
(updatedProgress) => {
|
||||||
|
setProgress(updatedProgress);
|
||||||
|
setError(null);
|
||||||
|
},
|
||||||
|
(wsError) => {
|
||||||
|
setError(wsError.message);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
if (wsRef.current) {
|
||||||
|
wsRef.current.close();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}, [jobId]);
|
||||||
|
|
||||||
|
return { progress, error };
|
||||||
|
}
|
||||||
|
|
||||||
109
frontend/src/api/hooks/useWebSocket.ts
Normal file
109
frontend/src/api/hooks/useWebSocket.ts
Normal file
@@ -0,0 +1,109 @@
|
|||||||
|
// frontend/dashboard/src/api/hooks/useWebSocket.ts
|
||||||
|
/**
|
||||||
|
* Generic WebSocket hook for real-time updates
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { useState, useEffect, useRef, useCallback } from 'react';
|
||||||
|
|
||||||
|
export interface WebSocketOptions {
|
||||||
|
reconnectAttempts?: number;
|
||||||
|
reconnectInterval?: number;
|
||||||
|
onOpen?: () => void;
|
||||||
|
onClose?: () => void;
|
||||||
|
onError?: (error: Event) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useWebSocket<T>(
|
||||||
|
url: string | null,
|
||||||
|
options: WebSocketOptions = {}
|
||||||
|
): {
|
||||||
|
data: T | null;
|
||||||
|
connectionState: 'connecting' | 'open' | 'closed' | 'error';
|
||||||
|
send: (data: any) => void;
|
||||||
|
close: () => void;
|
||||||
|
} {
|
||||||
|
const [data, setData] = useState<T | null>(null);
|
||||||
|
const [connectionState, setConnectionState] = useState<'connecting' | 'open' | 'closed' | 'error'>('closed');
|
||||||
|
|
||||||
|
const wsRef = useRef<WebSocket | null>(null);
|
||||||
|
const reconnectTimeoutRef = useRef<NodeJS.Timeout | null>(null);
|
||||||
|
const reconnectAttemptsRef = useRef(0);
|
||||||
|
|
||||||
|
const {
|
||||||
|
reconnectAttempts = 3,
|
||||||
|
reconnectInterval = 3000,
|
||||||
|
onOpen,
|
||||||
|
onClose,
|
||||||
|
onError,
|
||||||
|
} = options;
|
||||||
|
|
||||||
|
const connect = useCallback(() => {
|
||||||
|
if (!url || wsRef.current?.readyState === WebSocket.OPEN) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
setConnectionState('connecting');
|
||||||
|
wsRef.current = new WebSocket(url);
|
||||||
|
|
||||||
|
wsRef.current.onopen = () => {
|
||||||
|
setConnectionState('open');
|
||||||
|
reconnectAttemptsRef.current = 0;
|
||||||
|
onOpen?.();
|
||||||
|
};
|
||||||
|
|
||||||
|
wsRef.current.onmessage = (event) => {
|
||||||
|
try {
|
||||||
|
const parsedData = JSON.parse(event.data);
|
||||||
|
setData(parsedData);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to parse WebSocket message:', error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
wsRef.current.onclose = () => {
|
||||||
|
setConnectionState('closed');
|
||||||
|
onClose?.();
|
||||||
|
|
||||||
|
// Attempt to reconnect
|
||||||
|
if (reconnectAttemptsRef.current < reconnectAttempts) {
|
||||||
|
reconnectAttemptsRef.current++;
|
||||||
|
reconnectTimeoutRef.current = setTimeout(connect, reconnectInterval);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
wsRef.current.onerror = (error) => {
|
||||||
|
setConnectionState('error');
|
||||||
|
onError?.(error);
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
setConnectionState('error');
|
||||||
|
console.error('WebSocket connection failed:', error);
|
||||||
|
}
|
||||||
|
}, [url, reconnectAttempts, reconnectInterval, onOpen, onClose, onError]);
|
||||||
|
|
||||||
|
const send = useCallback((data: any) => {
|
||||||
|
if (wsRef.current?.readyState === WebSocket.OPEN) {
|
||||||
|
wsRef.current.send(JSON.stringify(data));
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const close = useCallback(() => {
|
||||||
|
if (reconnectTimeoutRef.current) {
|
||||||
|
clearTimeout(reconnectTimeoutRef.current);
|
||||||
|
}
|
||||||
|
if (wsRef.current) {
|
||||||
|
wsRef.current.close();
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (url) {
|
||||||
|
connect();
|
||||||
|
}
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
close();
|
||||||
|
};
|
||||||
|
}, [url, connect, close]);
|
||||||
|
|
||||||
|
return { data, connectionState, send, close };
|
||||||
|
}
|
||||||
35
frontend/src/api/index.ts
Normal file
35
frontend/src/api/index.ts
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
// 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';
|
||||||
96
frontend/src/api/interceptors.ts
Normal file
96
frontend/src/api/interceptors.ts
Normal file
@@ -0,0 +1,96 @@
|
|||||||
|
// 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);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
98
frontend/src/api/services/authApi.ts
Normal file
98
frontend/src/api/services/authApi.ts
Normal file
@@ -0,0 +1,98 @@
|
|||||||
|
// 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
53
frontend/src/api/services/dataApi.ts
Normal file
53
frontend/src/api/services/dataApi.ts
Normal file
@@ -0,0 +1,53 @@
|
|||||||
|
// 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');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
48
frontend/src/api/services/forecastingApi.ts
Normal file
48
frontend/src/api/services/forecastingApi.ts
Normal file
@@ -0,0 +1,48 @@
|
|||||||
|
// 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 },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
70
frontend/src/api/services/salesApi.ts
Normal file
70
frontend/src/api/services/salesApi.ts
Normal file
@@ -0,0 +1,70 @@
|
|||||||
|
// 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');
|
||||||
|
}
|
||||||
|
}
|
||||||
41
frontend/src/api/services/tenantApi.ts
Normal file
41
frontend/src/api/services/tenantApi.ts
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
// 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');
|
||||||
|
}
|
||||||
|
}
|
||||||
62
frontend/src/api/services/trainingApi.ts
Normal file
62
frontend/src/api/services/trainingApi.ts
Normal file
@@ -0,0 +1,62 @@
|
|||||||
|
// 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
166
frontend/src/components/charts/ForecastChart.tsx
Normal file
166
frontend/src/components/charts/ForecastChart.tsx
Normal file
@@ -0,0 +1,166 @@
|
|||||||
|
// ForecastChart.tsx (Modified)
|
||||||
|
import React from 'react';
|
||||||
|
import {
|
||||||
|
Chart as ChartJS,
|
||||||
|
CategoryScale,
|
||||||
|
LinearScale,
|
||||||
|
PointElement,
|
||||||
|
LineElement,
|
||||||
|
Title,
|
||||||
|
Tooltip,
|
||||||
|
Legend,
|
||||||
|
Filler,
|
||||||
|
} from 'chart.js';
|
||||||
|
import { Line } from 'react-chartjs-2';
|
||||||
|
import { format } from 'date-fns';
|
||||||
|
import { es } from 'date-fns/locale';
|
||||||
|
|
||||||
|
ChartJS.register(
|
||||||
|
CategoryScale,
|
||||||
|
LinearScale,
|
||||||
|
PointElement,
|
||||||
|
LineElement,
|
||||||
|
Title,
|
||||||
|
Tooltip,
|
||||||
|
Legend,
|
||||||
|
Filler
|
||||||
|
);
|
||||||
|
|
||||||
|
interface ForecastData {
|
||||||
|
date: string;
|
||||||
|
predicted_quantity: number;
|
||||||
|
confidence_lower: number;
|
||||||
|
confidence_upper: number;
|
||||||
|
actual_quantity?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ForecastChartProps {
|
||||||
|
data: ForecastData[];
|
||||||
|
productName: string;
|
||||||
|
// height?: number; // Removed fixed height prop
|
||||||
|
}
|
||||||
|
|
||||||
|
const ForecastChart: React.FC<ForecastChartProps> = ({ data, productName /*, height = 400*/ }) => { // Removed height from props
|
||||||
|
const chartData = {
|
||||||
|
labels: data.map(d => format(new Date(d.date), 'dd MMM', { locale: es })),
|
||||||
|
datasets: [
|
||||||
|
{
|
||||||
|
label: 'Predicción',
|
||||||
|
data: data.map(d => d.predicted_quantity),
|
||||||
|
borderColor: 'rgb(59, 130, 246)',
|
||||||
|
backgroundColor: 'rgba(59, 130, 246, 0.1)',
|
||||||
|
borderWidth: 2,
|
||||||
|
tension: 0.1,
|
||||||
|
pointRadius: 4,
|
||||||
|
pointHoverRadius: 6,
|
||||||
|
fill: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Intervalo Inferior',
|
||||||
|
data: data.map(d => d.confidence_lower),
|
||||||
|
borderColor: 'rgba(59, 130, 246, 0.3)',
|
||||||
|
backgroundColor: 'transparent',
|
||||||
|
borderDash: [5, 5],
|
||||||
|
pointRadius: 0,
|
||||||
|
tension: 0.1,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Intervalo Superior',
|
||||||
|
data: data.map(d => d.confidence_upper),
|
||||||
|
borderColor: 'rgba(59, 130, 246, 0.3)',
|
||||||
|
backgroundColor: 'transparent',
|
||||||
|
borderDash: [5, 5],
|
||||||
|
pointRadius: 0,
|
||||||
|
tension: 0.1,
|
||||||
|
},
|
||||||
|
// Optional: Actual quantity if available
|
||||||
|
...(data[0]?.actual_quantity !== undefined && data.some(d => d.actual_quantity !== undefined) ? [{
|
||||||
|
label: 'Real',
|
||||||
|
data: data.map(d => d.actual_quantity),
|
||||||
|
borderColor: 'rgb(255, 99, 132)',
|
||||||
|
backgroundColor: 'rgba(255, 99, 132, 0.1)',
|
||||||
|
borderWidth: 2,
|
||||||
|
tension: 0.1,
|
||||||
|
pointRadius: 4,
|
||||||
|
pointHoverRadius: 6,
|
||||||
|
hidden: true, // Initially hidden, can be toggled
|
||||||
|
}] : []),
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
const chartOptions = {
|
||||||
|
responsive: true,
|
||||||
|
maintainAspectRatio: false, // Ensures the chart fills its parent container's dimensions
|
||||||
|
plugins: {
|
||||||
|
legend: {
|
||||||
|
position: 'top' as const,
|
||||||
|
labels: {
|
||||||
|
font: {
|
||||||
|
size: 12,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
title: {
|
||||||
|
display: true,
|
||||||
|
text: `Predicción de Demanda - ${productName}`,
|
||||||
|
font: {
|
||||||
|
size: 16,
|
||||||
|
weight: 'bold' as const,
|
||||||
|
},
|
||||||
|
padding: {
|
||||||
|
bottom: 20,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
tooltip: {
|
||||||
|
mode: 'index' as const,
|
||||||
|
intersect: false,
|
||||||
|
callbacks: {
|
||||||
|
label: function(context: any) {
|
||||||
|
const label = context.dataset.label || '';
|
||||||
|
const value = context.parsed.y;
|
||||||
|
if (value !== null && value !== undefined) {
|
||||||
|
return `${label}: ${Math.round(value)} unidades`;
|
||||||
|
}
|
||||||
|
return '';
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
scales: {
|
||||||
|
x: {
|
||||||
|
grid: {
|
||||||
|
display: false,
|
||||||
|
},
|
||||||
|
title: {
|
||||||
|
display: true,
|
||||||
|
text: 'Fecha',
|
||||||
|
font: {
|
||||||
|
size: 14,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
y: {
|
||||||
|
beginAtZero: true,
|
||||||
|
grid: {
|
||||||
|
color: 'rgba(0, 0, 0, 0.05)',
|
||||||
|
},
|
||||||
|
title: {
|
||||||
|
display: true,
|
||||||
|
text: 'Cantidad (unidades)',
|
||||||
|
font: {
|
||||||
|
size: 14,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
interaction: {
|
||||||
|
mode: 'nearest' as const,
|
||||||
|
axis: 'x' as const,
|
||||||
|
intersect: false,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
return <Line data={chartData} options={chartOptions} />;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default ForecastChart;
|
||||||
31
frontend/src/components/common/PrivateRoute.tsx
Normal file
31
frontend/src/components/common/PrivateRoute.tsx
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
"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;
|
||||||
109
frontend/src/components/common/ProductSelector.tsx
Normal file
109
frontend/src/components/common/ProductSelector.tsx
Normal file
@@ -0,0 +1,109 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { Fragment } from 'react';
|
||||||
|
import { Listbox, Transition } from '@headlessui/react';
|
||||||
|
import { CheckIcon, ChevronUpDownIcon } from '@heroicons/react/20/solid';
|
||||||
|
|
||||||
|
interface Product {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
displayName: string;
|
||||||
|
icon?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ProductSelectorProps {
|
||||||
|
products: Product[];
|
||||||
|
selected: Product;
|
||||||
|
onChange: (product: Product) => void;
|
||||||
|
label?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const ProductSelector: React.FC<ProductSelectorProps> = ({
|
||||||
|
products,
|
||||||
|
selected,
|
||||||
|
onChange,
|
||||||
|
label = 'Seleccionar Producto',
|
||||||
|
}) => {
|
||||||
|
return (
|
||||||
|
<div className="w-full">
|
||||||
|
{label && (
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||||
|
{label}
|
||||||
|
</label>
|
||||||
|
)}
|
||||||
|
<Listbox value={selected} onChange={onChange}>
|
||||||
|
<div className="relative">
|
||||||
|
<Listbox.Button className="relative w-full cursor-default rounded-lg bg-white py-2 pl-3 pr-10 text-left shadow-md focus:outline-none focus-visible:border-orange-500 focus-visible:ring-2 focus-visible:ring-white focus-visible:ring-opacity-75 focus-visible:ring-offset-2 focus-visible:ring-offset-orange-300 sm:text-sm">
|
||||||
|
<span className="flex items-center">
|
||||||
|
{selected.icon && (
|
||||||
|
<span className="mr-2 text-lg">{selected.icon}</span>
|
||||||
|
)}
|
||||||
|
<span className="block truncate">{selected.displayName}</span>
|
||||||
|
</span>
|
||||||
|
<span className="pointer-events-none absolute inset-y-0 right-0 flex items-center pr-2">
|
||||||
|
<ChevronUpDownIcon
|
||||||
|
className="h-5 w-5 text-gray-400"
|
||||||
|
aria-hidden="true"
|
||||||
|
/>
|
||||||
|
</span>
|
||||||
|
</Listbox.Button>
|
||||||
|
<Transition
|
||||||
|
as={Fragment}
|
||||||
|
leave="transition ease-in duration-100"
|
||||||
|
leaveFrom="opacity-100"
|
||||||
|
leaveTo="opacity-0"
|
||||||
|
>
|
||||||
|
<Listbox.Options className="absolute z-10 mt-1 max-h-60 w-full overflow-auto rounded-md bg-white py-1 text-base shadow-lg ring-1 ring-black ring-opacity-5 focus:outline-none sm:text-sm">
|
||||||
|
{products.map((product) => (
|
||||||
|
<Listbox.Option
|
||||||
|
key={product.id}
|
||||||
|
className={({ active }) =>
|
||||||
|
`relative cursor-default select-none py-2 pl-10 pr-4 ${
|
||||||
|
active ? 'bg-orange-100 text-orange-900' : 'text-gray-900'
|
||||||
|
}`
|
||||||
|
}
|
||||||
|
value={product}
|
||||||
|
>
|
||||||
|
{({ selected }) => (
|
||||||
|
<>
|
||||||
|
<span className="flex items-center">
|
||||||
|
{product.icon && (
|
||||||
|
<span className="mr-2 text-lg">{product.icon}</span>
|
||||||
|
)}
|
||||||
|
<span
|
||||||
|
className={`block truncate ${
|
||||||
|
selected ? 'font-medium' : 'font-normal'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{product.displayName}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
{selected ? (
|
||||||
|
<span className="absolute inset-y-0 left-0 flex items-center pl-3 text-orange-600">
|
||||||
|
<CheckIcon className="h-5 w-5" aria-hidden="true" />
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Listbox.Option>
|
||||||
|
))}
|
||||||
|
</Listbox.Options>
|
||||||
|
</Transition>
|
||||||
|
</div>
|
||||||
|
</Listbox>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Export default products list
|
||||||
|
export const defaultProducts: Product[] = [
|
||||||
|
{ id: 'pan', name: 'pan', displayName: 'Pan', icon: '🍞' },
|
||||||
|
{ id: 'croissant', name: 'croissant', displayName: 'Croissant', icon: '🥐' },
|
||||||
|
{ id: 'napolitana', name: 'napolitana', displayName: 'Napolitana', icon: '🥮' },
|
||||||
|
{ id: 'palmera', name: 'palmera', displayName: 'Palmera', icon: '🍪' },
|
||||||
|
{ id: 'cafe', name: 'cafe', displayName: 'Café', icon: '☕' },
|
||||||
|
{ id: 'bocadillo', name: 'bocadillo', displayName: 'Bocadillo', icon: '🥖' },
|
||||||
|
{ id: 'tarta', name: 'tarta', displayName: 'Tarta', icon: '🎂' },
|
||||||
|
{ id: 'donut', name: 'donut', displayName: 'Donut', icon: '🍩' },
|
||||||
|
];
|
||||||
|
|
||||||
|
export default ProductSelector;
|
||||||
109
frontend/src/contexts/AuthContext.tsx
Normal file
109
frontend/src/contexts/AuthContext.tsx
Normal file
@@ -0,0 +1,109 @@
|
|||||||
|
import React, { createContext, useState, useContext, useEffect } from 'react';
|
||||||
|
import api from '../api/api';
|
||||||
|
import { useRouter } from 'next/router';
|
||||||
|
import axios from 'axios';
|
||||||
|
|
||||||
|
interface User {
|
||||||
|
id: string;
|
||||||
|
email: string;
|
||||||
|
full_name: string;
|
||||||
|
tenant_id: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Tenant {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
subdomain: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface AuthContextType {
|
||||||
|
user: User | null;
|
||||||
|
tenant: Tenant | null;
|
||||||
|
login: (email: string, password: string) => Promise<void>;
|
||||||
|
logout: () => void;
|
||||||
|
loading: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const AuthContext = createContext<AuthContextType | undefined>(undefined);
|
||||||
|
|
||||||
|
export const useAuth = () => {
|
||||||
|
const context = useContext(AuthContext);
|
||||||
|
if (!context) {
|
||||||
|
throw new Error('useAuth must be used within an AuthProvider');
|
||||||
|
}
|
||||||
|
return context;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const AuthProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => {
|
||||||
|
const [user, setUser] = useState<User | null>(null);
|
||||||
|
const [tenant, setTenant] = useState<Tenant | null>(null);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const router = useRouter();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const token = localStorage.getItem('access_token');
|
||||||
|
if (token) {
|
||||||
|
loadUserData();
|
||||||
|
} else {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const loadUserData = async () => {
|
||||||
|
try {
|
||||||
|
const response = await api.get('/auth/users/me');
|
||||||
|
setUser(response.data.user);
|
||||||
|
setTenant(response.data.tenant);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to load user data:', error);
|
||||||
|
localStorage.removeItem('access_token');
|
||||||
|
localStorage.removeItem('tenant_id');
|
||||||
|
setUser(null);
|
||||||
|
setTenant(null);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const login = async (email: string, password: string) => {
|
||||||
|
try {
|
||||||
|
// Create form data for OAuth2PasswordRequestForm
|
||||||
|
const formData = new URLSearchParams();
|
||||||
|
formData.append('username', email);
|
||||||
|
formData.append('password', password);
|
||||||
|
|
||||||
|
// Make login request with correct content type
|
||||||
|
const response = await axios.post(
|
||||||
|
`${process.env.NEXT_PUBLIC_API_URL || 'http://localhost:8000'}/api/v1/auth/token`,
|
||||||
|
formData,
|
||||||
|
{
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/x-www-form-urlencoded',
|
||||||
|
},
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
localStorage.setItem('access_token', response.data.access_token);
|
||||||
|
localStorage.setItem('tenant_id', response.data.tenant_id);
|
||||||
|
|
||||||
|
await loadUserData();
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Login failed:', error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const logout = () => {
|
||||||
|
localStorage.removeItem('access_token');
|
||||||
|
localStorage.removeItem('tenant_id');
|
||||||
|
setUser(null);
|
||||||
|
setTenant(null);
|
||||||
|
router.push('/login');
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AuthContext.Provider value={{ user, tenant, login, logout, loading }}>
|
||||||
|
{children}
|
||||||
|
</AuthContext.Provider>
|
||||||
|
);
|
||||||
|
};
|
||||||
12
frontend/src/pages/_app.tsx
Normal file
12
frontend/src/pages/_app.tsx
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
import { AuthProvider } from '../api';
|
||||||
|
import '../styles/globals.css';
|
||||||
|
|
||||||
|
function App({ Component, pageProps }: any) {
|
||||||
|
return (
|
||||||
|
<AuthProvider>
|
||||||
|
<Component {...pageProps} />
|
||||||
|
</AuthProvider>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default App;
|
||||||
706
frontend/src/pages/dashboard/index.tsx
Normal file
706
frontend/src/pages/dashboard/index.tsx
Normal file
@@ -0,0 +1,706 @@
|
|||||||
|
// frontend/src/pages/dashboard/index.tsx (Fixed version)
|
||||||
|
import React, { useState, useEffect, useMemo } from 'react';
|
||||||
|
import { useAuth } from '../../contexts/AuthContext';
|
||||||
|
import ForecastChart from '../../components/charts/ForecastChart';
|
||||||
|
import dashboardApi from '../../api/dashboardApi';
|
||||||
|
import {
|
||||||
|
ChartBarIcon,
|
||||||
|
CalendarIcon,
|
||||||
|
CloudIcon,
|
||||||
|
TruckIcon,
|
||||||
|
ExclamationTriangleIcon,
|
||||||
|
CheckCircleIcon,
|
||||||
|
ArrowTrendingUpIcon,
|
||||||
|
ArrowTrendingDownIcon,
|
||||||
|
CogIcon,
|
||||||
|
BellIcon,
|
||||||
|
UserCircleIcon,
|
||||||
|
Bars3Icon,
|
||||||
|
XMarkIcon,
|
||||||
|
ArrowPathIcon
|
||||||
|
} from '@heroicons/react/24/outline';
|
||||||
|
import {
|
||||||
|
Chart as ChartJS,
|
||||||
|
CategoryScale,
|
||||||
|
LinearScale,
|
||||||
|
PointElement,
|
||||||
|
LineElement,
|
||||||
|
Title,
|
||||||
|
Tooltip,
|
||||||
|
Legend,
|
||||||
|
BarElement,
|
||||||
|
} from 'chart.js';
|
||||||
|
import { Line } from 'react-chartjs-2';
|
||||||
|
import { format, subDays } from 'date-fns';
|
||||||
|
import { es } from 'date-fns/locale';
|
||||||
|
|
||||||
|
ChartJS.register(
|
||||||
|
CategoryScale,
|
||||||
|
LinearScale,
|
||||||
|
PointElement,
|
||||||
|
LineElement,
|
||||||
|
Title,
|
||||||
|
Tooltip,
|
||||||
|
Legend,
|
||||||
|
BarElement
|
||||||
|
);
|
||||||
|
|
||||||
|
interface SalesRecord {
|
||||||
|
id: string;
|
||||||
|
product_name: string;
|
||||||
|
quantity_sold: number;
|
||||||
|
revenue: number;
|
||||||
|
sale_date: string;
|
||||||
|
created_at: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ForecastRecord {
|
||||||
|
date: string;
|
||||||
|
product_name: string;
|
||||||
|
predicted_quantity: number;
|
||||||
|
confidence_lower: number;
|
||||||
|
confidence_upper: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
const Dashboard: React.FC = () => {
|
||||||
|
const { user, tenant, logout } = useAuth();
|
||||||
|
const [currentDate] = useState(new Date());
|
||||||
|
const [isSidebarOpen, setIsSidebarOpen] = useState(false);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [retryCount, setRetryCount] = useState(0);
|
||||||
|
|
||||||
|
const [salesHistory, setSalesHistory] = useState<SalesRecord[]>([]);
|
||||||
|
const [panForecast, setPanForecast] = useState<ForecastRecord[]>([]);
|
||||||
|
const [croissantForecast, setCroissantForecast] = useState<ForecastRecord[]>([]);
|
||||||
|
const [cafeForecast, setCafeForecast] = useState<ForecastRecord[]>([]);
|
||||||
|
const [bocadilloForecast, setBocadilloForecast] = useState<ForecastRecord[]>([]);
|
||||||
|
|
||||||
|
const fetchDashboardData = async (skipLoading = false) => {
|
||||||
|
if (!skipLoading) {
|
||||||
|
setLoading(true);
|
||||||
|
}
|
||||||
|
setError(null);
|
||||||
|
|
||||||
|
try {
|
||||||
|
console.log('Fetching dashboard data...');
|
||||||
|
|
||||||
|
const endDate = format(currentDate, 'yyyy-MM-dd');
|
||||||
|
const startDate = format(subDays(currentDate, 30), 'yyyy-MM-dd'); // Fetch 30 days instead of 7
|
||||||
|
|
||||||
|
console.log('Fetching sales history from', startDate, 'to', endDate);
|
||||||
|
|
||||||
|
// Fetch sales history
|
||||||
|
const fetchedSales = await dashboardApi.getSalesHistory(startDate, endDate);
|
||||||
|
console.log('Fetched sales:', fetchedSales);
|
||||||
|
setSalesHistory(fetchedSales || []);
|
||||||
|
|
||||||
|
// Fetch forecasts for each product
|
||||||
|
const products = ['Pan', 'Croissant', 'Cafe', 'Bocadillo'];
|
||||||
|
|
||||||
|
for (const product of products) {
|
||||||
|
try {
|
||||||
|
console.log(`Fetching forecast for ${product}`);
|
||||||
|
const forecast = await dashboardApi.getProductForecast(product, 14);
|
||||||
|
console.log(`Forecast for ${product}:`, forecast);
|
||||||
|
|
||||||
|
switch (product) {
|
||||||
|
case 'Pan':
|
||||||
|
setPanForecast(forecast || []);
|
||||||
|
break;
|
||||||
|
case 'Croissant':
|
||||||
|
setCroissantForecast(forecast || []);
|
||||||
|
break;
|
||||||
|
case 'Cafe':
|
||||||
|
setCafeForecast(forecast || []);
|
||||||
|
break;
|
||||||
|
case 'Bocadillo':
|
||||||
|
setBocadilloForecast(forecast || []);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
} catch (forecastError) {
|
||||||
|
console.error(`Error fetching forecast for ${product}:`, forecastError);
|
||||||
|
// Continue with other products if one fails
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('Dashboard data loaded successfully');
|
||||||
|
setRetryCount(0);
|
||||||
|
|
||||||
|
} catch (err: any) {
|
||||||
|
console.error("Error fetching dashboard data:", err);
|
||||||
|
|
||||||
|
let errorMessage = "No se pudieron cargar los datos del dashboard.";
|
||||||
|
|
||||||
|
if (err.response?.status === 401) {
|
||||||
|
errorMessage = "Sesión expirada. Por favor, inicia sesión nuevamente.";
|
||||||
|
// Optionally logout the user
|
||||||
|
// logout();
|
||||||
|
} else if (err.response?.status === 403) {
|
||||||
|
errorMessage = "No tienes permisos para acceder a estos datos.";
|
||||||
|
} else if (err.response?.status === 404) {
|
||||||
|
errorMessage = "No se encontraron datos. Esto puede ser normal para nuevos usuarios.";
|
||||||
|
} else if (err.response?.status >= 500) {
|
||||||
|
errorMessage = "Error del servidor. Por favor, inténtalo más tarde.";
|
||||||
|
} else if (err.code === 'NETWORK_ERROR' || !err.response) {
|
||||||
|
errorMessage = "Error de conexión. Verifica tu conexión a internet.";
|
||||||
|
}
|
||||||
|
|
||||||
|
setError(errorMessage);
|
||||||
|
|
||||||
|
// Auto-retry logic for temporary network errors
|
||||||
|
if (retryCount < 3 && (!err.response || err.response.status >= 500)) {
|
||||||
|
console.log(`Retrying in 2 seconds... (attempt ${retryCount + 1}/3)`);
|
||||||
|
setTimeout(() => {
|
||||||
|
setRetryCount(prev => prev + 1);
|
||||||
|
fetchDashboardData(true);
|
||||||
|
}, 2000);
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleRetry = () => {
|
||||||
|
setRetryCount(0);
|
||||||
|
fetchDashboardData();
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (user && tenant) {
|
||||||
|
console.log('User and tenant loaded, fetching dashboard data');
|
||||||
|
fetchDashboardData();
|
||||||
|
} else {
|
||||||
|
console.log('User or tenant not available yet');
|
||||||
|
}
|
||||||
|
}, [user, tenant]);
|
||||||
|
|
||||||
|
const salesChartData = useMemo(() => {
|
||||||
|
if (!salesHistory.length) {
|
||||||
|
return {
|
||||||
|
labels: [],
|
||||||
|
datasets: []
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const salesByDateAndProduct: { [date: string]: { [product: string]: number } } = {};
|
||||||
|
salesHistory.forEach(sale => {
|
||||||
|
const date = format(new Date(sale.sale_date), 'yyyy-MM-dd');
|
||||||
|
if (!salesByDateAndProduct[date]) {
|
||||||
|
salesByDateAndProduct[date] = {};
|
||||||
|
}
|
||||||
|
salesByDateAndProduct[date][sale.product_name] = (salesByDateAndProduct[date][sale.product_name] || 0) + sale.quantity_sold;
|
||||||
|
});
|
||||||
|
|
||||||
|
const uniqueDates = Object.keys(salesByDateAndProduct).sort();
|
||||||
|
const allProductNames = Array.from(new Set(salesHistory.map(s => s.product_name)));
|
||||||
|
|
||||||
|
const datasets = allProductNames.map(productName => {
|
||||||
|
const productData = uniqueDates.map(date => salesByDateAndProduct[date][productName] || 0);
|
||||||
|
|
||||||
|
let borderColor = '';
|
||||||
|
let backgroundColor = '';
|
||||||
|
switch(productName.toLowerCase()) {
|
||||||
|
case 'pan':
|
||||||
|
borderColor = 'rgb(255, 99, 132)';
|
||||||
|
backgroundColor = 'rgba(255, 99, 132, 0.5)';
|
||||||
|
break;
|
||||||
|
case 'croissant':
|
||||||
|
borderColor = 'rgb(53, 162, 235)';
|
||||||
|
backgroundColor = 'rgba(53, 162, 235, 0.5)';
|
||||||
|
break;
|
||||||
|
case 'cafe':
|
||||||
|
borderColor = 'rgb(75, 192, 192)';
|
||||||
|
backgroundColor = 'rgba(75, 192, 192, 0.5)';
|
||||||
|
break;
|
||||||
|
case 'bocadillo':
|
||||||
|
borderColor = 'rgb(153, 102, 255)';
|
||||||
|
backgroundColor = 'rgba(153, 102, 255, 0.5)';
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
borderColor = `hsl(${Math.random() * 360}, 70%, 50%)`;
|
||||||
|
backgroundColor = `hsla(${Math.random() * 360}, 70%, 50%, 0.5)`;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
label: `Ventas de ${productName}`,
|
||||||
|
data: productData,
|
||||||
|
borderColor,
|
||||||
|
backgroundColor,
|
||||||
|
tension: 0.1,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
labels: uniqueDates.map(d => format(new Date(d), 'dd MMM', { locale: es })),
|
||||||
|
datasets: datasets,
|
||||||
|
};
|
||||||
|
}, [salesHistory]);
|
||||||
|
|
||||||
|
const demoForecastData = useMemo(() => {
|
||||||
|
if (!panForecast.length) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
return panForecast.map(f => ({
|
||||||
|
date: format(new Date(f.date), 'yyyy-MM-dd'),
|
||||||
|
predicted_quantity: f.predicted_quantity,
|
||||||
|
confidence_lower: f.confidence_lower,
|
||||||
|
confidence_upper: f.confidence_upper,
|
||||||
|
}));
|
||||||
|
}, [panForecast]);
|
||||||
|
|
||||||
|
const productPredictions = useMemo(() => {
|
||||||
|
const today = format(currentDate, 'yyyy-MM-dd');
|
||||||
|
const todaySales = salesHistory.filter(s => format(new Date(s.sale_date), 'yyyy-MM-dd') === today);
|
||||||
|
|
||||||
|
const currentSalesByProduct: { [product: string]: number } = {};
|
||||||
|
todaySales.forEach(sale => {
|
||||||
|
currentSalesByProduct[sale.product_name] = (currentSalesByProduct[sale.product_name] || 0) + sale.quantity_sold;
|
||||||
|
});
|
||||||
|
|
||||||
|
const allForecasts = [
|
||||||
|
...panForecast,
|
||||||
|
...croissantForecast,
|
||||||
|
...cafeForecast,
|
||||||
|
...bocadilloForecast
|
||||||
|
];
|
||||||
|
|
||||||
|
if (!allForecasts.length) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
const uniqueProductsInForecasts = Array.from(new Set(allForecasts.map(f => f.product_name)));
|
||||||
|
|
||||||
|
const predictions = uniqueProductsInForecasts.map((productName, index) => {
|
||||||
|
const productTodayForecast = allForecasts.find(f =>
|
||||||
|
f.product_name === productName && format(new Date(f.date), 'yyyy-MM-dd') === today
|
||||||
|
);
|
||||||
|
|
||||||
|
const predicted = productTodayForecast?.predicted_quantity || 0;
|
||||||
|
const current = currentSalesByProduct[productName] || 0;
|
||||||
|
|
||||||
|
let status: 'good' | 'warning' | 'bad' = 'good';
|
||||||
|
if (predicted > 0) {
|
||||||
|
const percentageAchieved = (current / predicted) * 100;
|
||||||
|
if (percentageAchieved < 50) {
|
||||||
|
status = 'bad';
|
||||||
|
} else if (percentageAchieved < 90) {
|
||||||
|
status = 'warning';
|
||||||
|
}
|
||||||
|
} else if (current > 0) {
|
||||||
|
status = 'good';
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: index + 1,
|
||||||
|
product: productName,
|
||||||
|
predicted: Math.round(predicted),
|
||||||
|
current: current,
|
||||||
|
status: status,
|
||||||
|
};
|
||||||
|
}).filter(p => p.predicted > 0 || p.current > 0);
|
||||||
|
|
||||||
|
return predictions;
|
||||||
|
}, [salesHistory, panForecast, croissantForecast, cafeForecast, bocadilloForecast, currentDate]);
|
||||||
|
|
||||||
|
const kpiData = useMemo(() => {
|
||||||
|
if (!salesHistory.length) {
|
||||||
|
return {
|
||||||
|
totalSalesToday: 0,
|
||||||
|
salesChange: 0,
|
||||||
|
totalProductsSoldToday: 0,
|
||||||
|
productsSoldChange: 0,
|
||||||
|
wasteToday: 0,
|
||||||
|
wasteChange: 0,
|
||||||
|
totalPredictedValueToday: 0,
|
||||||
|
predictedValueChange: 0,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const today = format(currentDate, 'yyyy-MM-dd');
|
||||||
|
const yesterday = format(subDays(currentDate, 1), 'yyyy-MM-dd');
|
||||||
|
|
||||||
|
const salesToday = salesHistory.filter(s => format(new Date(s.sale_date), 'yyyy-MM-dd') === today);
|
||||||
|
const salesYesterday = salesHistory.filter(s => format(new Date(s.sale_date), 'yyyy-MM-dd') === yesterday);
|
||||||
|
|
||||||
|
const totalSalesToday = salesToday.reduce((sum, s) => sum + s.revenue, 0);
|
||||||
|
const totalProductsSoldToday = salesToday.reduce((sum, s) => sum + s.quantity_sold, 0);
|
||||||
|
const totalSalesYesterday = salesYesterday.reduce((sum, s) => sum + s.revenue, 0);
|
||||||
|
const totalProductsSoldYesterday = salesYesterday.reduce((sum, s) => sum + s.quantity_sold, 0);
|
||||||
|
|
||||||
|
const wasteToday = 15; // Mock data
|
||||||
|
const wasteLastWeek = 15.3; // Mock data
|
||||||
|
|
||||||
|
const salesChange = totalSalesYesterday > 0 ? ((totalSalesToday - totalSalesYesterday) / totalSalesYesterday) * 100 : (totalSalesToday > 0 ? 100 : 0);
|
||||||
|
const productsSoldChange = totalProductsSoldYesterday > 0 ? ((totalProductsSoldToday - totalProductsSoldYesterday) / totalProductsSoldYesterday) * 100 : (totalProductsSoldToday > 0 ? 100 : 0);
|
||||||
|
const wasteChange = wasteLastWeek > 0 ? ((wasteToday - wasteLastWeek) / wasteLastWeek) * 100 : (wasteToday > 0 ? 100 : 0);
|
||||||
|
|
||||||
|
const totalPredictedValueToday = productPredictions.reduce((sum, p) => sum + p.predicted, 0) * 1.5;
|
||||||
|
const predictedValueChange = totalPredictedValueToday > 0 ? ((totalSalesToday - totalPredictedValueToday) / totalPredictedValueToday) * 100 : (totalSalesToday > 0 ? 100 : 0);
|
||||||
|
|
||||||
|
return {
|
||||||
|
totalSalesToday,
|
||||||
|
salesChange,
|
||||||
|
totalProductsSoldToday,
|
||||||
|
productsSoldChange,
|
||||||
|
wasteToday,
|
||||||
|
wasteChange,
|
||||||
|
totalPredictedValueToday,
|
||||||
|
predictedValueChange,
|
||||||
|
};
|
||||||
|
}, [salesHistory, productPredictions, currentDate]);
|
||||||
|
|
||||||
|
const salesChartOptions = {
|
||||||
|
responsive: true,
|
||||||
|
maintainAspectRatio: false,
|
||||||
|
plugins: {
|
||||||
|
legend: {
|
||||||
|
position: 'top' as const,
|
||||||
|
},
|
||||||
|
title: {
|
||||||
|
display: true,
|
||||||
|
text: 'Histórico de Ventas Recientes',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
scales: {
|
||||||
|
y: {
|
||||||
|
beginAtZero: true,
|
||||||
|
title: {
|
||||||
|
display: true,
|
||||||
|
text: 'Cantidad Vendida (uds)',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
x: {
|
||||||
|
title: {
|
||||||
|
display: true,
|
||||||
|
text: 'Fecha',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen flex items-center justify-center bg-gray-100">
|
||||||
|
<div className="text-center">
|
||||||
|
<div className="animate-spin rounded-full h-16 w-16 border-t-2 border-b-2 border-orange-600 mx-auto"></div>
|
||||||
|
<p className="mt-4 text-lg text-gray-700">Cargando datos del dashboard...</p>
|
||||||
|
{retryCount > 0 && (
|
||||||
|
<p className="mt-2 text-sm text-gray-500">Reintentando... ({retryCount}/3)</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen flex items-center justify-center bg-gray-100">
|
||||||
|
<div className="text-center bg-white p-8 rounded-lg shadow-md max-w-md mx-4">
|
||||||
|
<ExclamationTriangleIcon className="h-12 w-12 text-red-500 mx-auto mb-4" />
|
||||||
|
<h2 className="text-xl font-semibold text-gray-900 mb-2">Error al cargar datos</h2>
|
||||||
|
<p className="text-gray-600 mb-6">{error}</p>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<button
|
||||||
|
onClick={handleRetry}
|
||||||
|
className="w-full bg-orange-600 text-white px-4 py-2 rounded-lg hover:bg-orange-700 transition-colors flex items-center justify-center"
|
||||||
|
>
|
||||||
|
<ArrowPathIcon className="h-5 w-5 mr-2" />
|
||||||
|
Reintentar
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => window.location.reload()}
|
||||||
|
className="w-full bg-gray-200 text-gray-800 px-4 py-2 rounded-lg hover:bg-gray-300 transition-colors"
|
||||||
|
>
|
||||||
|
Recargar página
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen bg-gray-100 flex">
|
||||||
|
{/* Overlay for mobile sidebar */}
|
||||||
|
{isSidebarOpen && (
|
||||||
|
<div
|
||||||
|
className="fixed inset-0 bg-black bg-opacity-50 z-30 lg:hidden"
|
||||||
|
onClick={() => setIsSidebarOpen(false)}
|
||||||
|
></div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Sidebar - Made responsive */}
|
||||||
|
<aside
|
||||||
|
className={`fixed inset-y-0 left-0 z-40 bg-white shadow-md overflow-y-auto transform transition-transform duration-300 ease-in-out
|
||||||
|
${isSidebarOpen ? 'translate-x-0' : '-translate-x-full'} lg:translate-x-0 lg:static lg:inset-0 w-64`}
|
||||||
|
>
|
||||||
|
<div className="p-6 border-b border-gray-200">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<h2 className="text-xl font-bold text-gray-900">PanIA</h2>
|
||||||
|
<button
|
||||||
|
className="lg:hidden p-2"
|
||||||
|
onClick={() => setIsSidebarOpen(false)}
|
||||||
|
>
|
||||||
|
<XMarkIcon className="h-6 w-6 text-gray-500" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<p className="text-sm text-gray-600 mt-1">{tenant?.name}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<nav className="mt-6">
|
||||||
|
<div className="px-6 py-3">
|
||||||
|
<div className="flex items-center text-orange-600">
|
||||||
|
<ChartBarIcon className="h-5 w-5 mr-3" />
|
||||||
|
<span className="font-medium">Dashboard</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-8 px-6">
|
||||||
|
<h3 className="text-xs font-semibold text-gray-500 uppercase tracking-wide">
|
||||||
|
Herramientas
|
||||||
|
</h3>
|
||||||
|
<div className="mt-2 space-y-1">
|
||||||
|
<div className="flex items-center text-gray-700 hover:bg-gray-100 rounded-lg p-2 cursor-pointer">
|
||||||
|
<CalendarIcon className="h-5 w-5 mr-3" />
|
||||||
|
<span className="text-sm">Pronósticos</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center text-gray-700 hover:bg-gray-100 rounded-lg p-2 cursor-pointer">
|
||||||
|
<CloudIcon className="h-5 w-5 mr-3" />
|
||||||
|
<span className="text-sm">Datos Climáticos</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center text-gray-700 hover:bg-gray-100 rounded-lg p-2 cursor-pointer">
|
||||||
|
<TruckIcon className="h-5 w-5 mr-3" />
|
||||||
|
<span className="text-sm">Pedidos</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-8 px-6">
|
||||||
|
<h3 className="text-xs font-semibold text-gray-500 uppercase tracking-wide">
|
||||||
|
Configuración
|
||||||
|
</h3>
|
||||||
|
<div className="mt-2 space-y-1">
|
||||||
|
<div className="flex items-center text-gray-700 hover:bg-gray-100 rounded-lg p-2 cursor-pointer">
|
||||||
|
<CogIcon className="h-5 w-5 mr-3" />
|
||||||
|
<span className="text-sm">Ajustes</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center text-gray-700 hover:bg-gray-100 rounded-lg p-2 cursor-pointer">
|
||||||
|
<UserCircleIcon className="h-5 w-5 mr-3" />
|
||||||
|
<span className="text-sm">Perfil</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<div className="absolute bottom-0 w-full p-6 border-t border-gray-200">
|
||||||
|
<div className="flex items-center">
|
||||||
|
<div className="w-8 h-8 bg-orange-600 rounded-full flex items-center justify-center">
|
||||||
|
<span className="text-white text-sm font-medium">
|
||||||
|
{user?.full_name?.charAt(0)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="ml-3">
|
||||||
|
<p className="text-sm font-medium text-gray-900">{user?.full_name}</p>
|
||||||
|
<p className="text-xs text-gray-500">{user?.email}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={logout}
|
||||||
|
className="mt-3 w-full text-left text-sm text-red-600 hover:text-red-700"
|
||||||
|
>
|
||||||
|
Cerrar sesión
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
|
||||||
|
{/* Main content */}
|
||||||
|
<div className="flex-1 flex flex-col overflow-hidden">
|
||||||
|
{/* Header */}
|
||||||
|
<header className="bg-white shadow-sm border-b border-gray-200 flex items-center justify-between px-4 py-3">
|
||||||
|
<button
|
||||||
|
className="lg:hidden p-2"
|
||||||
|
onClick={() => setIsSidebarOpen(true)}
|
||||||
|
>
|
||||||
|
<Bars3Icon className="h-6 w-6 text-gray-500" />
|
||||||
|
</button>
|
||||||
|
<h1 className="text-xl font-semibold text-gray-900">Dashboard</h1>
|
||||||
|
<div className="flex items-center space-x-4">
|
||||||
|
<span className="text-gray-600 text-sm">
|
||||||
|
{format(currentDate, 'dd MMMM yyyy', { locale: es })}
|
||||||
|
</span>
|
||||||
|
<button onClick={handleRetry} className="p-2 text-gray-500 hover:text-gray-700">
|
||||||
|
<ArrowPathIcon className="h-5 w-5" />
|
||||||
|
</button>
|
||||||
|
<BellIcon className="h-6 w-6 text-gray-500 cursor-pointer" />
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<main className="flex-1 overflow-y-auto p-4 sm:p-6 lg:p-8">
|
||||||
|
{/* Show a notice if no data is available */}
|
||||||
|
{!salesHistory.length && !loading && (
|
||||||
|
<div className="bg-blue-50 border border-blue-200 rounded-lg p-6 mb-6">
|
||||||
|
<div className="flex items-center">
|
||||||
|
<ExclamationTriangleIcon className="h-6 w-6 text-blue-600 mr-3" />
|
||||||
|
<div>
|
||||||
|
<h3 className="text-lg font-medium text-blue-900">
|
||||||
|
No hay datos disponibles
|
||||||
|
</h3>
|
||||||
|
<p className="text-blue-700 mt-1">
|
||||||
|
Parece que aún no tienes datos de ventas. Los datos se generarán automáticamente
|
||||||
|
después de completar el proceso de configuración.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* KPI Cards */}
|
||||||
|
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-6 mb-8">
|
||||||
|
<div className="bg-white rounded-lg shadow-md p-6">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<h3 className="text-lg font-medium text-gray-500">Ventas Hoy</h3>
|
||||||
|
<ChartBarIcon className="h-6 w-6 text-orange-500" />
|
||||||
|
</div>
|
||||||
|
<p className="mt-1 text-3xl font-semibold text-gray-900">
|
||||||
|
€ {kpiData.totalSalesToday.toFixed(2)}
|
||||||
|
</p>
|
||||||
|
<p className={`mt-2 text-sm flex items-center ${kpiData.salesChange >= 0 ? 'text-green-600' : 'text-red-600'}`}>
|
||||||
|
{kpiData.salesChange >= 0 ? <ArrowTrendingUpIcon className="h-4 w-4 mr-1" /> : <ArrowTrendingDownIcon className="h-4 w-4 mr-1" />}
|
||||||
|
{kpiData.salesChange.toFixed(1)}% desde ayer
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="bg-white rounded-lg shadow-md p-6">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<h3 className="text-lg font-medium text-gray-500">Productos Vendidos</h3>
|
||||||
|
<CheckCircleIcon className="h-6 w-6 text-green-500" />
|
||||||
|
</div>
|
||||||
|
<p className="mt-1 text-3xl font-semibold text-gray-900">
|
||||||
|
{kpiData.totalProductsSoldToday} uds.
|
||||||
|
</p>
|
||||||
|
<p className={`mt-2 text-sm flex items-center ${kpiData.productsSoldChange >= 0 ? 'text-green-600' : 'text-red-600'}`}>
|
||||||
|
{kpiData.productsSoldChange >= 0 ? <ArrowTrendingUpIcon className="h-4 w-4 mr-1" /> : <ArrowTrendingDownIcon className="h-4 w-4 mr-1" />}
|
||||||
|
{kpiData.productsSoldChange.toFixed(1)}% desde ayer
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="bg-white rounded-lg shadow-md p-6">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<h3 className="text-lg font-medium text-gray-500">Desperdicio</h3>
|
||||||
|
<ExclamationTriangleIcon className="h-6 w-6 text-red-500" />
|
||||||
|
</div>
|
||||||
|
<p className="mt-1 text-3xl font-semibold text-gray-900">
|
||||||
|
{kpiData.wasteToday} kg
|
||||||
|
</p>
|
||||||
|
<p className={`mt-2 text-sm flex items-center ${kpiData.wasteChange <= 0 ? 'text-green-600' : 'text-red-600'}`}>
|
||||||
|
{kpiData.wasteChange <= 0 ? <ArrowTrendingDownIcon className="h-4 w-4 mr-1" /> : <ArrowTrendingUpIcon className="h-4 w-4 mr-1" />}
|
||||||
|
{kpiData.wasteChange.toFixed(1)}% desde la semana pasada
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="bg-white rounded-lg shadow-md p-6">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<h3 className="text-lg font-medium text-gray-500">Valor Pronosticado</h3>
|
||||||
|
<ChartBarIcon className="h-6 w-6 text-blue-500" />
|
||||||
|
</div>
|
||||||
|
<p className="mt-1 text-3xl font-semibold text-gray-900">
|
||||||
|
€ {kpiData.totalPredictedValueToday.toFixed(2)}
|
||||||
|
</p>
|
||||||
|
<p className={`mt-2 text-sm flex items-center ${kpiData.predictedValueChange >= 0 ? 'text-green-600' : 'text-red-600'}`}>
|
||||||
|
{kpiData.predictedValueChange >= 0 ? <ArrowTrendingUpIcon className="h-4 w-4 mr-1" /> : <ArrowTrendingDownIcon className="h-4 w-4 mr-1" />}
|
||||||
|
{kpiData.predictedValueChange.toFixed(1)}% sobre predicción
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Charts Section */}
|
||||||
|
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6 mb-8">
|
||||||
|
<div className="bg-white rounded-lg shadow-md p-6">
|
||||||
|
<h2 className="text-xl font-semibold text-gray-900 mb-4">Ventas por Día</h2>
|
||||||
|
{salesHistory.length > 0 ? (
|
||||||
|
<div style={{ height: '350px' }}>
|
||||||
|
<Line data={salesChartData} options={salesChartOptions} />
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="h-80 flex items-center justify-center text-gray-500">
|
||||||
|
<div className="text-center">
|
||||||
|
<ChartBarIcon className="h-12 w-12 mx-auto mb-4 text-gray-400" />
|
||||||
|
<p>No hay datos de ventas disponibles</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="bg-white rounded-lg shadow-md p-6">
|
||||||
|
<h2 className="text-xl font-semibold text-gray-900 mb-4">Pronóstico de Demanda (Pan)</h2>
|
||||||
|
{demoForecastData.length > 0 ? (
|
||||||
|
<div style={{ height: '350px' }}>
|
||||||
|
<ForecastChart data={demoForecastData} productName="Pan" />
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="h-80 flex items-center justify-center text-gray-500">
|
||||||
|
<div className="text-center">
|
||||||
|
<CalendarIcon className="h-12 w-12 mx-auto mb-4 text-gray-400" />
|
||||||
|
<p>No hay pronósticos disponibles</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Product Predictions */}
|
||||||
|
<div className="bg-white rounded-lg shadow-md p-6">
|
||||||
|
<h2 className="text-xl font-semibold text-gray-900 mb-4">Predicciones de Productos Clave</h2>
|
||||||
|
{productPredictions.length > 0 ? (
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-4">
|
||||||
|
{productPredictions.map((prediction) => (
|
||||||
|
<div key={prediction.id} className="border border-gray-200 rounded-lg p-4">
|
||||||
|
<div className="flex items-center justify-between mb-2">
|
||||||
|
<h4 className="text-sm font-medium text-gray-900">{prediction.product}</h4>
|
||||||
|
<div className={`w-3 h-3 rounded-full ${
|
||||||
|
prediction.status === 'good' ? 'bg-green-400' :
|
||||||
|
prediction.status === 'warning' ? 'bg-yellow-400' : 'bg-red-400'
|
||||||
|
}`}></div>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1">
|
||||||
|
<div className="flex justify-between text-sm">
|
||||||
|
<span className="text-gray-500">Predicción:</span>
|
||||||
|
<span className="font-medium">{prediction.predicted} uds</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-between text-sm">
|
||||||
|
<span className="text-gray-500">Actual:</span>
|
||||||
|
<span className="font-medium">{prediction.current} uds</span>
|
||||||
|
</div>
|
||||||
|
<div className="w-full bg-gray-200 rounded-full h-2">
|
||||||
|
<div
|
||||||
|
className={`h-2 rounded-full ${
|
||||||
|
prediction.status === 'good' ? 'bg-green-400' :
|
||||||
|
prediction.status === 'warning' ? 'bg-yellow-400' : 'bg-red-400'
|
||||||
|
}`}
|
||||||
|
style={{
|
||||||
|
width: `${Math.min((prediction.current / prediction.predicted) * 100, 100)}%`
|
||||||
|
}}
|
||||||
|
></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="text-center py-8 text-gray-500">
|
||||||
|
<div className="text-center">
|
||||||
|
<TruckIcon className="h-12 w-12 mx-auto mb-4 text-gray-400" />
|
||||||
|
<p>No hay predicciones disponibles</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default Dashboard;
|
||||||
143
frontend/src/pages/index.tsx
Normal file
143
frontend/src/pages/index.tsx
Normal file
@@ -0,0 +1,143 @@
|
|||||||
|
import Head from 'next/head';
|
||||||
|
import Link from 'next/link';
|
||||||
|
|
||||||
|
const HomePage = () => {
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen bg-pania-white"> {/* Set overall background to PanIA white */}
|
||||||
|
<Head>
|
||||||
|
<title>PanIA - Inteligencia Artificial para tu Panadería</title> {/* Updated title and tagline */}
|
||||||
|
<meta name="description" content="La primera IA diseñada para panaderías españolas que transforma tus datos en predicciones precisas." /> {/* Updated meta description */}
|
||||||
|
</Head>
|
||||||
|
|
||||||
|
{/* Navigation Bar */}
|
||||||
|
<header className="bg-pania-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</div> {/* PanIA brand name */}
|
||||||
|
<div>
|
||||||
|
<Link href="/login" className="text-pania-blue hover:text-pania-blue-dark font-medium px-4 py-2 rounded-md">
|
||||||
|
Iniciar Sesión
|
||||||
|
</Link>
|
||||||
|
<Link href="/onboarding" className="ml-4 bg-pania-blue text-pania-white px-4 py-2 rounded-md hover:bg-pania-blue-dark transition-colors duration-200"> {/* CTA to onboarding */}
|
||||||
|
Prueba Gratis
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</nav>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<main>
|
||||||
|
{/* Hero Section */}
|
||||||
|
<section className="bg-pania-golden text-pania-white py-20 text-center"> {/* Warm Golden background */}
|
||||||
|
<div className="container mx-auto px-4">
|
||||||
|
<h1 className="text-5xl md:text-6xl font-bold leading-tight mb-4">
|
||||||
|
Inteligencia Artificial que Revoluciona tu Panadería
|
||||||
|
</h1>
|
||||||
|
<p className="text-xl md:text-2xl mb-8 max-w-3xl mx-auto">
|
||||||
|
Reduce desperdicios hasta <span className="font-bold">25%</span> y aumenta ganancias con predicciones precisas diseñadas para panaderías españolas.
|
||||||
|
</p>
|
||||||
|
<Link href="/onboarding" className="bg-pania-blue text-pania-white text-lg font-semibold px-8 py-4 rounded-lg shadow-lg hover:bg-pania-blue-dark transition-transform transform hover:scale-105">
|
||||||
|
Prueba Gratis 30 Días
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* Social Proof Section */}
|
||||||
|
<section className="py-16 bg-pania-white">
|
||||||
|
<div className="container mx-auto px-4 text-center">
|
||||||
|
<h2 className="text-3xl font-bold text-pania-charcoal mb-8">
|
||||||
|
Más de 150 panaderías confían en PanIA
|
||||||
|
</h2>
|
||||||
|
<div className="flex justify-center items-center space-x-8 mb-8">
|
||||||
|
{/* Placeholder for customer logos */}
|
||||||
|
<div className="h-16 w-32 bg-gray-200 rounded-lg flex items-center justify-center text-gray-500">Logo 1</div>
|
||||||
|
<div className="h-16 w-32 bg-gray-200 rounded-lg flex items-center justify-center text-gray-500">Logo 2</div>
|
||||||
|
<div className="h-16 w-32 bg-gray-200 rounded-lg flex items-center justify-center text-gray-500">Logo 3</div>
|
||||||
|
</div>
|
||||||
|
<p className="text-lg text-gray-600 italic">
|
||||||
|
"PanIA ha transformado completamente nuestra gestión de inventario. ¡Menos desperdicio y más beneficios!" - Panadería San Miguel, Madrid
|
||||||
|
</p>
|
||||||
|
{/* Placeholder for star ratings */}
|
||||||
|
<div className="text-2xl text-yellow-500 mt-4">★★★★★</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* Features Section - Cómo Funciona PanIA */}
|
||||||
|
<section className="bg-gray-50 py-20">
|
||||||
|
<div className="container mx-auto px-4">
|
||||||
|
<h2 className="text-4xl font-bold text-pania-charcoal text-center mb-12">
|
||||||
|
Cómo Funciona PanIA
|
||||||
|
</h2>
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-8">
|
||||||
|
<div className="bg-pania-white p-8 rounded-lg shadow-md text-center">
|
||||||
|
<div className="text-pania-blue text-5xl mb-4">📊</div> {/* Icon placeholder */}
|
||||||
|
<h3 className="text-xl font-semibold text-pania-charcoal mb-2">Conecta tus Datos</h3>
|
||||||
|
<p className="text-gray-600">Sube tus ventas históricas en 5 minutos de forma segura y sencilla.</p>
|
||||||
|
</div>
|
||||||
|
<div className="bg-pania-white p-8 rounded-lg shadow-md text-center">
|
||||||
|
<div className="text-pania-blue text-5xl mb-4">🧠</div> {/* Icon placeholder */}
|
||||||
|
<h3 className="text-xl font-semibold text-pania-charcoal mb-2">IA Entrena tu Modelo</h3>
|
||||||
|
<p className="text-gray-600">Nuestra Inteligencia Artificial aprende los patrones únicos de tu negocio y mercado local.</p>
|
||||||
|
</div>
|
||||||
|
<div className="bg-pania-white p-8 rounded-lg shadow-md text-center">
|
||||||
|
<div className="text-pania-blue text-5xl mb-4">📈</div> {/* Icon placeholder */}
|
||||||
|
<h3 className="text-xl font-semibold text-pania-charcoal mb-2">Recibe Predicciones</h3>
|
||||||
|
<p className="text-gray-600">Obtén predicciones diarias precisas automáticamente para optimizar tu producción.</p>
|
||||||
|
</div>
|
||||||
|
<div className="bg-pania-white p-8 rounded-lg shadow-md text-center">
|
||||||
|
<div className="text-pania-blue text-5xl mb-4">💰</div> {/* Icon placeholder */}
|
||||||
|
<h3 className="text-xl font-semibold text-pania-charcoal mb-2">Reduce Desperdicios</h3>
|
||||||
|
<p className="text-gray-600">Ve resultados inmediatos en tu desperdicio y un aumento significativo en tus ganancias.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* Call to Action Section */}
|
||||||
|
<section className="bg-pania-blue text-pania-white py-16 text-center"> {/* Tech Blue background */}
|
||||||
|
<div className="container mx-auto px-4">
|
||||||
|
<h2 className="text-3xl md:text-4xl font-bold mb-4">
|
||||||
|
¿Listo para transformar tu panadería?
|
||||||
|
</h2>
|
||||||
|
<p className="text-xl mb-8">
|
||||||
|
Únete a las panaderías que ya están viendo el futuro con PanIA.
|
||||||
|
</p>
|
||||||
|
<Link href="/onboarding" className="bg-pania-golden text-pania-white text-lg font-semibold px-8 py-4 rounded-lg shadow-lg hover:bg-pania-golden-dark transition-transform transform hover:scale-105"> {/* Golden CTA button */}
|
||||||
|
Comienza tu Prueba Gratis
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* Trust Signals Section */}
|
||||||
|
<section className="bg-pania-charcoal text-pania-white py-12"> {/* Charcoal background */}
|
||||||
|
<div className="container mx-auto px-4 grid grid-cols-1 md:grid-cols-3 gap-8 text-center">
|
||||||
|
<div>
|
||||||
|
<p className="font-bold text-lg mb-2">Datos seguros y protegidos</p>
|
||||||
|
<p className="text-sm">(GDPR compliant)</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="font-bold text-lg mb-2">Soporte en español</p>
|
||||||
|
<p className="text-sm">7 días a la semana</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="font-bold text-lg mb-2">Garantía de satisfacción</p>
|
||||||
|
<p className="text-sm">100%</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</main>
|
||||||
|
|
||||||
|
{/* Footer */}
|
||||||
|
<footer className="bg-gray-800 text-gray-300 py-8 text-center">
|
||||||
|
<div className="container mx-auto px-4">
|
||||||
|
<p>© {new Date().getFullYear()} PanIA. Todos los derechos reservados.</p>
|
||||||
|
<div className="mt-4 flex justify-center space-x-6">
|
||||||
|
<Link href="#" className="hover:text-white">Política de Privacidad</Link>
|
||||||
|
<Link href="#" className="hover:text-white">Términos de Servicio</Link>
|
||||||
|
<Link href="#" className="hover:text-white">Contacto</Link>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</footer>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default HomePage;
|
||||||
76
frontend/src/pages/login.tsx
Normal file
76
frontend/src/pages/login.tsx
Normal file
@@ -0,0 +1,76 @@
|
|||||||
|
import { useState } from 'react';
|
||||||
|
import { useRouter } from 'next/router';
|
||||||
|
import Head from 'next/head';
|
||||||
|
import { useAuth } from '../api';
|
||||||
|
|
||||||
|
const Login = () => {
|
||||||
|
const [username, setUsername] = useState('');
|
||||||
|
const [password, setPassword] = useState('');
|
||||||
|
const [error, setError] = useState('');
|
||||||
|
const { login } = useAuth();
|
||||||
|
const router = useRouter();
|
||||||
|
|
||||||
|
const handleSubmit = async (e: React.FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
setError('');
|
||||||
|
try {
|
||||||
|
await login(username, password);
|
||||||
|
router.push('/dashboard'); // Assuming a dashboard route after login
|
||||||
|
} catch (err) {
|
||||||
|
setError('Credenciales inválidas. Inténtalo de nuevo.');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen flex items-center justify-center bg-pania-golden"> {/* Updated background to PanIA golden */}
|
||||||
|
<Head>
|
||||||
|
<title>Login - PanIA</title> {/* Updated title with PanIA */}
|
||||||
|
</Head>
|
||||||
|
<div className="bg-pania-white p-8 rounded-lg shadow-lg max-w-md w-full"> {/* Updated background to PanIA white */}
|
||||||
|
<div className="text-center mb-6">
|
||||||
|
<h1 className="text-4xl font-extrabold text-pania-charcoal mb-2">PanIA</h1> {/* Updated to PanIA brand name and charcoal color */}
|
||||||
|
<p className="text-pania-blue text-lg">Inteligencia Artificial para tu Panadería</p> {/* Added tagline and tech blue color */}
|
||||||
|
</div>
|
||||||
|
<form onSubmit={handleSubmit} className="space-y-6">
|
||||||
|
<div>
|
||||||
|
<label htmlFor="username" className="block text-sm font-medium text-pania-charcoal">
|
||||||
|
Usuario
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
id="username"
|
||||||
|
className="mt-1 block w-full border border-gray-300 rounded-md shadow-sm py-2 px-3 focus:ring-pania-blue focus:border-pania-blue sm:text-sm"
|
||||||
|
value={username}
|
||||||
|
onChange={(e) => setUsername(e.target.value)}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label htmlFor="password" className="block text-sm font-medium text-pania-charcoal">
|
||||||
|
Contraseña
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="password"
|
||||||
|
id="password"
|
||||||
|
className="mt-1 block w-full border border-gray-300 rounded-md shadow-sm py-2 px-3 focus:ring-pania-blue focus:border-pania-blue sm:text-sm"
|
||||||
|
value={password}
|
||||||
|
onChange={(e) => setPassword(e.target.value)}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{error && <p className="text-red-500 text-sm text-center">{error}</p>}
|
||||||
|
<div>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
className="w-full flex justify-center py-2 px-4 border border-transparent rounded-md shadow-sm text-sm font-medium text-pania-white bg-pania-blue hover:bg-pania-blue-dark focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-pania-blue" // Updated button styles
|
||||||
|
>
|
||||||
|
Iniciar Sesión
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default Login;
|
||||||
664
frontend/src/pages/onboarding.tsx
Normal file
664
frontend/src/pages/onboarding.tsx
Normal file
@@ -0,0 +1,664 @@
|
|||||||
|
// Fixed Frontend Onboarding with Auto-Training
|
||||||
|
// frontend/src/pages/onboarding.tsx
|
||||||
|
|
||||||
|
import React, { useState, useEffect, useCallback } from 'react';
|
||||||
|
import { useRouter } from 'next/router';
|
||||||
|
import { CheckIcon, ArrowRightIcon, ArrowLeftIcon, CloudArrowUpIcon } from '@heroicons/react/24/outline';
|
||||||
|
import onboardingApi from '../api/onboardingApi';
|
||||||
|
|
||||||
|
const OnboardingPage = () => {
|
||||||
|
const router = useRouter();
|
||||||
|
const [currentStep, setCurrentStep] = useState(1);
|
||||||
|
const [completedSteps, setCompletedSteps] = useState<number[]>([]);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [authToken, setAuthToken] = useState<string | null>(null);
|
||||||
|
const [tenantId, setTenantId] = useState<string | null>(null);
|
||||||
|
const [trainingTaskId, setTrainingTaskId] = useState<string | null>(null);
|
||||||
|
const [trainingStarted, setTrainingStarted] = useState(false); // New state to track training start
|
||||||
|
|
||||||
|
const [formData, setFormData] = useState({
|
||||||
|
// Step 1: User Registration
|
||||||
|
full_name: '',
|
||||||
|
email: '',
|
||||||
|
password: '',
|
||||||
|
confirm_password: '',
|
||||||
|
|
||||||
|
// Step 2: Bakery Information
|
||||||
|
bakery_name: '',
|
||||||
|
address: '',
|
||||||
|
city: 'Madrid',
|
||||||
|
postal_code: '',
|
||||||
|
has_nearby_schools: false,
|
||||||
|
has_nearby_offices: false,
|
||||||
|
|
||||||
|
// Step 3: Sales History File
|
||||||
|
salesFile: null as File | null,
|
||||||
|
|
||||||
|
// Step 4: Model Training
|
||||||
|
trainingStatus: 'pending'
|
||||||
|
});
|
||||||
|
|
||||||
|
const [errors, setErrors] = useState<{ [key: string]: string }>({});
|
||||||
|
const [trainingProgress, setTrainingProgress] = useState({
|
||||||
|
currentTask: '',
|
||||||
|
progress: 0,
|
||||||
|
tasks: [
|
||||||
|
{ id: 1, name: 'Procesando archivo de ventas históricas...', completed: false },
|
||||||
|
{ id: 2, name: 'Preparando datos para el entrenamiento...', completed: false },
|
||||||
|
{ id: 3, name: 'Entrenando modelos de pronóstico (esto puede tardar unos minutos)...', completed: false },
|
||||||
|
{ id: 4, name: 'Evaluando y optimizando modelos...', completed: false },
|
||||||
|
{ id: 5, name: 'Desplegando modelos en producción...', completed: false },
|
||||||
|
{ id: 6, name: 'Entrenamiento completado.', completed: false },
|
||||||
|
]
|
||||||
|
});
|
||||||
|
|
||||||
|
// Load auth token and tenantId on component mount
|
||||||
|
useEffect(() => {
|
||||||
|
const token = localStorage.getItem('access_token');
|
||||||
|
const storedTenantId = localStorage.getItem('tenant_id');
|
||||||
|
if (token) {
|
||||||
|
setAuthToken(token);
|
||||||
|
onboardingApi.setAuthToken(token);
|
||||||
|
}
|
||||||
|
if (storedTenantId) {
|
||||||
|
setTenantId(storedTenantId);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Utility function to extract error message from FastAPI response
|
||||||
|
const getErrorMessage = (error: any): string => {
|
||||||
|
if (error.response && error.response.data && error.response.data.detail) {
|
||||||
|
const detail = error.response.data.detail;
|
||||||
|
if (typeof detail === 'string') {
|
||||||
|
return detail;
|
||||||
|
}
|
||||||
|
if (Array.isArray(detail)) {
|
||||||
|
return detail.map((err: any) => err.msg || JSON.stringify(err)).join(', ');
|
||||||
|
}
|
||||||
|
if (typeof detail === 'object') {
|
||||||
|
return detail.msg || JSON.stringify(detail);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return error.message || 'Ocurrió un error inesperado.';
|
||||||
|
};
|
||||||
|
|
||||||
|
const startModelTraining = useCallback(async () => {
|
||||||
|
console.log('Starting model training...');
|
||||||
|
setLoading(true);
|
||||||
|
setErrors({});
|
||||||
|
setTrainingStarted(true); // Mark training as started
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await onboardingApi.startTraining();
|
||||||
|
console.log('Training API response:', response);
|
||||||
|
|
||||||
|
setFormData(prev => ({ ...prev, trainingStatus: 'in_progress' }));
|
||||||
|
setTrainingTaskId(response.data.task_id);
|
||||||
|
setCompletedSteps(prev => [...prev, 4]);
|
||||||
|
|
||||||
|
console.log('Training started successfully with task ID:', response.data.task_id);
|
||||||
|
} catch (err: any) {
|
||||||
|
console.error('Error starting training:', err);
|
||||||
|
setErrors({ general: getErrorMessage(err) });
|
||||||
|
setTrainingStarted(false); // Reset if failed
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Auto-start training when entering step 4
|
||||||
|
useEffect(() => {
|
||||||
|
if (currentStep === 4 && !trainingStarted && !trainingTaskId && !loading) {
|
||||||
|
console.log('Auto-starting training on step 4...');
|
||||||
|
startModelTraining();
|
||||||
|
}
|
||||||
|
}, [currentStep, trainingStarted, trainingTaskId, loading, startModelTraining]);
|
||||||
|
|
||||||
|
// Polling for training status
|
||||||
|
useEffect(() => {
|
||||||
|
let interval: NodeJS.Timeout;
|
||||||
|
|
||||||
|
if (currentStep === 4 && trainingTaskId) {
|
||||||
|
console.log(`Starting to poll for training status with task ID: ${trainingTaskId}`);
|
||||||
|
interval = setInterval(async () => {
|
||||||
|
try {
|
||||||
|
const statusResponse = await onboardingApi.getTrainingStatus(trainingTaskId);
|
||||||
|
console.log("Polling status:", statusResponse);
|
||||||
|
|
||||||
|
const { status, progress, current_step, error: trainingError } = statusResponse.data;
|
||||||
|
|
||||||
|
setTrainingProgress(prev => ({
|
||||||
|
...prev,
|
||||||
|
currentTask: current_step || 'Procesando...',
|
||||||
|
progress: progress || 0,
|
||||||
|
tasks: prev.tasks.map(task =>
|
||||||
|
task.name === current_step ? { ...task, completed: true } : task
|
||||||
|
),
|
||||||
|
}));
|
||||||
|
|
||||||
|
setFormData(prev => ({ ...prev, trainingStatus: status }));
|
||||||
|
|
||||||
|
if (status === 'completed') {
|
||||||
|
clearInterval(interval);
|
||||||
|
setLoading(false);
|
||||||
|
setCompletedSteps(prev => [...prev.filter(s => s !== 4), 4]);
|
||||||
|
console.log('Training completed successfully!');
|
||||||
|
} else if (status === 'failed') {
|
||||||
|
clearInterval(interval);
|
||||||
|
setLoading(false);
|
||||||
|
setTrainingStarted(false); // Allow retry
|
||||||
|
setErrors({ general: trainingError || 'El entrenamiento falló.' });
|
||||||
|
console.error('Training failed:', trainingError);
|
||||||
|
}
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error('Error fetching training status:', error);
|
||||||
|
clearInterval(interval);
|
||||||
|
setLoading(false);
|
||||||
|
setTrainingStarted(false); // Allow retry
|
||||||
|
setErrors({ general: getErrorMessage(error) });
|
||||||
|
}
|
||||||
|
}, 3000); // Poll every 3 seconds
|
||||||
|
}
|
||||||
|
|
||||||
|
return () => clearInterval(interval);
|
||||||
|
}, [currentStep, trainingTaskId]);
|
||||||
|
|
||||||
|
// Use useCallback for memoized functions
|
||||||
|
const handleNext = useCallback(async () => {
|
||||||
|
setLoading(true);
|
||||||
|
setErrors({});
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (currentStep === 1) {
|
||||||
|
if (formData.password !== formData.confirm_password) {
|
||||||
|
setErrors({ confirmPassword: 'Las contraseñas no coinciden' });
|
||||||
|
setLoading(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const response = await onboardingApi.registerUser(formData);
|
||||||
|
console.log('User registered:', response);
|
||||||
|
|
||||||
|
if (response.data?.access_token) {
|
||||||
|
localStorage.setItem('access_token', response.data.access_token);
|
||||||
|
onboardingApi.setAuthToken(response.data.access_token);
|
||||||
|
setAuthToken(response.data.access_token);
|
||||||
|
}
|
||||||
|
if (response.data?.tenant_id) {
|
||||||
|
localStorage.setItem('tenant_id', response.data.tenant_id);
|
||||||
|
setTenantId(response.data.tenant_id);
|
||||||
|
}
|
||||||
|
setCompletedSteps(prev => [...prev, 1]);
|
||||||
|
setCurrentStep(2);
|
||||||
|
|
||||||
|
} else if (currentStep === 2) {
|
||||||
|
const response = await onboardingApi.registerBakery(formData);
|
||||||
|
console.log('Bakery registered:', response);
|
||||||
|
setCompletedSteps(prev => [...prev, 2]);
|
||||||
|
setCurrentStep(3);
|
||||||
|
|
||||||
|
} else if (currentStep === 3) {
|
||||||
|
if (!formData.salesFile) {
|
||||||
|
setErrors({ salesFile: 'Por favor, suba el archivo de historial de ventas.' });
|
||||||
|
setLoading(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const response = await onboardingApi.uploadSalesHistory(formData.salesFile);
|
||||||
|
console.log('Sales history uploaded:', response);
|
||||||
|
setCompletedSteps(prev => [...prev, 3]);
|
||||||
|
setCurrentStep(4); // This will trigger auto-training via useEffect
|
||||||
|
}
|
||||||
|
} catch (err: any) {
|
||||||
|
console.error('Error in step', currentStep, err);
|
||||||
|
setErrors({ general: getErrorMessage(err) });
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}, [currentStep, formData]);
|
||||||
|
|
||||||
|
const handlePrevious = useCallback(() => {
|
||||||
|
setCurrentStep(prev => Math.max(1, prev - 1));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleFileChange = useCallback((event: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
|
if (event.target.files && event.target.files[0]) {
|
||||||
|
setFormData(prev => ({ ...prev, salesFile: event.target.files![0] }));
|
||||||
|
setErrors(prev => ({ ...prev, salesFile: '' }));
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleSubmitFinal = async () => {
|
||||||
|
setLoading(true);
|
||||||
|
setErrors({});
|
||||||
|
try {
|
||||||
|
const response = await onboardingApi.completeOnboarding();
|
||||||
|
console.log('Onboarding completed:', response);
|
||||||
|
router.push('/dashboard');
|
||||||
|
} catch (err: any) {
|
||||||
|
console.error('Error completing onboarding:', err);
|
||||||
|
setErrors({ general: getErrorMessage(err) });
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const renderStep = () => {
|
||||||
|
switch (currentStep) {
|
||||||
|
case 1:
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<h2 className="text-2xl font-semibold text-gray-800 mb-4">Paso 1: Datos de Usuario</h2>
|
||||||
|
<p className="text-gray-600 mb-6">Regístrate para comenzar a usar BakeryForecast.</p>
|
||||||
|
{errors.general && (
|
||||||
|
<div className="bg-red-100 border border-red-400 text-red-700 px-4 py-3 rounded relative mb-4" role="alert">
|
||||||
|
<span className="block sm:inline">{errors.general}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div>
|
||||||
|
<label htmlFor="fullName" className="block text-sm font-medium text-gray-700">Nombre Completo</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
id="fullName"
|
||||||
|
value={formData.full_name}
|
||||||
|
onChange={(e) => setFormData(prev => ({ ...prev, full_name: e.target.value }))}
|
||||||
|
className="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-orange-500 focus:ring-orange-500"
|
||||||
|
placeholder="Tu nombre completo"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label htmlFor="email" className="block text-sm font-medium text-gray-700">Email</label>
|
||||||
|
<input
|
||||||
|
type="email"
|
||||||
|
id="email"
|
||||||
|
value={formData.email}
|
||||||
|
onChange={(e) => setFormData(prev => ({ ...prev, email: e.target.value }))}
|
||||||
|
className="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-orange-500 focus:ring-orange-500"
|
||||||
|
placeholder="tu@email.com"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label htmlFor="password" className="block text-sm font-medium text-gray-700">Contraseña</label>
|
||||||
|
<input
|
||||||
|
type="password"
|
||||||
|
id="password"
|
||||||
|
value={formData.password}
|
||||||
|
onChange={(e) => setFormData(prev => ({ ...prev, password: e.target.value }))}
|
||||||
|
className="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-orange-500 focus:ring-orange-500"
|
||||||
|
placeholder="••••••••"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label htmlFor="confirmPassword" className="block text-sm font-medium text-gray-700">Confirmar Contraseña</label>
|
||||||
|
<input
|
||||||
|
type="password"
|
||||||
|
id="confirmPassword"
|
||||||
|
value={formData.confirm_password}
|
||||||
|
onChange={(e) => setFormData(prev => ({ ...prev, confirm_password: e.target.value }))}
|
||||||
|
className="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-orange-500 focus:ring-orange-500"
|
||||||
|
placeholder="••••••••"
|
||||||
|
/>
|
||||||
|
{errors.confirmPassword && <p className="text-red-500 text-xs mt-1">{errors.confirmPassword}</p>}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
case 2:
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<h2 className="text-2xl font-semibold text-gray-800 mb-4">Paso 2: Información de la Panadería</h2>
|
||||||
|
<p className="text-gray-600 mb-6">Cuéntanos sobre tu panadería para personalizar las predicciones.</p>
|
||||||
|
{errors.general && (
|
||||||
|
<div className="bg-red-100 border border-red-400 text-red-700 px-4 py-3 rounded relative mb-4" role="alert">
|
||||||
|
<span className="block sm:inline">{errors.general}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div>
|
||||||
|
<label htmlFor="bakeryName" className="block text-sm font-medium text-gray-700">Nombre de la Panadería</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
id="bakeryName"
|
||||||
|
value={formData.bakery_name}
|
||||||
|
onChange={(e) => setFormData(prev => ({ ...prev, bakery_name: e.target.value }))}
|
||||||
|
className="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-orange-500 focus:ring-orange-500"
|
||||||
|
placeholder="Panadería San José"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label htmlFor="address" className="block text-sm font-medium text-gray-700">Dirección</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
id="address"
|
||||||
|
value={formData.address}
|
||||||
|
onChange={(e) => setFormData(prev => ({ ...prev, address: e.target.value }))}
|
||||||
|
className="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-orange-500 focus:ring-orange-500"
|
||||||
|
placeholder="Calle Mayor 123, Madrid"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label htmlFor="postalCode" className="block text-sm font-medium text-gray-700">Código Postal</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
id="postalCode"
|
||||||
|
value={formData.postal_code}
|
||||||
|
onChange={(e) => setFormData(prev => ({ ...prev, postal_code: e.target.value }))}
|
||||||
|
className="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-orange-500 focus:ring-orange-500"
|
||||||
|
placeholder="28001"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<label className="flex items-center">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={formData.has_nearby_schools}
|
||||||
|
onChange={(e) => setFormData(prev => ({ ...prev, has_nearby_schools: e.target.checked }))}
|
||||||
|
className="rounded border-gray-300 text-orange-600 focus:ring-orange-500"
|
||||||
|
/>
|
||||||
|
<span className="ml-2 text-sm text-gray-700">Hay colegios cerca</span>
|
||||||
|
</label>
|
||||||
|
<label className="flex items-center">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={formData.has_nearby_offices}
|
||||||
|
onChange={(e) => setFormData(prev => ({ ...prev, has_nearby_offices: e.target.checked }))}
|
||||||
|
className="rounded border-gray-300 text-orange-600 focus:ring-orange-500"
|
||||||
|
/>
|
||||||
|
<span className="ml-2 text-sm text-gray-700">Hay oficinas cerca</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
case 3:
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<h2 className="text-2xl font-semibold text-gray-800 mb-4">Paso 3: Historial de Ventas</h2>
|
||||||
|
<p className="text-gray-600 mb-6">Sube un archivo CSV con tu historial de ventas para entrenar el modelo de predicción.</p>
|
||||||
|
{errors.general && (
|
||||||
|
<div className="bg-red-100 border border-red-400 text-red-700 px-4 py-3 rounded relative mb-4" role="alert">
|
||||||
|
<span className="block sm:inline">{errors.general}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{errors.salesFile && <p className="text-red-500 text-xs mb-4">{errors.salesFile}</p>}
|
||||||
|
|
||||||
|
<div className="mt-1 flex justify-center px-6 pt-5 pb-6 border-2 border-gray-300 border-dashed rounded-md">
|
||||||
|
<div className="space-y-1 text-center">
|
||||||
|
<CloudArrowUpIcon className="mx-auto h-12 w-12 text-gray-400" />
|
||||||
|
<div className="flex text-sm text-gray-600">
|
||||||
|
<label
|
||||||
|
htmlFor="file-upload"
|
||||||
|
className="relative cursor-pointer bg-white rounded-md font-medium text-orange-600 hover:text-orange-500 focus-within:outline-none focus-within:ring-2 focus-within:ring-offset-2 focus-within:ring-orange-500"
|
||||||
|
>
|
||||||
|
<span>Sube un archivo</span>
|
||||||
|
<input id="file-upload" name="file-upload" type="file" className="sr-only" onChange={handleFileChange} accept=".csv,.xlsx" />
|
||||||
|
</label>
|
||||||
|
<p className="pl-1">o arrastra y suelta</p>
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-gray-500">Archivo CSV o Excel</p>
|
||||||
|
{formData.salesFile && (
|
||||||
|
<p className="text-sm text-gray-700 mt-2">Archivo seleccionado: <strong>{formData.salesFile.name}</strong></p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<p className="mt-4 text-sm text-gray-500">
|
||||||
|
Asegúrate de que tu archivo contiene columnas como: <strong>date</strong>, <strong>product_name</strong>, <strong>quantity_sold</strong>, <strong>revenue</strong> (opcional).
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
case 4:
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<h2 className="text-2xl font-semibold text-gray-800 mb-4">Paso 4: Entrenamiento del Modelo</h2>
|
||||||
|
<p className="text-gray-600 mb-6">
|
||||||
|
{trainingStarted ?
|
||||||
|
'Estamos entrenando los modelos de predicción con tus datos de ventas. Esto puede tardar unos minutos.' :
|
||||||
|
'Preparándose para entrenar los modelos de predicción...'
|
||||||
|
}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{errors.general && (
|
||||||
|
<div className="bg-red-100 border border-red-400 text-red-700 px-4 py-3 rounded relative mb-4" role="alert">
|
||||||
|
<span className="block sm:inline">{errors.general}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="bg-white shadow overflow-hidden sm:rounded-lg">
|
||||||
|
<div className="px-4 py-5 sm:px-6">
|
||||||
|
<h3 className="text-lg leading-6 font-medium text-gray-900">Estado del Entrenamiento</h3>
|
||||||
|
<p className="mt-1 max-w-2xl text-sm text-gray-500">Progreso del entrenamiento del modelo de IA</p>
|
||||||
|
</div>
|
||||||
|
<div className="border-t border-gray-200">
|
||||||
|
<dl>
|
||||||
|
<div className="py-4 sm:py-5 sm:grid sm:grid-cols-3 sm:gap-4 sm:px-6">
|
||||||
|
<dt className="text-sm font-medium text-gray-500">Estado</dt>
|
||||||
|
<dd className="mt-1 text-sm text-gray-900 sm:mt-0 sm:col-span-2">
|
||||||
|
<span className={`inline-flex px-2 py-1 text-xs font-semibold rounded-full ${
|
||||||
|
formData.trainingStatus === 'pending' ? 'bg-yellow-100 text-yellow-800' :
|
||||||
|
formData.trainingStatus === 'in_progress' ? 'bg-blue-100 text-blue-800' :
|
||||||
|
formData.trainingStatus === 'completed' ? 'bg-green-100 text-green-800' :
|
||||||
|
'bg-red-100 text-red-800'
|
||||||
|
}`}>
|
||||||
|
{formData.trainingStatus === 'pending' ? 'En espera' :
|
||||||
|
formData.trainingStatus === 'in_progress' ? 'En progreso' :
|
||||||
|
formData.trainingStatus === 'completed' ? 'Completado' :
|
||||||
|
formData.trainingStatus === 'failed' ? 'Fallido' : 'Desconocido'}
|
||||||
|
</span>
|
||||||
|
</dd>
|
||||||
|
</div>
|
||||||
|
<div className="py-4 sm:py-5 sm:grid sm:grid-cols-3 sm:gap-4 sm:px-6">
|
||||||
|
<dt className="text-sm font-medium text-gray-500">Progreso</dt>
|
||||||
|
<dd className="mt-1 text-sm text-gray-900 sm:mt-0 sm:col-span-2">
|
||||||
|
<div className="w-full bg-gray-200 rounded-full h-2.5">
|
||||||
|
<div className="bg-orange-600 h-2.5 rounded-full" style={{ width: `${trainingProgress.progress}%` }}></div>
|
||||||
|
</div>
|
||||||
|
<p className="mt-1 text-right text-xs text-gray-500">{trainingProgress.progress}%</p>
|
||||||
|
</dd>
|
||||||
|
</div>
|
||||||
|
<div className="py-4 sm:py-5 sm:grid sm:grid-cols-3 sm:gap-4 sm:px-6">
|
||||||
|
<dt className="text-sm font-medium text-gray-500">Paso Actual</dt>
|
||||||
|
<dd className="mt-1 text-sm text-gray-900 sm:mt-0 sm:col-span-2">{trainingProgress.currentTask || 'Iniciando...'}</dd>
|
||||||
|
</div>
|
||||||
|
<div className="py-4 sm:py-5 sm:grid sm:grid-cols-3 sm:gap-4 sm:px-6">
|
||||||
|
<dt className="text-sm font-medium text-gray-500">Historial de Tareas</dt>
|
||||||
|
<dd className="mt-1 text-sm text-gray-900 sm:mt-0 sm:col-span-2">
|
||||||
|
<ul className="divide-y divide-gray-200">
|
||||||
|
{trainingProgress.tasks.map(task => (
|
||||||
|
<li key={task.id} className="py-2 flex items-center justify-between">
|
||||||
|
<span className="text-sm">{task.name}</span>
|
||||||
|
{task.completed && <CheckIcon className="h-5 w-5 text-green-500" />}
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</dd>
|
||||||
|
</div>
|
||||||
|
</dl>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Manual start button (only shown if auto-start failed) */}
|
||||||
|
{!trainingStarted && formData.trainingStatus === 'pending' && !loading && (
|
||||||
|
<div className="mt-6 text-center">
|
||||||
|
<button
|
||||||
|
onClick={startModelTraining}
|
||||||
|
className="inline-flex items-center px-6 py-3 border border-transparent text-base font-medium rounded-md shadow-sm text-white bg-orange-600 hover:bg-orange-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-orange-500"
|
||||||
|
>
|
||||||
|
Iniciar Entrenamiento
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Retry button if training failed */}
|
||||||
|
{formData.trainingStatus === 'failed' && !loading && (
|
||||||
|
<div className="mt-6 text-center">
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
setTrainingStarted(false);
|
||||||
|
setTrainingTaskId(null);
|
||||||
|
setFormData(prev => ({ ...prev, trainingStatus: 'pending' }));
|
||||||
|
setErrors({});
|
||||||
|
}}
|
||||||
|
className="inline-flex items-center px-6 py-3 border border-transparent text-base font-medium rounded-md shadow-sm text-white bg-red-600 hover:bg-red-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-red-500"
|
||||||
|
>
|
||||||
|
Reintentar Entrenamiento
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
case 5:
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<h2 className="text-2xl font-semibold text-gray-800 mb-4">¡Enhorabuena!</h2>
|
||||||
|
<p className="text-gray-600 mb-6">Has completado el proceso de configuración. Tu sistema de predicción está listo para usar.</p>
|
||||||
|
<div className="bg-green-50 border border-green-200 rounded-md p-4">
|
||||||
|
<div className="flex">
|
||||||
|
<CheckIcon className="h-5 w-5 text-green-400" />
|
||||||
|
<div className="ml-3">
|
||||||
|
<h3 className="text-sm font-medium text-green-800">Sistema Configurado</h3>
|
||||||
|
<div className="mt-2 text-sm text-green-700">
|
||||||
|
<p>Tu modelo de predicción ha sido entrenado y está listo para generar pronósticos precisos para tu panadería.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
default:
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const steps = [
|
||||||
|
{ id: 1, name: 'Registro', status: currentStep > 1 || completedSteps.includes(1) ? 'complete' : currentStep === 1 ? 'current' : 'upcoming' },
|
||||||
|
{ id: 2, name: 'Panadería', status: currentStep > 2 || completedSteps.includes(2) ? 'complete' : currentStep === 2 ? 'current' : 'upcoming' },
|
||||||
|
{ id: 3, name: 'Historial de Ventas', status: currentStep > 3 || completedSteps.includes(3) ? 'complete' : currentStep === 3 ? 'current' : 'upcoming' },
|
||||||
|
{ id: 4, name: 'Entrenamiento ML', status: currentStep > 4 || completedSteps.includes(4) ? 'complete' : currentStep === 4 ? 'current' : 'upcoming' },
|
||||||
|
{ id: 5, name: 'Completar', status: currentStep > 5 || completedSteps.includes(5) ? 'complete' : currentStep === 5 ? 'current' : 'upcoming' },
|
||||||
|
];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen bg-gray-100 flex items-center justify-center p-4 sm:p-6 lg:p-8">
|
||||||
|
<div className="bg-white shadow-xl rounded-lg p-6 sm:p-8 w-full max-w-4xl">
|
||||||
|
{/* Progress Stepper */}
|
||||||
|
<nav aria-label="Progress" className="mb-8">
|
||||||
|
<ol role="list" className="flex items-center justify-center">
|
||||||
|
{steps.map((step, stepIdx) => (
|
||||||
|
<li key={step.name} className="relative flex-1">
|
||||||
|
<div className="flex flex-col items-center">
|
||||||
|
<span className={`flex h-10 w-10 items-center justify-center rounded-full ${
|
||||||
|
step.status === 'complete' ? 'bg-orange-600' :
|
||||||
|
step.status === 'current' ? 'border-2 border-orange-600' :
|
||||||
|
'bg-gray-200'
|
||||||
|
}`}>
|
||||||
|
{step.status === 'complete' ? (
|
||||||
|
<CheckIcon className="h-6 w-6 text-white" aria-hidden="true" />
|
||||||
|
) : (
|
||||||
|
<span className={`text-sm font-medium ${step.status === 'current' ? 'text-orange-600' : 'text-gray-500'}`}>
|
||||||
|
{step.id}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
<span className={`mt-2 text-sm font-medium ${step.status === 'current' ? 'text-orange-600' : 'text-gray-500'}`}>
|
||||||
|
{step.name}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{stepIdx !== steps.length - 1 && (
|
||||||
|
<div
|
||||||
|
className={`absolute right-0 top-5 h-0.5 w-1/2 translate-x-1/2 transform ${
|
||||||
|
step.status === 'complete' ? 'bg-orange-600' : 'bg-gray-200'
|
||||||
|
}`}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ol>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
{/* Step Content */}
|
||||||
|
<div className="mt-8">
|
||||||
|
{renderStep()}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Navigation Buttons */}
|
||||||
|
<div className="mt-8 flex justify-between">
|
||||||
|
<button
|
||||||
|
onClick={handlePrevious}
|
||||||
|
disabled={currentStep === 1 || loading}
|
||||||
|
className={`flex items-center px-6 py-3 rounded-lg font-medium transition-colors ${
|
||||||
|
currentStep === 1 || loading
|
||||||
|
? 'bg-gray-200 text-gray-500 cursor-not-allowed'
|
||||||
|
: 'bg-orange-100 hover:bg-orange-200 text-orange-700'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<ArrowLeftIcon className="w-5 h-5 mr-2" />
|
||||||
|
Anterior
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{currentStep < 4 && (
|
||||||
|
<button
|
||||||
|
onClick={handleNext}
|
||||||
|
disabled={loading}
|
||||||
|
className={`flex items-center px-6 py-3 rounded-lg font-medium transition-colors ${
|
||||||
|
loading
|
||||||
|
? 'bg-gray-400 cursor-not-allowed'
|
||||||
|
: 'bg-orange-600 hover:bg-orange-700'
|
||||||
|
} text-white`}
|
||||||
|
>
|
||||||
|
{loading ? (
|
||||||
|
<div className="animate-spin rounded-full h-5 w-5 border-b-2 border-white mr-2"></div>
|
||||||
|
) : (
|
||||||
|
'Siguiente'
|
||||||
|
)}
|
||||||
|
{!loading && <ArrowRightIcon className="w-5 h-5 ml-2" />}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{currentStep === 4 && (
|
||||||
|
<button
|
||||||
|
onClick={handleSubmitFinal}
|
||||||
|
disabled={loading || formData.trainingStatus !== 'completed'}
|
||||||
|
className={`flex items-center px-6 py-3 rounded-lg font-medium transition-colors ${
|
||||||
|
loading || formData.trainingStatus !== 'completed'
|
||||||
|
? 'bg-gray-400 cursor-not-allowed'
|
||||||
|
: 'bg-green-600 hover:bg-green-700'
|
||||||
|
} text-white`}
|
||||||
|
>
|
||||||
|
{loading ? (
|
||||||
|
<div className="animate-spin rounded-full h-5 w-5 border-b-2 border-white mr-2"></div>
|
||||||
|
) : (
|
||||||
|
'Ir al Dashboard'
|
||||||
|
)}
|
||||||
|
{!loading && <ArrowRightIcon className="w-5 h-5 ml-2" />}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{currentStep === 5 && (
|
||||||
|
<button
|
||||||
|
onClick={handleSubmitFinal}
|
||||||
|
disabled={loading}
|
||||||
|
className={`flex items-center px-6 py-3 rounded-lg font-medium transition-colors ${
|
||||||
|
loading
|
||||||
|
? 'bg-gray-400 cursor-not-allowed'
|
||||||
|
: 'bg-green-600 hover:bg-green-700'
|
||||||
|
} text-white`}
|
||||||
|
>
|
||||||
|
{loading ? (
|
||||||
|
<div className="animate-spin rounded-full h-5 w-5 border-b-2 border-white mr-2"></div>
|
||||||
|
) : (
|
||||||
|
'Ir al Dashboard'
|
||||||
|
)}
|
||||||
|
{!loading && <ArrowRightIcon className="w-5 h-5 ml-2" />}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default OnboardingPage;
|
||||||
6
frontend/src/styles/globals.css
Normal file
6
frontend/src/styles/globals.css
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
/* src/styles/globals.css */
|
||||||
|
@tailwind base;
|
||||||
|
@tailwind components;
|
||||||
|
@tailwind utilities;
|
||||||
|
|
||||||
|
/* You can add any custom global CSS here */
|
||||||
170
frontend/src/types/api.ts
Normal file
170
frontend/src/types/api.ts
Normal file
@@ -0,0 +1,170 @@
|
|||||||
|
// frontend/dashboard/src/types/api.ts
|
||||||
|
/**
|
||||||
|
* Shared TypeScript interfaces for API communication
|
||||||
|
*/
|
||||||
|
|
||||||
|
// Base response types
|
||||||
|
export interface ApiResponse<T = any> {
|
||||||
|
data?: T;
|
||||||
|
message?: string;
|
||||||
|
status: string;
|
||||||
|
timestamp?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ApiError {
|
||||||
|
detail: string;
|
||||||
|
service?: string;
|
||||||
|
error_code?: string;
|
||||||
|
timestamp?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Auth types
|
||||||
|
export interface LoginRequest {
|
||||||
|
email: string;
|
||||||
|
password: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RegisterRequest {
|
||||||
|
email: string;
|
||||||
|
password: string;
|
||||||
|
full_name: string;
|
||||||
|
phone?: string;
|
||||||
|
language?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TokenResponse {
|
||||||
|
access_token: string;
|
||||||
|
refresh_token: string;
|
||||||
|
token_type: string;
|
||||||
|
expires_in: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UserProfile {
|
||||||
|
id: string;
|
||||||
|
email: string;
|
||||||
|
full_name: string;
|
||||||
|
is_active: boolean;
|
||||||
|
is_verified: boolean;
|
||||||
|
tenant_id?: string;
|
||||||
|
role: string;
|
||||||
|
phone?: string;
|
||||||
|
language: string;
|
||||||
|
timezone: string;
|
||||||
|
created_at?: string;
|
||||||
|
last_login?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Tenant types
|
||||||
|
export interface TenantInfo {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
email: string;
|
||||||
|
phone: string;
|
||||||
|
address: string;
|
||||||
|
latitude: number;
|
||||||
|
longitude: number;
|
||||||
|
business_type: string;
|
||||||
|
is_active: boolean;
|
||||||
|
created_at: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sales types
|
||||||
|
export interface SalesRecord {
|
||||||
|
id: string;
|
||||||
|
tenant_id: string;
|
||||||
|
product_name: string;
|
||||||
|
quantity_sold: number;
|
||||||
|
revenue: number;
|
||||||
|
date: string;
|
||||||
|
created_at: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CreateSalesRequest {
|
||||||
|
product_name: string;
|
||||||
|
quantity_sold: number;
|
||||||
|
revenue: number;
|
||||||
|
date: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Training types
|
||||||
|
export interface TrainingJobStatus {
|
||||||
|
id: string;
|
||||||
|
tenant_id: string;
|
||||||
|
status: 'queued' | 'running' | 'completed' | 'failed' | 'cancelled';
|
||||||
|
progress: number;
|
||||||
|
current_step?: string;
|
||||||
|
started_at: string;
|
||||||
|
completed_at?: string;
|
||||||
|
duration_seconds?: number;
|
||||||
|
models_trained?: Record<string, any>;
|
||||||
|
metrics?: Record<string, any>;
|
||||||
|
error_message?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TrainingRequest {
|
||||||
|
force_retrain?: boolean;
|
||||||
|
products?: string[];
|
||||||
|
training_days?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TrainedModel {
|
||||||
|
id: string;
|
||||||
|
product_name: string;
|
||||||
|
model_type: string;
|
||||||
|
model_version: string;
|
||||||
|
mape?: number;
|
||||||
|
rmse?: number;
|
||||||
|
mae?: number;
|
||||||
|
r2_score?: number;
|
||||||
|
training_samples?: number;
|
||||||
|
features_used?: string[];
|
||||||
|
is_active: boolean;
|
||||||
|
created_at: string;
|
||||||
|
last_used_at?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Forecast types
|
||||||
|
export interface ForecastRecord {
|
||||||
|
id: string;
|
||||||
|
tenant_id: string;
|
||||||
|
product_name: string;
|
||||||
|
forecast_date: string;
|
||||||
|
predicted_quantity: number;
|
||||||
|
confidence_lower: number;
|
||||||
|
confidence_upper: number;
|
||||||
|
model_version: string;
|
||||||
|
created_at: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ForecastRequest {
|
||||||
|
product_name?: string;
|
||||||
|
forecast_days?: number;
|
||||||
|
include_confidence?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Data types
|
||||||
|
export interface WeatherData {
|
||||||
|
date: string;
|
||||||
|
temperature: number;
|
||||||
|
humidity: number;
|
||||||
|
precipitation: number;
|
||||||
|
wind_speed: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TrafficData {
|
||||||
|
date: string;
|
||||||
|
traffic_volume: number;
|
||||||
|
pedestrian_count: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Notification types
|
||||||
|
export interface NotificationSettings {
|
||||||
|
email_enabled: boolean;
|
||||||
|
whatsapp_enabled: boolean;
|
||||||
|
training_notifications: boolean;
|
||||||
|
forecast_notifications: boolean;
|
||||||
|
alert_thresholds: {
|
||||||
|
low_stock_percentage: number;
|
||||||
|
high_demand_increase: number;
|
||||||
|
};
|
||||||
|
}
|
||||||
80
frontend/src/utils/apiHelpers.ts
Normal file
80
frontend/src/utils/apiHelpers.ts
Normal file
@@ -0,0 +1,80 @@
|
|||||||
|
// 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;
|
||||||
|
});
|
||||||
|
}
|
||||||
35
frontend/tailwind.config.js
Normal file
35
frontend/tailwind.config.js
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
/** @type {import('tailwindcss').Config} */
|
||||||
|
module.exports = {
|
||||||
|
content: [
|
||||||
|
'./src/pages/**/*.{js,ts,jsx,tsx,mdx}',
|
||||||
|
'./src/components/**/*.{js,ts,jsx,tsx,mdx}',
|
||||||
|
'./src/app/**/*.{js,ts,jsx,tsx,mdx}',
|
||||||
|
],
|
||||||
|
theme: {
|
||||||
|
extend: {
|
||||||
|
colors: {
|
||||||
|
primary: {
|
||||||
|
50: '#eff6ff',
|
||||||
|
500: '#3b82f6',
|
||||||
|
600: '#2563eb',
|
||||||
|
700: '#1d4ed8',
|
||||||
|
},
|
||||||
|
bakery: { // Existing bakery colors, can be potentially phased out or used as accents
|
||||||
|
brown: '#8B4513',
|
||||||
|
cream: '#FFF8DC',
|
||||||
|
wheat: '#F5DEB3',
|
||||||
|
},
|
||||||
|
pania: { // New PanIA brand colors
|
||||||
|
golden: '#F4A261', // Primary: Warm Golden - representing bread/warmth
|
||||||
|
blue: '#2A9D8F', // Secondary: Tech Blue - representing AI/innovation
|
||||||
|
brown: '#8B4513', // Accent: Deep Brown - representing traditional bakery
|
||||||
|
white: '#FFFFFF', // Neutral: Clean White
|
||||||
|
charcoal: '#333333', // Neutral: Charcoal
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
plugins: [
|
||||||
|
require('@tailwindcss/forms'),
|
||||||
|
],
|
||||||
|
}
|
||||||
41
frontend/tsconfig.json
Normal file
41
frontend/tsconfig.json
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "es5",
|
||||||
|
"lib": [
|
||||||
|
"dom",
|
||||||
|
"dom.iterable",
|
||||||
|
"esnext"
|
||||||
|
],
|
||||||
|
"allowJs": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"strict": true,
|
||||||
|
"forceConsistentCasingInFileNames": true,
|
||||||
|
"noEmit": true,
|
||||||
|
"esModuleInterop": true,
|
||||||
|
"module": "esnext",
|
||||||
|
"moduleResolution": "node",
|
||||||
|
"resolveJsonModule": true,
|
||||||
|
"isolatedModules": true,
|
||||||
|
"jsx": "preserve",
|
||||||
|
"incremental": true,
|
||||||
|
"paths": {
|
||||||
|
"@/*": [
|
||||||
|
"./src/*"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"plugins": [
|
||||||
|
{
|
||||||
|
"name": "next"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"include": [
|
||||||
|
"next-env.d.ts",
|
||||||
|
"**/*.ts",
|
||||||
|
"**/*.tsx",
|
||||||
|
".next/types/**/*.ts"
|
||||||
|
],
|
||||||
|
"exclude": [
|
||||||
|
"node_modules"
|
||||||
|
]
|
||||||
|
}
|
||||||
31
gateway/Dockerfile
Normal file
31
gateway/Dockerfile
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
FROM python:3.11-slim
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
# Install system dependencies
|
||||||
|
RUN apt-get update && apt-get install -y \
|
||||||
|
gcc \
|
||||||
|
curl \
|
||||||
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
|
# Copy requirements
|
||||||
|
COPY requirements.txt .
|
||||||
|
|
||||||
|
# Install Python dependencies
|
||||||
|
RUN pip install --no-cache-dir -r requirements.txt
|
||||||
|
|
||||||
|
# Copy application code
|
||||||
|
COPY . .
|
||||||
|
|
||||||
|
# Add shared libraries to Python path
|
||||||
|
ENV PYTHONPATH="/app:/app/shared:$PYTHONPATH"
|
||||||
|
|
||||||
|
# Expose port
|
||||||
|
EXPOSE 8000
|
||||||
|
|
||||||
|
# Health check
|
||||||
|
HEALTHCHECK --interval=30s --timeout=10s --start-period=5s --retries=3 \
|
||||||
|
CMD curl -f http://localhost:8000/health || exit 1
|
||||||
|
|
||||||
|
# Run application
|
||||||
|
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"]
|
||||||
@@ -130,37 +130,3 @@ async def get_training_jobs(
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Get training jobs error: {e}")
|
logger.error(f"Get training jobs error: {e}")
|
||||||
raise HTTPException(status_code=500, detail="Internal server error")
|
raise HTTPException(status_code=500, detail="Internal server error")
|
||||||
|
|
||||||
|
|
||||||
# gateway/Dockerfile
|
|
||||||
FROM python:3.11-slim
|
|
||||||
|
|
||||||
WORKDIR /app
|
|
||||||
|
|
||||||
# Install system dependencies
|
|
||||||
RUN apt-get update && apt-get install -y \
|
|
||||||
gcc \
|
|
||||||
curl \
|
|
||||||
&& rm -rf /var/lib/apt/lists/*
|
|
||||||
|
|
||||||
# Copy requirements
|
|
||||||
COPY requirements.txt .
|
|
||||||
|
|
||||||
# Install Python dependencies
|
|
||||||
RUN pip install --no-cache-dir -r requirements.txt
|
|
||||||
|
|
||||||
# Copy application code
|
|
||||||
COPY . .
|
|
||||||
|
|
||||||
# Add shared libraries to Python path
|
|
||||||
ENV PYTHONPATH="/app:/app/shared:$PYTHONPATH"
|
|
||||||
|
|
||||||
# Expose port
|
|
||||||
EXPOSE 8000
|
|
||||||
|
|
||||||
# Health check
|
|
||||||
HEALTHCHECK --interval=30s --timeout=10s --start-period=5s --retries=3 \
|
|
||||||
CMD curl -f http://localhost:8000/health || exit 1
|
|
||||||
|
|
||||||
# Run application
|
|
||||||
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"]
|
|
||||||
34
services/auth/Dockerfile
Normal file
34
services/auth/Dockerfile
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
FROM python:3.11-slim
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
# Install system dependencies
|
||||||
|
RUN apt-get update && apt-get install -y \
|
||||||
|
gcc \
|
||||||
|
curl \
|
||||||
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
|
# Copy requirements
|
||||||
|
COPY requirements.txt .
|
||||||
|
|
||||||
|
# Install Python dependencies
|
||||||
|
RUN pip install --no-cache-dir -r requirements.txt
|
||||||
|
|
||||||
|
# Copy shared libraries
|
||||||
|
COPY --from=shared /shared /app/shared
|
||||||
|
|
||||||
|
# Copy application code
|
||||||
|
COPY . .
|
||||||
|
|
||||||
|
# Add shared libraries to Python path
|
||||||
|
ENV PYTHONPATH="/app:/app/shared:$PYTHONPATH"
|
||||||
|
|
||||||
|
# Expose port
|
||||||
|
EXPOSE 8000
|
||||||
|
|
||||||
|
# Health check
|
||||||
|
HEALTHCHECK --interval=30s --timeout=10s --start-period=5s --retries=3 \
|
||||||
|
CMD curl -f http://localhost:8000/health || exit 1
|
||||||
|
|
||||||
|
# Run application
|
||||||
|
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"]
|
||||||
@@ -6,41 +6,4 @@ from shared.messaging.rabbitmq import RabbitMQClient
|
|||||||
from app.core.config import settings
|
from app.core.config import settings
|
||||||
|
|
||||||
# Global message publisher
|
# Global message publisher
|
||||||
message_publisher = RabbitMQClient(settings.RABBITMQ_URL)
|
message_publisher = RabbitMQClient(settings.RABBITMQ_URL)
|
||||||
|
|
||||||
|
|
||||||
# services/auth/Dockerfile
|
|
||||||
FROM python:3.11-slim
|
|
||||||
|
|
||||||
WORKDIR /app
|
|
||||||
|
|
||||||
# Install system dependencies
|
|
||||||
RUN apt-get update && apt-get install -y \
|
|
||||||
gcc \
|
|
||||||
curl \
|
|
||||||
&& rm -rf /var/lib/apt/lists/*
|
|
||||||
|
|
||||||
# Copy requirements
|
|
||||||
COPY requirements.txt .
|
|
||||||
|
|
||||||
# Install Python dependencies
|
|
||||||
RUN pip install --no-cache-dir -r requirements.txt
|
|
||||||
|
|
||||||
# Copy shared libraries
|
|
||||||
COPY --from=shared /shared /app/shared
|
|
||||||
|
|
||||||
# Copy application code
|
|
||||||
COPY . .
|
|
||||||
|
|
||||||
# Add shared libraries to Python path
|
|
||||||
ENV PYTHONPATH="/app:/app/shared:$PYTHONPATH"
|
|
||||||
|
|
||||||
# Expose port
|
|
||||||
EXPOSE 8000
|
|
||||||
|
|
||||||
# Health check
|
|
||||||
HEALTHCHECK --interval=30s --timeout=10s --start-period=5s --retries=3 \
|
|
||||||
CMD curl -f http://localhost:8000/health || exit 1
|
|
||||||
|
|
||||||
# Run application
|
|
||||||
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"]
|
|
||||||
39
services/training/Dockerfile
Normal file
39
services/training/Dockerfile
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
# services/training/Dockerfile
|
||||||
|
FROM python:3.11-slim
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
# Install system dependencies
|
||||||
|
RUN apt-get update && apt-get install -y \
|
||||||
|
gcc \
|
||||||
|
g++ \
|
||||||
|
curl \
|
||||||
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
|
# Copy requirements
|
||||||
|
COPY requirements.txt .
|
||||||
|
|
||||||
|
# Install Python dependencies
|
||||||
|
RUN pip install --no-cache-dir -r requirements.txt
|
||||||
|
|
||||||
|
# Copy shared libraries
|
||||||
|
COPY --from=shared /shared /app/shared
|
||||||
|
|
||||||
|
# Copy application code
|
||||||
|
COPY . .
|
||||||
|
|
||||||
|
# Create model storage directory
|
||||||
|
RUN mkdir -p /app/models
|
||||||
|
|
||||||
|
# Add shared libraries to Python path
|
||||||
|
ENV PYTHONPATH="/app:/app/shared:$PYTHONPATH"
|
||||||
|
|
||||||
|
# Expose port
|
||||||
|
EXPOSE 8000
|
||||||
|
|
||||||
|
# Health check
|
||||||
|
HEALTHCHECK --interval=30s --timeout=10s --start-period=5s --retries=3 \
|
||||||
|
CMD curl -f http://localhost:8000/health || exit 1
|
||||||
|
|
||||||
|
# Run application
|
||||||
|
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"]
|
||||||
Reference in New Issue
Block a user