ADD new frontend
This commit is contained in:
@@ -1,85 +0,0 @@
|
||||
frontend/src/api/
|
||||
├── client/ # HTTP client configuration
|
||||
│ ├── index.ts # Main API client
|
||||
│ ├── config.ts # Client configuration
|
||||
│ ├── interceptors.ts # Request/response interceptors
|
||||
│ └── types.ts # Client-specific types
|
||||
├── services/ # Service-specific API calls
|
||||
│ ├── index.ts # Export all services
|
||||
│ ├── auth.service.ts # Authentication operations
|
||||
│ ├── tenant.service.ts # Tenant management
|
||||
│ ├── data.service.ts # Data operations
|
||||
│ ├── training.service.ts # ML training operations
|
||||
│ ├── forecasting.service.ts # Forecasting operations
|
||||
│ └── notification.service.ts # Notification operations
|
||||
├── types/ # TypeScript definitions
|
||||
│ ├── index.ts # Re-export all types
|
||||
│ ├── common.ts # Common API types
|
||||
│ ├── auth.ts # Authentication types
|
||||
│ ├── tenant.ts # Tenant types
|
||||
│ ├── data.ts # Data types
|
||||
│ ├── training.ts # Training types
|
||||
│ ├── forecasting.ts # Forecasting types
|
||||
│ └── notification.ts # Notification types
|
||||
├── hooks/ # React hooks for API calls
|
||||
│ ├── index.ts # Export all hooks
|
||||
│ ├── useAuth.ts # Authentication hooks
|
||||
│ ├── useTenant.ts # Tenant hooks
|
||||
│ ├── useData.ts # Data hooks
|
||||
│ ├── useTraining.ts # Training hooks
|
||||
│ ├── useForecast.ts # Forecasting hooks
|
||||
│ └── useNotification.ts # Notification hooks
|
||||
├── utils/ # API utilities
|
||||
│ ├── index.ts # Export utilities
|
||||
│ ├── response.ts # Response handling
|
||||
│ ├── error.ts # Error handling
|
||||
│ ├── validation.ts # Request validation
|
||||
│ └── transform.ts # Data transformation
|
||||
├── websocket/ # WebSocket management
|
||||
│ ├── index.ts # WebSocket exports
|
||||
│ ├── manager.ts # WebSocket manager
|
||||
│ ├── types.ts # WebSocket types
|
||||
│ └── hooks.ts # WebSocket hooks
|
||||
└── index.ts # Main API exports
|
||||
```
|
||||
|
||||
## 🎯 Key Improvements
|
||||
|
||||
### 1. **Modern Architecture Patterns**
|
||||
- **Service Layer Pattern**: Clean separation of concerns
|
||||
- **Repository Pattern**: Consistent data access layer
|
||||
- **Factory Pattern**: Flexible service instantiation
|
||||
- **Observer Pattern**: Event-driven updates
|
||||
|
||||
### 2. **Type Safety**
|
||||
- **Strict TypeScript**: Full type coverage
|
||||
- **Schema Validation**: Runtime type checking
|
||||
- **Generic Types**: Reusable type definitions
|
||||
- **Union Types**: Precise API responses
|
||||
|
||||
### 3. **Error Handling**
|
||||
- **Centralized Error Management**: Consistent error handling
|
||||
- **Error Recovery**: Automatic retry mechanisms
|
||||
- **User-Friendly Messages**: Localized error messages
|
||||
- **Error Boundaries**: Component-level error isolation
|
||||
|
||||
### 4. **Performance Optimization**
|
||||
- **Request Caching**: Intelligent cache management
|
||||
- **Request Deduplication**: Prevent duplicate calls
|
||||
- **Optimistic Updates**: Immediate UI feedback
|
||||
- **Background Sync**: Offline-first approach
|
||||
|
||||
### 5. **Developer Experience**
|
||||
- **Auto-completion**: Full IntelliSense support
|
||||
- **Type-safe Hooks**: React hooks with types
|
||||
- **Error Prevention**: Compile-time error detection
|
||||
- **Documentation**: Comprehensive JSDoc comments
|
||||
|
||||
## 🚀 Implementation Benefits
|
||||
|
||||
1. **Maintainability**: Modular structure for easy updates
|
||||
2. **Scalability**: Easy to add new services and endpoints
|
||||
3. **Testability**: Isolated services for unit testing
|
||||
4. **Reusability**: Shared utilities and types
|
||||
5. **Type Safety**: Prevent runtime errors
|
||||
6. **Developer Productivity**: IntelliSense and auto-completion
|
||||
@@ -1,142 +0,0 @@
|
||||
// frontend/src/api/client/config.ts
|
||||
/**
|
||||
* API Client Configuration
|
||||
* Centralized configuration for all API clients
|
||||
*/
|
||||
|
||||
export interface ApiConfig {
|
||||
baseURL: string;
|
||||
timeout: number;
|
||||
retries: number;
|
||||
retryDelay: number;
|
||||
enableLogging: boolean;
|
||||
enableCaching: boolean;
|
||||
cacheTimeout: number;
|
||||
}
|
||||
|
||||
export interface ServiceEndpoints {
|
||||
auth: string;
|
||||
tenant: string;
|
||||
data: string;
|
||||
training: string;
|
||||
forecasting: string;
|
||||
notification: string;
|
||||
}
|
||||
|
||||
// Environment-based configuration
|
||||
const getEnvironmentConfig = (): ApiConfig => {
|
||||
// Use import.meta.env instead of process.env for Vite
|
||||
const isDevelopment = import.meta.env.DEV;
|
||||
const isProduction = import.meta.env.PROD;
|
||||
|
||||
return {
|
||||
baseURL: import.meta.env.VITE_API_URL || 'http://localhost:8000/api/v1',
|
||||
timeout: parseInt(import.meta.env.VITE_API_TIMEOUT || '30000'),
|
||||
retries: parseInt(import.meta.env.VITE_API_RETRIES || '3'),
|
||||
retryDelay: parseInt(import.meta.env.VITE_API_RETRY_DELAY || '1000'),
|
||||
enableLogging: isDevelopment || import.meta.env.VITE_API_LOGGING === 'true',
|
||||
enableCaching: import.meta.env.VITE_API_CACHING !== 'false',
|
||||
cacheTimeout: parseInt(import.meta.env.VITE_API_CACHE_TIMEOUT || '300000'), // 5 minutes
|
||||
};
|
||||
};
|
||||
|
||||
export const apiConfig: ApiConfig = getEnvironmentConfig();
|
||||
|
||||
// Service endpoint configuration
|
||||
export const serviceEndpoints: ServiceEndpoints = {
|
||||
auth: '/auth',
|
||||
tenant: '/tenants',
|
||||
data: '/tenants', // Data operations are tenant-scoped
|
||||
training: '/tenants', // Training operations are tenant-scoped
|
||||
forecasting: '/tenants', // Forecasting operations are tenant-scoped
|
||||
notification: '/tenants', // Notification operations are tenant-scoped
|
||||
};
|
||||
|
||||
// HTTP status codes
|
||||
export const HttpStatus = {
|
||||
OK: 200,
|
||||
CREATED: 201,
|
||||
NO_CONTENT: 204,
|
||||
BAD_REQUEST: 400,
|
||||
UNAUTHORIZED: 401,
|
||||
FORBIDDEN: 403,
|
||||
NOT_FOUND: 404,
|
||||
CONFLICT: 409,
|
||||
UNPROCESSABLE_ENTITY: 422,
|
||||
INTERNAL_SERVER_ERROR: 500,
|
||||
BAD_GATEWAY: 502,
|
||||
SERVICE_UNAVAILABLE: 503,
|
||||
} as const;
|
||||
|
||||
// Request timeout configuration
|
||||
export const RequestTimeouts = {
|
||||
SHORT: 5000, // 5 seconds - for quick operations
|
||||
MEDIUM: 15000, // 15 seconds - for normal operations
|
||||
LONG: 60000, // 1 minute - for file uploads
|
||||
EXTENDED: 300000, // 5 minutes - for training operations
|
||||
} as const;
|
||||
|
||||
// Cache configuration
|
||||
export interface CacheConfig {
|
||||
defaultTTL: number;
|
||||
maxSize: number;
|
||||
strategies: {
|
||||
user: number;
|
||||
tenant: number;
|
||||
data: number;
|
||||
forecast: number;
|
||||
};
|
||||
}
|
||||
|
||||
export const cacheConfig: CacheConfig = {
|
||||
defaultTTL: 300000, // 5 minutes
|
||||
maxSize: 100, // Maximum cached items
|
||||
strategies: {
|
||||
user: 600000, // 10 minutes
|
||||
tenant: 1800000, // 30 minutes
|
||||
data: 300000, // 5 minutes
|
||||
forecast: 600000, // 10 minutes
|
||||
},
|
||||
};
|
||||
|
||||
// Retry configuration
|
||||
export interface RetryConfig {
|
||||
attempts: number;
|
||||
delay: number;
|
||||
backoff: number;
|
||||
retryCondition: (error: any) => boolean;
|
||||
}
|
||||
|
||||
export const retryConfig: RetryConfig = {
|
||||
attempts: 3,
|
||||
delay: 1000,
|
||||
backoff: 2, // Exponential backoff multiplier
|
||||
retryCondition: (error: any) => {
|
||||
// Retry on network errors and specific HTTP status codes
|
||||
if (!error.response) return true; // Network error
|
||||
const status = error.response.status;
|
||||
return status >= 500 || status === 408 || status === 429;
|
||||
},
|
||||
};
|
||||
|
||||
// API versioning
|
||||
export const ApiVersion = {
|
||||
V1: 'v1',
|
||||
CURRENT: 'v1',
|
||||
} as const;
|
||||
|
||||
export interface FeatureFlags {
|
||||
enableWebSockets: boolean;
|
||||
enableOfflineMode: boolean;
|
||||
enableOptimisticUpdates: boolean;
|
||||
enableRequestDeduplication: boolean;
|
||||
enableMetrics: boolean;
|
||||
}
|
||||
|
||||
export const featureFlags: FeatureFlags = {
|
||||
enableWebSockets: import.meta.env.VITE_ENABLE_WEBSOCKETS === 'true',
|
||||
enableOfflineMode: import.meta.env.VITE_ENABLE_OFFLINE === 'true',
|
||||
enableOptimisticUpdates: import.meta.env.VITE_ENABLE_OPTIMISTIC_UPDATES !== 'false',
|
||||
enableRequestDeduplication: import.meta.env.VITE_ENABLE_DEDUPLICATION !== 'false',
|
||||
enableMetrics: import.meta.env.VITE_ENABLE_METRICS === 'true',
|
||||
};
|
||||
@@ -1,578 +0,0 @@
|
||||
// frontend/src/api/client/index.ts
|
||||
/**
|
||||
* Enhanced API Client with modern features
|
||||
* Supports caching, retries, optimistic updates, and more
|
||||
*/
|
||||
|
||||
import {
|
||||
ApiResponse,
|
||||
ApiError,
|
||||
RequestConfig,
|
||||
UploadConfig,
|
||||
UploadProgress,
|
||||
RequestInterceptor,
|
||||
ResponseInterceptor,
|
||||
CacheEntry,
|
||||
RequestMetrics,
|
||||
} from './types';
|
||||
import { apiConfig, retryConfig, cacheConfig, featureFlags } from './config';
|
||||
|
||||
export class ApiClient {
|
||||
private baseURL: string;
|
||||
private cache = new Map<string, CacheEntry>();
|
||||
private pendingRequests = new Map<string, Promise<any>>();
|
||||
private requestInterceptors: RequestInterceptor[] = [];
|
||||
private responseInterceptors: ResponseInterceptor[] = [];
|
||||
private metrics: RequestMetrics[] = [];
|
||||
|
||||
constructor(baseURL?: string) {
|
||||
this.baseURL = baseURL || apiConfig.baseURL;
|
||||
// ✅ CRITICAL FIX: Remove trailing slash
|
||||
this.baseURL = this.baseURL.replace(/\/+$/, '');
|
||||
console.log('🔧 API Client initialized with baseURL:', this.baseURL);
|
||||
}
|
||||
|
||||
private buildURL(endpoint: string): string {
|
||||
// Remove leading slash from endpoint if present to avoid double slashes
|
||||
const cleanEndpoint = endpoint.startsWith('/') ? endpoint : `/${endpoint}`;
|
||||
const fullURL = `${this.baseURL}${cleanEndpoint}`;
|
||||
|
||||
// ✅ DEBUG: Log URL construction
|
||||
console.log('🔗 Building URL:', {
|
||||
baseURL: this.baseURL,
|
||||
endpoint: cleanEndpoint,
|
||||
fullURL: fullURL
|
||||
});
|
||||
|
||||
return fullURL;
|
||||
}
|
||||
|
||||
/**
|
||||
* Add request interceptor
|
||||
*/
|
||||
addRequestInterceptor(interceptor: RequestInterceptor): void {
|
||||
this.requestInterceptors.push(interceptor);
|
||||
}
|
||||
|
||||
/**
|
||||
* Add response interceptor
|
||||
*/
|
||||
addResponseInterceptor(interceptor: ResponseInterceptor): void {
|
||||
this.responseInterceptors.push(interceptor);
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate cache key for request
|
||||
*/
|
||||
private getCacheKey(url: string, config?: RequestConfig): string {
|
||||
const method = config?.method || 'GET';
|
||||
const params = config?.params ? JSON.stringify(config.params) : '';
|
||||
return `${method}:${url}:${params}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if response is cached and valid
|
||||
*/
|
||||
private getCachedResponse<T>(key: string): T | null {
|
||||
if (!featureFlags.enableRequestDeduplication && !apiConfig.enableCaching) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const cached = this.cache.get(key);
|
||||
if (!cached) return null;
|
||||
|
||||
const now = Date.now();
|
||||
if (now - cached.timestamp > cached.ttl) {
|
||||
this.cache.delete(key);
|
||||
return null;
|
||||
}
|
||||
|
||||
return cached.data;
|
||||
}
|
||||
|
||||
/**
|
||||
* Cache response data
|
||||
*/
|
||||
private setCachedResponse<T>(key: string, data: T, ttl?: number): void {
|
||||
if (!apiConfig.enableCaching) return;
|
||||
|
||||
const cacheTTL = ttl || cacheConfig.defaultTTL;
|
||||
this.cache.set(key, {
|
||||
data,
|
||||
timestamp: Date.now(),
|
||||
ttl: cacheTTL,
|
||||
key,
|
||||
});
|
||||
|
||||
// Cleanup old cache entries if cache is full
|
||||
if (this.cache.size > cacheConfig.maxSize) {
|
||||
const oldestKey = this.cache.keys().next().value;
|
||||
this.cache.delete(oldestKey);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply request interceptors
|
||||
*/
|
||||
private async applyRequestInterceptors(config: RequestConfig): Promise<RequestConfig> {
|
||||
let modifiedConfig = { ...config };
|
||||
|
||||
for (const interceptor of this.requestInterceptors) {
|
||||
if (interceptor.onRequest) {
|
||||
try {
|
||||
modifiedConfig = await interceptor.onRequest(modifiedConfig);
|
||||
} catch (error) {
|
||||
if (interceptor.onRequestError) {
|
||||
await interceptor.onRequestError(error);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return modifiedConfig;
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply response interceptors
|
||||
*/
|
||||
private async applyResponseInterceptors<T>(response: ApiResponse<T>): Promise<ApiResponse<T>> {
|
||||
let modifiedResponse = { ...response };
|
||||
|
||||
for (const interceptor of this.responseInterceptors) {
|
||||
if (interceptor.onResponse) {
|
||||
try {
|
||||
modifiedResponse = await interceptor.onResponse(modifiedResponse);
|
||||
} catch (error) {
|
||||
if (interceptor.onResponseError) {
|
||||
await interceptor.onResponseError(error);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return modifiedResponse;
|
||||
}
|
||||
|
||||
/**
|
||||
* Retry failed requests with exponential backoff
|
||||
*/
|
||||
private async retryRequest<T>(
|
||||
requestFn: () => Promise<T>,
|
||||
attempts: number = retryConfig.attempts
|
||||
): Promise<T> {
|
||||
try {
|
||||
return await requestFn();
|
||||
} catch (error) {
|
||||
if (attempts <= 0 || !retryConfig.retryCondition(error)) {
|
||||
throw error;
|
||||
}
|
||||
|
||||
const delay = retryConfig.delay * Math.pow(retryConfig.backoff, retryConfig.attempts - attempts);
|
||||
await new Promise(resolve => setTimeout(resolve, delay));
|
||||
|
||||
return this.retryRequest(requestFn, attempts - 1);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Record request metrics
|
||||
*/
|
||||
private recordMetrics(metrics: Partial<RequestMetrics>): void {
|
||||
if (!featureFlags.enableMetrics) return;
|
||||
|
||||
const completeMetrics: RequestMetrics = {
|
||||
url: '',
|
||||
method: 'GET',
|
||||
duration: 0,
|
||||
status: 0,
|
||||
size: 0,
|
||||
timestamp: Date.now(),
|
||||
cached: false,
|
||||
retries: 0,
|
||||
...metrics,
|
||||
};
|
||||
|
||||
this.metrics.push(completeMetrics);
|
||||
|
||||
// Keep only recent metrics (last 1000 requests)
|
||||
if (this.metrics.length > 1000) {
|
||||
this.metrics = this.metrics.slice(-1000);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Core request method with all features
|
||||
*/
|
||||
async request<T = any>(endpoint: string, config: RequestConfig = {}): Promise<T> {
|
||||
const startTime = Date.now();
|
||||
const url = this.buildURL(endpoint);
|
||||
const method = config.method || 'GET';
|
||||
|
||||
console.log('🚀 Making API request:', {
|
||||
method,
|
||||
endpoint,
|
||||
url,
|
||||
config
|
||||
});
|
||||
|
||||
// Apply request interceptors
|
||||
const modifiedConfig = await this.applyRequestInterceptors(config);
|
||||
|
||||
// Generate cache key
|
||||
const cacheKey = this.getCacheKey(endpoint, modifiedConfig);
|
||||
|
||||
// Check cache for GET requests
|
||||
if (method === 'GET' && (config.cache !== false)) {
|
||||
const cached = this.getCachedResponse<T>(cacheKey);
|
||||
if (cached) {
|
||||
this.recordMetrics({
|
||||
url: endpoint,
|
||||
method,
|
||||
duration: Date.now() - startTime,
|
||||
status: 200,
|
||||
cached: true,
|
||||
});
|
||||
return cached;
|
||||
}
|
||||
}
|
||||
|
||||
// Request deduplication for concurrent requests
|
||||
if (featureFlags.enableRequestDeduplication && method === 'GET') {
|
||||
const pendingRequest = this.pendingRequests.get(cacheKey);
|
||||
if (pendingRequest) {
|
||||
return pendingRequest;
|
||||
}
|
||||
}
|
||||
|
||||
// Create request promise
|
||||
const requestPromise = this.retryRequest(async () => {
|
||||
const headers: Record<string, string> = {
|
||||
'Content-Type': 'application/json',
|
||||
...modifiedConfig.headers,
|
||||
};
|
||||
|
||||
const fetchConfig: RequestInit = {
|
||||
method,
|
||||
headers,
|
||||
signal: AbortSignal.timeout(modifiedConfig.timeout || apiConfig.timeout),
|
||||
};
|
||||
|
||||
// Add body for non-GET requests
|
||||
if (method !== 'GET' && modifiedConfig.body) {
|
||||
if (modifiedConfig.body instanceof FormData) {
|
||||
// Remove Content-Type for FormData (let browser set it with boundary)
|
||||
delete headers['Content-Type'];
|
||||
fetchConfig.body = modifiedConfig.body;
|
||||
} else {
|
||||
fetchConfig.body = typeof modifiedConfig.body === 'string'
|
||||
? modifiedConfig.body
|
||||
: JSON.stringify(modifiedConfig.body);
|
||||
}
|
||||
}
|
||||
|
||||
// Add query parameters
|
||||
const urlWithParams = new URL(url);
|
||||
if (modifiedConfig.params) {
|
||||
Object.entries(modifiedConfig.params).forEach(([key, value]) => {
|
||||
if (value !== undefined && value !== null) {
|
||||
urlWithParams.searchParams.append(key, String(value));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
const response = await fetch(urlWithParams.toString(), fetchConfig);
|
||||
|
||||
if (!response.ok) {
|
||||
const errorText = await response.text();
|
||||
let errorData: ApiError;
|
||||
|
||||
try {
|
||||
errorData = JSON.parse(errorText);
|
||||
} catch {
|
||||
errorData = {
|
||||
message: `HTTP ${response.status}: ${response.statusText}`,
|
||||
detail: errorText,
|
||||
code: `HTTP_${response.status}`,
|
||||
};
|
||||
}
|
||||
|
||||
const error = new Error(errorData.message || 'Request failed');
|
||||
(error as any).response = { status: response.status, data: errorData };
|
||||
throw error;
|
||||
}
|
||||
|
||||
const responseData = await response.json();
|
||||
console.log('🔍 Raw responseData from fetch:', responseData);
|
||||
|
||||
// Apply response interceptors
|
||||
const processedResponse = await this.applyResponseInterceptors(responseData);
|
||||
console.log('🔍 processedResponse after interceptors:', processedResponse);
|
||||
|
||||
return processedResponse;
|
||||
});
|
||||
|
||||
// Store pending request for deduplication
|
||||
if (featureFlags.enableRequestDeduplication && method === 'GET') {
|
||||
this.pendingRequests.set(cacheKey, requestPromise);
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await requestPromise;
|
||||
|
||||
// Cache successful GET responses
|
||||
if (method === 'GET' && config.cache !== false) {
|
||||
this.setCachedResponse(cacheKey, result, config.cacheTTL);
|
||||
}
|
||||
|
||||
// Record metrics
|
||||
this.recordMetrics({
|
||||
url: endpoint,
|
||||
method,
|
||||
duration: Date.now() - startTime,
|
||||
status: 200,
|
||||
size: JSON.stringify(result).length,
|
||||
});
|
||||
|
||||
// Handle both wrapped and unwrapped responses
|
||||
// If result has a 'data' property, return it; otherwise return the result itself
|
||||
console.log('🔍 Final result before return:', result);
|
||||
console.log('🔍 Result has data property?', result && typeof result === 'object' && 'data' in result);
|
||||
|
||||
if (result && typeof result === 'object' && 'data' in result) {
|
||||
console.log('🔍 Returning result.data:', result.data);
|
||||
return result.data as T;
|
||||
}
|
||||
console.log('🔍 Returning raw result:', result);
|
||||
return result as T;
|
||||
} catch (error) {
|
||||
// Record error metrics
|
||||
this.recordMetrics({
|
||||
url: endpoint,
|
||||
method,
|
||||
duration: Date.now() - startTime,
|
||||
status: (error as any).response?.status || 0,
|
||||
});
|
||||
|
||||
throw error;
|
||||
} finally {
|
||||
// Clean up pending request
|
||||
if (featureFlags.enableRequestDeduplication && method === 'GET') {
|
||||
this.pendingRequests.delete(cacheKey);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Convenience methods for HTTP verbs
|
||||
*/
|
||||
async get<T = any>(endpoint: string, config?: RequestConfig): Promise<T> {
|
||||
return this.request<T>(endpoint, { ...config, method: 'GET' });
|
||||
}
|
||||
|
||||
async post<T = any>(endpoint: string, data?: any, config?: RequestConfig): Promise<T> {
|
||||
return this.request<T>(endpoint, {
|
||||
...config,
|
||||
method: 'POST',
|
||||
body: data
|
||||
});
|
||||
}
|
||||
|
||||
async put<T = any>(endpoint: string, data?: any, config?: RequestConfig): Promise<T> {
|
||||
return this.request<T>(endpoint, {
|
||||
...config,
|
||||
method: 'PUT',
|
||||
body: data
|
||||
});
|
||||
}
|
||||
|
||||
async patch<T = any>(endpoint: string, data?: any, config?: RequestConfig): Promise<T> {
|
||||
return this.request<T>(endpoint, {
|
||||
...config,
|
||||
method: 'PATCH',
|
||||
body: data
|
||||
});
|
||||
}
|
||||
|
||||
async delete<T = any>(endpoint: string, config?: RequestConfig): Promise<T> {
|
||||
return this.request<T>(endpoint, { ...config, method: 'DELETE' });
|
||||
}
|
||||
|
||||
/**
|
||||
* Raw request that returns the Response object for binary data
|
||||
*/
|
||||
async getRaw(endpoint: string, config?: RequestConfig): Promise<Response> {
|
||||
const url = this.buildURL(endpoint);
|
||||
const modifiedConfig = await this.applyRequestInterceptors(config || {});
|
||||
|
||||
const headers: Record<string, string> = {
|
||||
...modifiedConfig.headers,
|
||||
};
|
||||
|
||||
const fetchConfig: RequestInit = {
|
||||
method: 'GET',
|
||||
headers,
|
||||
signal: AbortSignal.timeout(modifiedConfig.timeout || apiConfig.timeout),
|
||||
};
|
||||
|
||||
// Add query parameters
|
||||
const urlWithParams = new URL(url);
|
||||
if (modifiedConfig.params) {
|
||||
Object.entries(modifiedConfig.params).forEach(([key, value]) => {
|
||||
if (value !== undefined && value !== null) {
|
||||
urlWithParams.searchParams.append(key, String(value));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
const response = await fetch(urlWithParams.toString(), fetchConfig);
|
||||
|
||||
if (!response.ok) {
|
||||
const errorText = await response.text();
|
||||
let errorData: ApiError;
|
||||
|
||||
try {
|
||||
errorData = JSON.parse(errorText);
|
||||
} catch {
|
||||
errorData = {
|
||||
message: `HTTP ${response.status}: ${response.statusText}`,
|
||||
detail: errorText,
|
||||
code: `HTTP_${response.status}`,
|
||||
};
|
||||
}
|
||||
|
||||
const error = new Error(errorData.message || 'Request failed');
|
||||
(error as any).response = { status: response.status, data: errorData };
|
||||
throw error;
|
||||
}
|
||||
|
||||
return response;
|
||||
}
|
||||
|
||||
/**
|
||||
* File upload with progress tracking
|
||||
*/
|
||||
async upload<T = any>(
|
||||
endpoint: string,
|
||||
file: File,
|
||||
additionalData?: Record<string, any>,
|
||||
config?: UploadConfig
|
||||
): Promise<T> {
|
||||
const formData = new FormData();
|
||||
formData.append('file', file);
|
||||
|
||||
if (additionalData) {
|
||||
Object.entries(additionalData).forEach(([key, value]) => {
|
||||
formData.append(key, String(value));
|
||||
});
|
||||
}
|
||||
|
||||
// For file uploads, we need to use XMLHttpRequest for progress tracking
|
||||
if (config?.onProgress) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const xhr = new XMLHttpRequest();
|
||||
|
||||
xhr.upload.addEventListener('progress', (event) => {
|
||||
if (event.lengthComputable && config.onProgress) {
|
||||
const progress: UploadProgress = {
|
||||
loaded: event.loaded,
|
||||
total: event.total,
|
||||
percentage: Math.round((event.loaded / event.total) * 100),
|
||||
};
|
||||
config.onProgress(progress);
|
||||
}
|
||||
});
|
||||
|
||||
xhr.addEventListener('load', () => {
|
||||
if (xhr.status >= 200 && xhr.status < 300) {
|
||||
try {
|
||||
const result = JSON.parse(xhr.responseText);
|
||||
resolve(result);
|
||||
} catch {
|
||||
resolve(xhr.responseText as any);
|
||||
}
|
||||
} else {
|
||||
reject(new Error(`Upload failed: ${xhr.status} ${xhr.statusText}`));
|
||||
}
|
||||
});
|
||||
|
||||
xhr.addEventListener('error', () => {
|
||||
reject(new Error('Upload failed'));
|
||||
});
|
||||
|
||||
xhr.open('POST', `${this.baseURL}${endpoint}`);
|
||||
|
||||
// Add headers (excluding Content-Type for FormData)
|
||||
if (config?.headers) {
|
||||
Object.entries(config.headers).forEach(([key, value]) => {
|
||||
if (key.toLowerCase() !== 'content-type') {
|
||||
xhr.setRequestHeader(key, value);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
xhr.send(formData);
|
||||
});
|
||||
}
|
||||
|
||||
// Fallback to regular request for uploads without progress
|
||||
return this.request<T>(endpoint, {
|
||||
...config,
|
||||
method: 'POST',
|
||||
body: formData,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear cache
|
||||
*/
|
||||
clearCache(pattern?: string): void {
|
||||
if (pattern) {
|
||||
// Clear cache entries matching pattern
|
||||
const regex = new RegExp(pattern);
|
||||
Array.from(this.cache.keys())
|
||||
.filter(key => regex.test(key))
|
||||
.forEach(key => this.cache.delete(key));
|
||||
} else {
|
||||
// Clear all cache
|
||||
this.cache.clear();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get client metrics
|
||||
*/
|
||||
getMetrics() {
|
||||
if (!featureFlags.enableMetrics) {
|
||||
return {
|
||||
totalRequests: 0,
|
||||
successfulRequests: 0,
|
||||
failedRequests: 0,
|
||||
averageResponseTime: 0,
|
||||
cacheHitRate: 0,
|
||||
errorRate: 0,
|
||||
};
|
||||
}
|
||||
|
||||
const total = this.metrics.length;
|
||||
const successful = this.metrics.filter(m => m.status >= 200 && m.status < 300).length;
|
||||
const cached = this.metrics.filter(m => m.cached).length;
|
||||
const averageTime = total > 0
|
||||
? this.metrics.reduce((sum, m) => sum + m.duration, 0) / total
|
||||
: 0;
|
||||
|
||||
return {
|
||||
totalRequests: total,
|
||||
successfulRequests: successful,
|
||||
failedRequests: total - successful,
|
||||
averageResponseTime: Math.round(averageTime),
|
||||
cacheHitRate: total > 0 ? Math.round((cached / total) * 100) : 0,
|
||||
errorRate: total > 0 ? Math.round(((total - successful) / total) * 100) : 0,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// Default API client instance
|
||||
console.log('🔧 Creating default API client...');
|
||||
export const apiClient = new ApiClient();
|
||||
@@ -1,488 +0,0 @@
|
||||
// frontend/src/api/client/interceptors.ts
|
||||
/**
|
||||
* Request and Response Interceptors
|
||||
* Handles authentication, logging, error handling, etc.
|
||||
*/
|
||||
|
||||
import { apiClient } from './index';
|
||||
import type { RequestConfig, ApiResponse } from './types';
|
||||
import { ApiErrorHandler } from '../utils';
|
||||
|
||||
/**
|
||||
* Authentication Interceptor
|
||||
* Automatically adds authentication headers to requests
|
||||
*/
|
||||
class AuthInterceptor {
|
||||
static isTokenExpired(token: string): boolean {
|
||||
try {
|
||||
const payload = JSON.parse(atob(token.split('.')[1]));
|
||||
const currentTime = Math.floor(Date.now() / 1000);
|
||||
return payload.exp <= currentTime;
|
||||
} catch (error) {
|
||||
console.warn('Error parsing token:', error);
|
||||
return true; // Treat invalid tokens as expired
|
||||
}
|
||||
}
|
||||
|
||||
static isTokenExpiringSoon(token: string, bufferMinutes: number = 5): boolean {
|
||||
try {
|
||||
const payload = JSON.parse(atob(token.split('.')[1]));
|
||||
const currentTime = Math.floor(Date.now() / 1000);
|
||||
const bufferSeconds = bufferMinutes * 60;
|
||||
return payload.exp <= (currentTime + bufferSeconds);
|
||||
} catch (error) {
|
||||
console.warn('Error parsing token for expiration check:', error);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
static async refreshTokenIfNeeded(): Promise<void> {
|
||||
const token = localStorage.getItem('auth_token');
|
||||
const refreshToken = localStorage.getItem('refresh_token');
|
||||
|
||||
if (!token || !refreshToken) {
|
||||
return;
|
||||
}
|
||||
|
||||
// If token is expiring within 5 minutes, proactively refresh
|
||||
if (this.isTokenExpiringSoon(token, 5)) {
|
||||
try {
|
||||
const baseURL = (apiClient as any).baseURL || window.location.origin;
|
||||
const response = await fetch(`${baseURL}/api/v1/auth/refresh`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ refresh_token: refreshToken }),
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
localStorage.setItem('auth_token', data.access_token);
|
||||
if (data.refresh_token) {
|
||||
localStorage.setItem('refresh_token', data.refresh_token);
|
||||
}
|
||||
} else {
|
||||
console.warn('Token refresh failed:', response.status);
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn('Token refresh error:', error);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
static setup() {
|
||||
apiClient.addRequestInterceptor({
|
||||
onRequest: async (config: RequestConfig) => {
|
||||
// Proactively refresh token if needed
|
||||
await this.refreshTokenIfNeeded();
|
||||
|
||||
let token = localStorage.getItem('auth_token');
|
||||
|
||||
if (token) {
|
||||
// Check if token is expired
|
||||
if (this.isTokenExpired(token)) {
|
||||
console.warn('Token expired, removing from storage');
|
||||
localStorage.removeItem('auth_token');
|
||||
token = null;
|
||||
}
|
||||
}
|
||||
|
||||
if (token) {
|
||||
config.headers = {
|
||||
...config.headers,
|
||||
Authorization: `Bearer ${token}`,
|
||||
};
|
||||
} else {
|
||||
console.warn('No valid auth token found - authentication required');
|
||||
}
|
||||
|
||||
return config;
|
||||
},
|
||||
|
||||
onRequestError: async (error: any) => {
|
||||
console.error('Request interceptor error:', error);
|
||||
throw error;
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Logging Interceptor
|
||||
* Logs API requests and responses for debugging
|
||||
*/
|
||||
class LoggingInterceptor {
|
||||
static setup() {
|
||||
apiClient.addRequestInterceptor({
|
||||
onRequest: async (config: RequestConfig) => {
|
||||
const requestId = `req_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`;
|
||||
|
||||
console.group(`🚀 API Request [${requestId}]`);
|
||||
console.log('Method:', config.method);
|
||||
console.log('URL:', config.url);
|
||||
console.log('Headers:', config.headers);
|
||||
if (config.body && config.method !== 'GET') {
|
||||
console.log('Body:', config.body);
|
||||
}
|
||||
if (config.params) {
|
||||
console.log('Params:', config.params);
|
||||
}
|
||||
console.groupEnd();
|
||||
|
||||
// Add request ID to config for response correlation
|
||||
config.headers = {
|
||||
...config.headers,
|
||||
'X-Request-ID': requestId,
|
||||
};
|
||||
|
||||
return config;
|
||||
},
|
||||
});
|
||||
|
||||
apiClient.addResponseInterceptor({
|
||||
onResponse: async <T>(response: ApiResponse<T>) => {
|
||||
const requestId = response.meta?.requestId || 'unknown';
|
||||
|
||||
console.group(`✅ API Response [${requestId}]`);
|
||||
console.log('Status:', response.status);
|
||||
console.log('Data:', response.data);
|
||||
if (response.message) {
|
||||
console.log('Message:', response.message);
|
||||
}
|
||||
console.groupEnd();
|
||||
|
||||
return response;
|
||||
},
|
||||
|
||||
onResponseError: async (error: any) => {
|
||||
const requestId = error?.config?.headers?.[`X-Request-ID`] || 'unknown';
|
||||
|
||||
console.group(`❌ API Error [${requestId}]`);
|
||||
console.error('Status:', error?.response?.status);
|
||||
console.error('Error:', ApiErrorHandler.formatError(error));
|
||||
console.error('Full Error:', error);
|
||||
console.groupEnd();
|
||||
|
||||
throw error;
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Tenant Context Interceptor
|
||||
* Automatically adds tenant context to tenant-scoped requests
|
||||
*/
|
||||
class TenantInterceptor {
|
||||
private static currentTenantId: string | null = null;
|
||||
|
||||
static setCurrentTenant(tenantId: string | null) {
|
||||
this.currentTenantId = tenantId;
|
||||
}
|
||||
|
||||
static getCurrentTenant(): string | null {
|
||||
return this.currentTenantId;
|
||||
}
|
||||
|
||||
static setup() {
|
||||
apiClient.addRequestInterceptor({
|
||||
onRequest: async (config: RequestConfig) => {
|
||||
// Add tenant context to tenant-scoped endpoints
|
||||
if (this.currentTenantId && this.isTenantScopedEndpoint(config.url)) {
|
||||
config.headers = {
|
||||
...config.headers,
|
||||
'X-Tenant-ID': this.currentTenantId,
|
||||
};
|
||||
}
|
||||
|
||||
return config;
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
private static isTenantScopedEndpoint(url?: string): boolean {
|
||||
if (!url) return false;
|
||||
return url.includes('/tenants/') ||
|
||||
url.includes('/training/') ||
|
||||
url.includes('/forecasts/') ||
|
||||
url.includes('/notifications/');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Error Recovery Interceptor
|
||||
* Handles automatic token refresh and retry logic
|
||||
*/
|
||||
class ErrorRecoveryInterceptor {
|
||||
private static isRefreshing = false;
|
||||
private static failedQueue: Array<{
|
||||
resolve: (token: string) => void;
|
||||
reject: (error: any) => void;
|
||||
}> = [];
|
||||
|
||||
static setup() {
|
||||
apiClient.addResponseInterceptor({
|
||||
onResponseError: async (error: any) => {
|
||||
const originalRequest = error.config;
|
||||
|
||||
// Handle 401 errors with token refresh
|
||||
if (error?.response?.status === 401 && !originalRequest._retry) {
|
||||
if (this.isRefreshing) {
|
||||
// Queue the request while refresh is in progress
|
||||
return new Promise((resolve, reject) => {
|
||||
this.failedQueue.push({ resolve, reject });
|
||||
}).then(token => {
|
||||
return this.retryRequestWithNewToken(originalRequest, token as string);
|
||||
}).catch(err => {
|
||||
throw err;
|
||||
});
|
||||
}
|
||||
|
||||
originalRequest._retry = true;
|
||||
this.isRefreshing = true;
|
||||
|
||||
try {
|
||||
const refreshToken = localStorage.getItem('refresh_token');
|
||||
|
||||
if (!refreshToken) {
|
||||
throw new Error('No refresh token available');
|
||||
}
|
||||
|
||||
// Use direct fetch to avoid interceptor recursion
|
||||
const baseURL = (apiClient as any).baseURL || window.location.origin;
|
||||
const response = await fetch(`${baseURL}/api/v1/auth/refresh`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({ refresh_token: refreshToken }),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Token refresh failed: ${response.status}`);
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
const newToken = data.access_token;
|
||||
|
||||
if (!newToken) {
|
||||
throw new Error('No access token received');
|
||||
}
|
||||
|
||||
localStorage.setItem('auth_token', newToken);
|
||||
|
||||
// Update new refresh token if provided
|
||||
if (data.refresh_token) {
|
||||
localStorage.setItem('refresh_token', data.refresh_token);
|
||||
}
|
||||
|
||||
// Process failed queue
|
||||
this.processQueue(null, newToken);
|
||||
|
||||
// Retry original request with new token
|
||||
return this.retryRequestWithNewToken(originalRequest, newToken);
|
||||
|
||||
} catch (refreshError) {
|
||||
console.warn('Token refresh failed:', refreshError);
|
||||
this.processQueue(refreshError, null);
|
||||
|
||||
// Clear auth data and redirect to login
|
||||
localStorage.removeItem('auth_token');
|
||||
localStorage.removeItem('refresh_token');
|
||||
localStorage.removeItem('user_data');
|
||||
|
||||
// Only redirect if we're not already on the login page
|
||||
if (typeof window !== 'undefined' && !window.location.pathname.includes('/login')) {
|
||||
window.location.href = '/login';
|
||||
}
|
||||
|
||||
throw refreshError;
|
||||
} finally {
|
||||
this.isRefreshing = false;
|
||||
}
|
||||
}
|
||||
|
||||
throw error;
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
private static async retryRequestWithNewToken(originalRequest: any, token: string) {
|
||||
try {
|
||||
// Use direct fetch instead of apiClient to avoid interceptor recursion
|
||||
const url = originalRequest.url || originalRequest.endpoint;
|
||||
const method = originalRequest.method || 'GET';
|
||||
|
||||
const fetchOptions: RequestInit = {
|
||||
method,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Authorization': `Bearer ${token}`,
|
||||
...originalRequest.headers
|
||||
}
|
||||
};
|
||||
|
||||
// Add body for non-GET requests
|
||||
if (method !== 'GET' && originalRequest.body) {
|
||||
fetchOptions.body = typeof originalRequest.body === 'string'
|
||||
? originalRequest.body
|
||||
: JSON.stringify(originalRequest.body);
|
||||
}
|
||||
|
||||
// Add query parameters if present
|
||||
let fullUrl = url;
|
||||
if (originalRequest.params) {
|
||||
const urlWithParams = new URL(fullUrl, (apiClient as any).baseURL);
|
||||
Object.entries(originalRequest.params).forEach(([key, value]) => {
|
||||
if (value !== undefined && value !== null) {
|
||||
urlWithParams.searchParams.append(key, String(value));
|
||||
}
|
||||
});
|
||||
fullUrl = urlWithParams.toString();
|
||||
}
|
||||
|
||||
// Retry request with refreshed token
|
||||
|
||||
const response = await fetch(fullUrl, fetchOptions);
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Request failed: ${response.status}`);
|
||||
}
|
||||
|
||||
return await response.json();
|
||||
} catch (retryError) {
|
||||
console.warn('Request retry failed:', retryError);
|
||||
throw retryError;
|
||||
}
|
||||
}
|
||||
|
||||
private static processQueue(error: any, token: string | null) {
|
||||
this.failedQueue.forEach(({ resolve, reject }) => {
|
||||
if (error) {
|
||||
reject(error);
|
||||
} else {
|
||||
resolve(token!);
|
||||
}
|
||||
});
|
||||
|
||||
this.failedQueue = [];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Performance Monitoring Interceptor
|
||||
* Tracks API performance metrics
|
||||
*/
|
||||
class PerformanceInterceptor {
|
||||
private static metrics: Array<{
|
||||
url: string;
|
||||
method: string;
|
||||
duration: number;
|
||||
status: number;
|
||||
timestamp: number;
|
||||
}> = [];
|
||||
|
||||
static setup() {
|
||||
apiClient.addRequestInterceptor({
|
||||
onRequest: async (config: RequestConfig) => {
|
||||
config.metadata = {
|
||||
...config.metadata,
|
||||
startTime: Date.now(),
|
||||
};
|
||||
|
||||
return config;
|
||||
},
|
||||
});
|
||||
|
||||
apiClient.addResponseInterceptor({
|
||||
onResponse: async <T>(response: ApiResponse<T>) => {
|
||||
const startTime = response.metadata?.startTime;
|
||||
if (startTime) {
|
||||
const duration = Date.now() - startTime;
|
||||
this.recordMetric({
|
||||
url: response.metadata?.url || 'unknown',
|
||||
method: response.metadata?.method || 'unknown',
|
||||
duration,
|
||||
status: 200,
|
||||
timestamp: Date.now(),
|
||||
});
|
||||
}
|
||||
|
||||
return response;
|
||||
},
|
||||
|
||||
onResponseError: async (error: any) => {
|
||||
const startTime = error.config?.metadata?.startTime;
|
||||
if (startTime) {
|
||||
const duration = Date.now() - startTime;
|
||||
this.recordMetric({
|
||||
url: error.config?.url || 'unknown',
|
||||
method: error.config?.method || 'unknown',
|
||||
duration,
|
||||
status: error?.response?.status || 0,
|
||||
timestamp: Date.now(),
|
||||
});
|
||||
}
|
||||
|
||||
throw error;
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
private static recordMetric(metric: any) {
|
||||
this.metrics.push(metric);
|
||||
|
||||
// Keep only last 1000 metrics
|
||||
if (this.metrics.length > 1000) {
|
||||
this.metrics = this.metrics.slice(-1000);
|
||||
}
|
||||
}
|
||||
|
||||
static getMetrics() {
|
||||
return [...this.metrics];
|
||||
}
|
||||
|
||||
static getAverageResponseTime(): number {
|
||||
if (this.metrics.length === 0) return 0;
|
||||
|
||||
const total = this.metrics.reduce((sum, metric) => sum + metric.duration, 0);
|
||||
return Math.round(total / this.metrics.length);
|
||||
}
|
||||
|
||||
static getErrorRate(): number {
|
||||
if (this.metrics.length === 0) return 0;
|
||||
|
||||
const errorCount = this.metrics.filter(metric => metric.status >= 400).length;
|
||||
return Math.round((errorCount / this.metrics.length) * 100);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Setup all interceptors
|
||||
* IMPORTANT: Order matters! ErrorRecoveryInterceptor must be first to handle token refresh
|
||||
*/
|
||||
export const setupInterceptors = () => {
|
||||
// 1. Error recovery first (handles 401 and token refresh)
|
||||
ErrorRecoveryInterceptor.setup();
|
||||
|
||||
// 2. Authentication (adds Bearer tokens)
|
||||
AuthInterceptor.setup();
|
||||
|
||||
// 3. Tenant context
|
||||
TenantInterceptor.setup();
|
||||
|
||||
// 4. Development-only interceptors
|
||||
const isDevelopment = true; // Temporarily set to true for development
|
||||
if (isDevelopment) {
|
||||
LoggingInterceptor.setup();
|
||||
PerformanceInterceptor.setup();
|
||||
}
|
||||
};
|
||||
|
||||
// Export interceptor classes for manual setup if needed
|
||||
export {
|
||||
AuthInterceptor,
|
||||
LoggingInterceptor,
|
||||
TenantInterceptor,
|
||||
ErrorRecoveryInterceptor,
|
||||
PerformanceInterceptor,
|
||||
};
|
||||
@@ -1,115 +0,0 @@
|
||||
// frontend/src/api/client/types.ts
|
||||
/**
|
||||
* Core API Client Types
|
||||
*/
|
||||
|
||||
export interface RequestConfig {
|
||||
method?: 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE';
|
||||
headers?: Record<string, string>;
|
||||
params?: Record<string, any>;
|
||||
body?: any;
|
||||
url?: string;
|
||||
timeout?: number;
|
||||
retries?: number;
|
||||
cache?: boolean;
|
||||
cacheTTL?: number;
|
||||
optimistic?: boolean;
|
||||
background?: boolean;
|
||||
metadata?: any;
|
||||
}
|
||||
|
||||
export interface ApiResponse<T = any> {
|
||||
data: T;
|
||||
message?: string;
|
||||
status: string;
|
||||
timestamp?: string;
|
||||
metadata?: any;
|
||||
meta?: {
|
||||
page?: number;
|
||||
limit?: number;
|
||||
total?: number;
|
||||
hasNext?: boolean;
|
||||
hasPrev?: boolean;
|
||||
requestId?: string;
|
||||
};
|
||||
}
|
||||
|
||||
export interface ApiError {
|
||||
message: string;
|
||||
detail?: string;
|
||||
code?: string;
|
||||
field?: string;
|
||||
timestamp?: string;
|
||||
service?: string;
|
||||
requestId?: string;
|
||||
}
|
||||
|
||||
export interface PaginatedResponse<T> {
|
||||
data: T[];
|
||||
pagination: {
|
||||
page: number;
|
||||
limit: number;
|
||||
total: number;
|
||||
totalPages: number;
|
||||
hasNext: boolean;
|
||||
hasPrev: boolean;
|
||||
};
|
||||
}
|
||||
|
||||
export interface UploadProgress {
|
||||
loaded: number;
|
||||
total: number;
|
||||
percentage: number;
|
||||
}
|
||||
|
||||
export interface UploadConfig extends RequestConfig {
|
||||
onProgress?: (progress: UploadProgress) => void;
|
||||
maxFileSize?: number;
|
||||
allowedTypes?: string[];
|
||||
}
|
||||
|
||||
// Request/Response interceptor types
|
||||
export interface RequestInterceptor {
|
||||
onRequest?: (config: RequestConfig) => RequestConfig | Promise<RequestConfig>;
|
||||
onRequestError?: (error: any) => any;
|
||||
}
|
||||
|
||||
export interface ResponseInterceptor {
|
||||
onResponse?: <T>(response: ApiResponse<T>) => ApiResponse<T> | Promise<ApiResponse<T>>;
|
||||
onResponseError?: (error: any) => any;
|
||||
}
|
||||
|
||||
// Cache types
|
||||
export interface CacheEntry<T = any> {
|
||||
data: T;
|
||||
timestamp: number;
|
||||
ttl: number;
|
||||
key: string;
|
||||
}
|
||||
|
||||
export interface CacheStrategy {
|
||||
key: (url: string, params?: any) => string;
|
||||
ttl: number;
|
||||
enabled: boolean;
|
||||
}
|
||||
|
||||
// Metrics types
|
||||
export interface RequestMetrics {
|
||||
url: string;
|
||||
method: string;
|
||||
duration: number;
|
||||
status: number;
|
||||
size: number;
|
||||
timestamp: number;
|
||||
cached: boolean;
|
||||
retries: number;
|
||||
}
|
||||
|
||||
export interface ClientMetrics {
|
||||
totalRequests: number;
|
||||
successfulRequests: number;
|
||||
failedRequests: number;
|
||||
averageResponseTime: number;
|
||||
cacheHitRate: number;
|
||||
errorRate: number;
|
||||
}
|
||||
@@ -1,66 +0,0 @@
|
||||
// frontend/src/api/hooks/index.ts
|
||||
/**
|
||||
* Main Hooks Export
|
||||
*/
|
||||
|
||||
export { useAuth, useAuthHeaders } from './useAuth';
|
||||
export { useTenant } from './useTenant';
|
||||
export { useSales } from './useSales';
|
||||
export { useExternal } from './useExternal';
|
||||
export { useTraining } from './useTraining';
|
||||
export { useForecast } from './useForecast';
|
||||
export { useNotification } from './useNotification';
|
||||
export { useOnboarding, useOnboardingStep } from './useOnboarding';
|
||||
export { useInventory, useInventoryDashboard, useInventoryItem, useInventoryProducts } from './useInventory';
|
||||
export { useRecipes, useProduction } from './useRecipes';
|
||||
export {
|
||||
useCurrentProcurementPlan,
|
||||
useProcurementPlanByDate,
|
||||
useProcurementPlan,
|
||||
useProcurementPlans,
|
||||
usePlanRequirements,
|
||||
useCriticalRequirements,
|
||||
useProcurementDashboard,
|
||||
useGenerateProcurementPlan,
|
||||
useUpdatePlanStatus,
|
||||
useTriggerDailyScheduler,
|
||||
useProcurementHealth,
|
||||
useProcurementPlanDashboard,
|
||||
useProcurementPlanActions
|
||||
} from './useProcurement';
|
||||
|
||||
// Import hooks for combined usage
|
||||
import { useAuth } from './useAuth';
|
||||
import { useTenant } from './useTenant';
|
||||
import { useSales } from './useSales';
|
||||
import { useExternal } from './useExternal';
|
||||
import { useTraining } from './useTraining';
|
||||
import { useForecast } from './useForecast';
|
||||
import { useNotification } from './useNotification';
|
||||
import { useOnboarding } from './useOnboarding';
|
||||
import { useInventory } from './useInventory';
|
||||
|
||||
// Combined hook for common operations
|
||||
export const useApiHooks = () => {
|
||||
const auth = useAuth();
|
||||
const tenant = useTenant();
|
||||
const sales = useSales();
|
||||
const external = useExternal();
|
||||
const training = useTraining({ disablePolling: true }); // Disable polling by default
|
||||
const forecast = useForecast();
|
||||
const notification = useNotification();
|
||||
const onboarding = useOnboarding();
|
||||
const inventory = useInventory();
|
||||
|
||||
return {
|
||||
auth,
|
||||
tenant,
|
||||
sales,
|
||||
external,
|
||||
training,
|
||||
forecast,
|
||||
notification,
|
||||
onboarding,
|
||||
inventory
|
||||
};
|
||||
};
|
||||
@@ -1,205 +0,0 @@
|
||||
// frontend/src/api/hooks/useAuth.ts
|
||||
/**
|
||||
* Authentication Hooks
|
||||
* React hooks for authentication operations
|
||||
*/
|
||||
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import { authService } from '../services';
|
||||
import type {
|
||||
LoginRequest,
|
||||
LoginResponse,
|
||||
RegisterRequest,
|
||||
UserResponse,
|
||||
PasswordResetRequest,
|
||||
} from '../types';
|
||||
|
||||
// Token management
|
||||
const TOKEN_KEY = 'auth_token';
|
||||
const REFRESH_TOKEN_KEY = 'refresh_token';
|
||||
const USER_KEY = 'user_data';
|
||||
|
||||
export const useAuth = () => {
|
||||
const [user, setUser] = useState<UserResponse | null>(null);
|
||||
const [isAuthenticated, setIsAuthenticated] = useState(false);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
// Initialize auth state from localStorage
|
||||
useEffect(() => {
|
||||
const initializeAuth = async () => {
|
||||
try {
|
||||
const token = localStorage.getItem(TOKEN_KEY);
|
||||
const userData = localStorage.getItem(USER_KEY);
|
||||
|
||||
if (token && userData) {
|
||||
setUser(JSON.parse(userData));
|
||||
setIsAuthenticated(true);
|
||||
|
||||
// Verify token is still valid
|
||||
try {
|
||||
const currentUser = await authService.getCurrentUser();
|
||||
setUser(currentUser);
|
||||
} catch (error) {
|
||||
// Token might be expired - let interceptors handle refresh
|
||||
// Only logout if refresh also fails (handled by ErrorRecoveryInterceptor)
|
||||
console.log('Token verification failed, interceptors will handle refresh if possible');
|
||||
|
||||
// Check if we have a refresh token - if not, logout immediately
|
||||
const refreshToken = localStorage.getItem(REFRESH_TOKEN_KEY);
|
||||
if (!refreshToken) {
|
||||
console.log('No refresh token available, logging out');
|
||||
logout();
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Auth initialization error:', error);
|
||||
logout();
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
initializeAuth();
|
||||
}, []);
|
||||
|
||||
const login = useCallback(async (credentials: LoginRequest): Promise<void> => {
|
||||
try {
|
||||
setIsLoading(true);
|
||||
setError(null);
|
||||
|
||||
const response = await authService.login(credentials);
|
||||
|
||||
// Store tokens and user data
|
||||
localStorage.setItem(TOKEN_KEY, response.access_token);
|
||||
if (response.refresh_token) {
|
||||
localStorage.setItem(REFRESH_TOKEN_KEY, response.refresh_token);
|
||||
}
|
||||
if (response.user) {
|
||||
localStorage.setItem(USER_KEY, JSON.stringify(response.user));
|
||||
setUser(response.user);
|
||||
}
|
||||
|
||||
setIsAuthenticated(true);
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : 'Login failed';
|
||||
setError(message);
|
||||
throw error;
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const register = useCallback(async (data: RegisterRequest): Promise<void> => {
|
||||
try {
|
||||
setIsLoading(true);
|
||||
setError(null);
|
||||
|
||||
const response = await authService.register(data);
|
||||
|
||||
// Auto-login after successful registration
|
||||
if (response && response.user) {
|
||||
await login({ email: data.email, password: data.password });
|
||||
} else {
|
||||
// If response doesn't have user property, registration might still be successful
|
||||
// Try to login anyway in case the user was created but response format is different
|
||||
await login({ email: data.email, password: data.password });
|
||||
}
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : 'Registration failed';
|
||||
setError(message);
|
||||
throw error;
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, [login]);
|
||||
|
||||
const logout = useCallback(async (): Promise<void> => {
|
||||
try {
|
||||
// Call logout endpoint if authenticated
|
||||
if (isAuthenticated) {
|
||||
await authService.logout();
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Logout error:', error);
|
||||
} finally {
|
||||
// Clear local state regardless of API call success
|
||||
localStorage.removeItem(TOKEN_KEY);
|
||||
localStorage.removeItem(REFRESH_TOKEN_KEY);
|
||||
localStorage.removeItem(USER_KEY);
|
||||
setUser(null);
|
||||
setIsAuthenticated(false);
|
||||
setError(null);
|
||||
}
|
||||
}, [isAuthenticated]);
|
||||
|
||||
const updateProfile = useCallback(async (data: Partial<UserResponse>): Promise<void> => {
|
||||
try {
|
||||
setIsLoading(true);
|
||||
setError(null);
|
||||
|
||||
const updatedUser = await authService.updateProfile(data);
|
||||
setUser(updatedUser);
|
||||
localStorage.setItem(USER_KEY, JSON.stringify(updatedUser));
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : 'Profile update failed';
|
||||
setError(message);
|
||||
throw error;
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const requestPasswordReset = useCallback(async (data: PasswordResetRequest): Promise<void> => {
|
||||
try {
|
||||
setIsLoading(true);
|
||||
setError(null);
|
||||
await authService.requestPasswordReset(data);
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : 'Password reset request failed';
|
||||
setError(message);
|
||||
throw error;
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const changePassword = useCallback(async (currentPassword: string, newPassword: string): Promise<void> => {
|
||||
try {
|
||||
setIsLoading(true);
|
||||
setError(null);
|
||||
await authService.changePassword(currentPassword, newPassword);
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : 'Password change failed';
|
||||
setError(message);
|
||||
throw error;
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
return {
|
||||
user,
|
||||
isAuthenticated,
|
||||
isLoading,
|
||||
error,
|
||||
login,
|
||||
register,
|
||||
logout,
|
||||
updateProfile,
|
||||
requestPasswordReset,
|
||||
changePassword,
|
||||
clearError: () => setError(null),
|
||||
};
|
||||
};
|
||||
|
||||
// Hook for getting authentication headers
|
||||
export const useAuthHeaders = () => {
|
||||
const getAuthHeaders = useCallback(() => {
|
||||
const token = localStorage.getItem(TOKEN_KEY);
|
||||
return token ? { Authorization: `Bearer ${token}` } : {};
|
||||
}, []);
|
||||
|
||||
return { getAuthHeaders };
|
||||
};
|
||||
@@ -1,238 +0,0 @@
|
||||
// frontend/src/api/hooks/useExternal.ts
|
||||
/**
|
||||
* External Data Management Hooks
|
||||
* Handles weather and traffic data operations
|
||||
*/
|
||||
|
||||
import { useState, useCallback } from 'react';
|
||||
import { externalService } from '../services/external.service';
|
||||
import type { WeatherData, TrafficData, WeatherForecast, HourlyForecast } from '../services/external.service';
|
||||
|
||||
export const useExternal = () => {
|
||||
const [weatherData, setWeatherData] = useState<WeatherData | null>(null);
|
||||
const [trafficData, setTrafficData] = useState<TrafficData | null>(null);
|
||||
const [weatherForecast, setWeatherForecast] = useState<WeatherForecast[]>([]);
|
||||
const [hourlyForecast, setHourlyForecast] = useState<HourlyForecast[]>([]);
|
||||
const [trafficForecast, setTrafficForecast] = useState<TrafficData[]>([]);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
/**
|
||||
* Get Current Weather
|
||||
*/
|
||||
const getCurrentWeather = useCallback(async (
|
||||
tenantId: string,
|
||||
lat: number,
|
||||
lon: number
|
||||
): Promise<WeatherData> => {
|
||||
try {
|
||||
setIsLoading(true);
|
||||
setError(null);
|
||||
|
||||
const weather = await externalService.getCurrentWeather(tenantId, lat, lon);
|
||||
setWeatherData(weather);
|
||||
|
||||
return weather;
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : 'Failed to get weather data';
|
||||
setError(message);
|
||||
throw error;
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
/**
|
||||
* Get Weather Forecast
|
||||
*/
|
||||
const getWeatherForecast = useCallback(async (
|
||||
tenantId: string,
|
||||
lat: number,
|
||||
lon: number,
|
||||
days: number = 7
|
||||
): Promise<WeatherForecast[]> => {
|
||||
try {
|
||||
setIsLoading(true);
|
||||
setError(null);
|
||||
|
||||
const forecast = await externalService.getWeatherForecast(tenantId, lat, lon, days);
|
||||
setWeatherForecast(forecast);
|
||||
|
||||
return forecast;
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : 'Failed to get weather forecast';
|
||||
setError(message);
|
||||
throw error;
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
/**
|
||||
* Get Hourly Weather Forecast
|
||||
*/
|
||||
const getHourlyWeatherForecast = useCallback(async (
|
||||
tenantId: string,
|
||||
lat: number,
|
||||
lon: number,
|
||||
hours: number = 48
|
||||
): Promise<HourlyForecast[]> => {
|
||||
try {
|
||||
setIsLoading(true);
|
||||
setError(null);
|
||||
|
||||
const forecast = await externalService.getHourlyWeatherForecast(tenantId, lat, lon, hours);
|
||||
setHourlyForecast(forecast);
|
||||
|
||||
return forecast;
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : 'Failed to get hourly weather forecast';
|
||||
setError(message);
|
||||
throw error;
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
/**
|
||||
* Get Historical Weather Data
|
||||
*/
|
||||
const getHistoricalWeather = useCallback(async (
|
||||
tenantId: string,
|
||||
lat: number,
|
||||
lon: number,
|
||||
startDate: string,
|
||||
endDate: string
|
||||
): Promise<WeatherData[]> => {
|
||||
try {
|
||||
setIsLoading(true);
|
||||
setError(null);
|
||||
|
||||
const data = await externalService.getHistoricalWeather(tenantId, lat, lon, startDate, endDate);
|
||||
|
||||
return data;
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : 'Failed to get historical weather';
|
||||
setError(message);
|
||||
throw error;
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
/**
|
||||
* Get Current Traffic
|
||||
*/
|
||||
const getCurrentTraffic = useCallback(async (
|
||||
tenantId: string,
|
||||
lat: number,
|
||||
lon: number
|
||||
): Promise<TrafficData> => {
|
||||
try {
|
||||
setIsLoading(true);
|
||||
setError(null);
|
||||
|
||||
const traffic = await externalService.getCurrentTraffic(tenantId, lat, lon);
|
||||
setTrafficData(traffic);
|
||||
|
||||
return traffic;
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : 'Failed to get traffic data';
|
||||
setError(message);
|
||||
throw error;
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
/**
|
||||
* Get Traffic Forecast
|
||||
*/
|
||||
const getTrafficForecast = useCallback(async (
|
||||
tenantId: string,
|
||||
lat: number,
|
||||
lon: number,
|
||||
hours: number = 24
|
||||
): Promise<TrafficData[]> => {
|
||||
try {
|
||||
setIsLoading(true);
|
||||
setError(null);
|
||||
|
||||
const forecast = await externalService.getTrafficForecast(tenantId, lat, lon, hours);
|
||||
setTrafficForecast(forecast);
|
||||
|
||||
return forecast;
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : 'Failed to get traffic forecast';
|
||||
setError(message);
|
||||
throw error;
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
/**
|
||||
* Get Historical Traffic Data
|
||||
*/
|
||||
const getHistoricalTraffic = useCallback(async (
|
||||
tenantId: string,
|
||||
lat: number,
|
||||
lon: number,
|
||||
startDate: string,
|
||||
endDate: string
|
||||
): Promise<TrafficData[]> => {
|
||||
try {
|
||||
setIsLoading(true);
|
||||
setError(null);
|
||||
|
||||
const data = await externalService.getHistoricalTraffic(tenantId, lat, lon, startDate, endDate);
|
||||
|
||||
return data;
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : 'Failed to get historical traffic';
|
||||
setError(message);
|
||||
throw error;
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
/**
|
||||
* Test External Services Connectivity
|
||||
*/
|
||||
const testConnectivity = useCallback(async (tenantId: string) => {
|
||||
try {
|
||||
setIsLoading(true);
|
||||
setError(null);
|
||||
|
||||
const results = await externalService.testConnectivity(tenantId);
|
||||
|
||||
return results;
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : 'Failed to test connectivity';
|
||||
setError(message);
|
||||
throw error;
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
return {
|
||||
weatherData,
|
||||
trafficData,
|
||||
weatherForecast,
|
||||
hourlyForecast,
|
||||
trafficForecast,
|
||||
isLoading,
|
||||
error,
|
||||
getCurrentWeather,
|
||||
getWeatherForecast,
|
||||
getHourlyWeatherForecast,
|
||||
getHistoricalWeather,
|
||||
getCurrentTraffic,
|
||||
getTrafficForecast,
|
||||
getHistoricalTraffic,
|
||||
testConnectivity,
|
||||
clearError: () => setError(null),
|
||||
};
|
||||
};
|
||||
@@ -1,229 +0,0 @@
|
||||
// frontend/src/api/hooks/useForecast.ts
|
||||
/**
|
||||
* Forecasting Operations Hooks
|
||||
*/
|
||||
|
||||
import { useState, useCallback } from 'react';
|
||||
import { forecastingService } from '../services';
|
||||
import type {
|
||||
SingleForecastRequest,
|
||||
BatchForecastRequest,
|
||||
ForecastResponse,
|
||||
BatchForecastResponse,
|
||||
ForecastAlert,
|
||||
QuickForecast,
|
||||
} from '../types';
|
||||
|
||||
export const useForecast = () => {
|
||||
const [forecasts, setForecasts] = useState<ForecastResponse[]>([]);
|
||||
const [batchForecasts, setBatchForecasts] = useState<BatchForecastResponse[]>([]);
|
||||
const [quickForecasts, setQuickForecasts] = useState<QuickForecast[]>([]);
|
||||
const [alerts, setAlerts] = useState<ForecastAlert[]>([]);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const createSingleForecast = useCallback(async (
|
||||
tenantId: string,
|
||||
request: SingleForecastRequest
|
||||
): Promise<ForecastResponse[]> => {
|
||||
try {
|
||||
setIsLoading(true);
|
||||
setError(null);
|
||||
|
||||
const newForecasts = await forecastingService.createSingleForecast(tenantId, request);
|
||||
setForecasts(prev => [...newForecasts, ...(prev || [])]);
|
||||
|
||||
return newForecasts;
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : 'Failed to create forecast';
|
||||
setError(message);
|
||||
throw error;
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const createBatchForecast = useCallback(async (
|
||||
tenantId: string,
|
||||
request: BatchForecastRequest
|
||||
): Promise<BatchForecastResponse> => {
|
||||
try {
|
||||
setIsLoading(true);
|
||||
setError(null);
|
||||
|
||||
const batchForecast = await forecastingService.createBatchForecast(tenantId, request);
|
||||
setBatchForecasts(prev => [batchForecast, ...(prev || [])]);
|
||||
|
||||
return batchForecast;
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : 'Failed to create batch forecast';
|
||||
setError(message);
|
||||
throw error;
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const getForecasts = useCallback(async (tenantId: string): Promise<ForecastResponse[]> => {
|
||||
try {
|
||||
setIsLoading(true);
|
||||
setError(null);
|
||||
|
||||
const response = await forecastingService.getForecasts(tenantId);
|
||||
setForecasts(response.data);
|
||||
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : 'Failed to get forecasts';
|
||||
setError(message);
|
||||
throw error;
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const getBatchForecastStatus = useCallback(async (
|
||||
tenantId: string,
|
||||
batchId: string
|
||||
): Promise<BatchForecastResponse> => {
|
||||
try {
|
||||
const batchForecast = await forecastingService.getBatchForecastStatus(tenantId, batchId);
|
||||
|
||||
// Update batch forecast in state
|
||||
setBatchForecasts(prev => (prev || []).map(bf =>
|
||||
bf.id === batchId ? batchForecast : bf
|
||||
));
|
||||
|
||||
return batchForecast;
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : 'Failed to get batch forecast status';
|
||||
setError(message);
|
||||
throw error;
|
||||
}
|
||||
}, []);
|
||||
|
||||
const getQuickForecasts = useCallback(async (tenantId: string): Promise<QuickForecast[]> => {
|
||||
try {
|
||||
setIsLoading(true);
|
||||
setError(null);
|
||||
|
||||
const quickForecastData = await forecastingService.getQuickForecasts(tenantId);
|
||||
setQuickForecasts(quickForecastData);
|
||||
|
||||
return quickForecastData;
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : 'Failed to get quick forecasts';
|
||||
setError(message);
|
||||
throw error;
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const getForecastAlerts = useCallback(async (tenantId: string): Promise<any> => {
|
||||
try {
|
||||
setIsLoading(true);
|
||||
setError(null);
|
||||
|
||||
const response = await forecastingService.getForecastAlerts(tenantId);
|
||||
|
||||
// Handle different response formats
|
||||
if (response && 'data' in response && response.data) {
|
||||
// Standard paginated format: { data: [...], pagination: {...} }
|
||||
setAlerts(response.data);
|
||||
return { alerts: response.data, ...response };
|
||||
} else if (response && Array.isArray(response)) {
|
||||
// Direct array format
|
||||
setAlerts(response);
|
||||
return { alerts: response };
|
||||
} else if (Array.isArray(response)) {
|
||||
// Direct array format
|
||||
setAlerts(response);
|
||||
return { alerts: response };
|
||||
} else {
|
||||
// Unknown format - return empty
|
||||
setAlerts([]);
|
||||
return { alerts: [] };
|
||||
}
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : 'Failed to get forecast alerts';
|
||||
setError(message);
|
||||
throw error;
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const acknowledgeForecastAlert = useCallback(async (
|
||||
tenantId: string,
|
||||
alertId: string
|
||||
): Promise<void> => {
|
||||
try {
|
||||
setIsLoading(true);
|
||||
setError(null);
|
||||
|
||||
const acknowledgedAlert = await forecastingService.acknowledgeForecastAlert(tenantId, alertId);
|
||||
setAlerts(prev => (prev || []).map(alert =>
|
||||
alert.id === alertId ? acknowledgedAlert : alert
|
||||
));
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : 'Failed to acknowledge alert';
|
||||
setError(message);
|
||||
throw error;
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const exportForecasts = useCallback(async (
|
||||
tenantId: string,
|
||||
format: 'csv' | 'excel' | 'json',
|
||||
params?: {
|
||||
inventory_product_id?: string; // Primary way to filter by product
|
||||
product_name?: string; // For backward compatibility
|
||||
start_date?: string;
|
||||
end_date?: string;
|
||||
}
|
||||
): Promise<void> => {
|
||||
try {
|
||||
setIsLoading(true);
|
||||
setError(null);
|
||||
|
||||
const blob = await forecastingService.exportForecasts(tenantId, format, params);
|
||||
|
||||
// Create download link
|
||||
const url = window.URL.createObjectURL(blob);
|
||||
const link = document.createElement('a');
|
||||
link.href = url;
|
||||
link.download = `forecasts.${format}`;
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
document.body.removeChild(link);
|
||||
window.URL.revokeObjectURL(url);
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : 'Export failed';
|
||||
setError(message);
|
||||
throw error;
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
return {
|
||||
forecasts,
|
||||
batchForecasts,
|
||||
quickForecasts,
|
||||
alerts,
|
||||
isLoading,
|
||||
error,
|
||||
createSingleForecast,
|
||||
createBatchForecast,
|
||||
getForecasts,
|
||||
getBatchForecastStatus,
|
||||
getQuickForecasts,
|
||||
getForecastAlerts,
|
||||
acknowledgeForecastAlert,
|
||||
exportForecasts,
|
||||
clearError: () => setError(null),
|
||||
};
|
||||
};
|
||||
@@ -1,536 +0,0 @@
|
||||
// frontend/src/api/hooks/useInventory.ts
|
||||
/**
|
||||
* Inventory Management React Hook
|
||||
* Provides comprehensive state management for inventory operations
|
||||
*/
|
||||
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import toast from 'react-hot-toast';
|
||||
|
||||
import {
|
||||
inventoryService,
|
||||
InventoryItem,
|
||||
StockLevel,
|
||||
StockMovement,
|
||||
InventorySearchParams,
|
||||
CreateInventoryItemRequest,
|
||||
UpdateInventoryItemRequest,
|
||||
StockAdjustmentRequest,
|
||||
PaginatedResponse,
|
||||
InventoryDashboardData
|
||||
} from '../services/inventory.service';
|
||||
import type { ProductInfo } from '../types';
|
||||
|
||||
import { useTenantId } from '../../hooks/useTenantId';
|
||||
|
||||
// ========== HOOK INTERFACES ==========
|
||||
|
||||
interface UseInventoryReturn {
|
||||
// State
|
||||
items: InventoryItem[];
|
||||
stockLevels: Record<string, StockLevel>;
|
||||
movements: StockMovement[];
|
||||
dashboardData: InventoryDashboardData | null;
|
||||
isLoading: boolean;
|
||||
error: string | null;
|
||||
pagination: {
|
||||
page: number;
|
||||
limit: number;
|
||||
total: number;
|
||||
totalPages: number;
|
||||
};
|
||||
|
||||
// Actions
|
||||
loadItems: (params?: InventorySearchParams) => Promise<void>;
|
||||
loadItem: (itemId: string) => Promise<InventoryItem | null>;
|
||||
createItem: (data: CreateInventoryItemRequest) => Promise<InventoryItem | null>;
|
||||
updateItem: (itemId: string, data: UpdateInventoryItemRequest) => Promise<InventoryItem | null>;
|
||||
deleteItem: (itemId: string) => Promise<boolean>;
|
||||
|
||||
// Stock operations
|
||||
loadStockLevels: () => Promise<void>;
|
||||
adjustStock: (itemId: string, adjustment: StockAdjustmentRequest) => Promise<StockMovement | null>;
|
||||
loadMovements: (params?: any) => Promise<void>;
|
||||
|
||||
|
||||
// Dashboard
|
||||
loadDashboard: () => Promise<void>;
|
||||
|
||||
// Utility
|
||||
searchItems: (query: string) => Promise<InventoryItem[]>;
|
||||
refresh: () => Promise<void>;
|
||||
clearError: () => void;
|
||||
}
|
||||
|
||||
interface UseInventoryDashboardReturn {
|
||||
dashboardData: InventoryDashboardData | null;
|
||||
isLoading: boolean;
|
||||
error: string | null;
|
||||
refresh: () => Promise<void>;
|
||||
}
|
||||
|
||||
interface UseInventoryItemReturn {
|
||||
item: InventoryItem | null;
|
||||
stockLevel: StockLevel | null;
|
||||
recentMovements: StockMovement[];
|
||||
isLoading: boolean;
|
||||
error: string | null;
|
||||
updateItem: (data: UpdateInventoryItemRequest) => Promise<boolean>;
|
||||
adjustStock: (adjustment: StockAdjustmentRequest) => Promise<boolean>;
|
||||
refresh: () => Promise<void>;
|
||||
}
|
||||
|
||||
// ========== MAIN INVENTORY HOOK ==========
|
||||
|
||||
export const useInventory = (autoLoad = true): UseInventoryReturn => {
|
||||
const { tenantId } = useTenantId();
|
||||
|
||||
// State
|
||||
const [items, setItems] = useState<InventoryItem[]>([]);
|
||||
const [stockLevels, setStockLevels] = useState<Record<string, StockLevel>>({});
|
||||
const [movements, setMovements] = useState<StockMovement[]>([]);
|
||||
const [dashboardData, setDashboardData] = useState<InventoryDashboardData | null>(null);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [pagination, setPagination] = useState({
|
||||
page: 1,
|
||||
limit: 20,
|
||||
total: 0,
|
||||
totalPages: 0
|
||||
});
|
||||
|
||||
// Clear error
|
||||
const clearError = useCallback(() => setError(null), []);
|
||||
|
||||
// Load inventory items
|
||||
const loadItems = useCallback(async (params?: InventorySearchParams) => {
|
||||
if (!tenantId) return;
|
||||
|
||||
setIsLoading(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
const response = await inventoryService.getInventoryItems(tenantId, params);
|
||||
console.log('🔄 useInventory: Loaded items:', response.items);
|
||||
setItems(response.items || []); // Ensure it's always an array
|
||||
setPagination({
|
||||
page: response.page || 1,
|
||||
limit: response.limit || 20,
|
||||
total: response.total || 0,
|
||||
totalPages: response.total_pages || 0
|
||||
});
|
||||
} catch (err: any) {
|
||||
console.error('❌ useInventory: Error loading items:', err);
|
||||
const errorMessage = err.response?.data?.detail || err.message || 'Error loading inventory items';
|
||||
|
||||
setError(errorMessage);
|
||||
setItems([]); // Set empty array on error
|
||||
|
||||
// Show appropriate error message
|
||||
if (err.response?.status === 401) {
|
||||
console.error('❌ useInventory: Authentication failed');
|
||||
} else if (err.response?.status === 403) {
|
||||
toast.error('No tienes permisos para acceder a este inventario');
|
||||
} else {
|
||||
toast.error(errorMessage);
|
||||
}
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, [tenantId]);
|
||||
|
||||
// Load single item
|
||||
const loadItem = useCallback(async (itemId: string): Promise<InventoryItem | null> => {
|
||||
if (!tenantId) return null;
|
||||
|
||||
try {
|
||||
const item = await inventoryService.getInventoryItem(tenantId, itemId);
|
||||
|
||||
// Update in local state if it exists
|
||||
setItems(prev => prev.map(i => i.id === itemId ? item : i));
|
||||
|
||||
return item;
|
||||
} catch (err: any) {
|
||||
const errorMessage = err.response?.data?.detail || err.message || 'Error loading item';
|
||||
setError(errorMessage);
|
||||
return null;
|
||||
}
|
||||
}, [tenantId]);
|
||||
|
||||
// Create item
|
||||
const createItem = useCallback(async (data: CreateInventoryItemRequest): Promise<InventoryItem | null> => {
|
||||
if (!tenantId) return null;
|
||||
|
||||
setIsLoading(true);
|
||||
|
||||
try {
|
||||
const newItem = await inventoryService.createInventoryItem(tenantId, data);
|
||||
setItems(prev => [newItem, ...prev]);
|
||||
toast.success(`Created ${newItem.name} successfully`);
|
||||
return newItem;
|
||||
} catch (err: any) {
|
||||
const errorMessage = err.response?.data?.detail || err.message || 'Error creating item';
|
||||
setError(errorMessage);
|
||||
toast.error(errorMessage);
|
||||
return null;
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, [tenantId]);
|
||||
|
||||
// Update item
|
||||
const updateItem = useCallback(async (
|
||||
itemId: string,
|
||||
data: UpdateInventoryItemRequest
|
||||
): Promise<InventoryItem | null> => {
|
||||
if (!tenantId) return null;
|
||||
|
||||
try {
|
||||
const updatedItem = await inventoryService.updateInventoryItem(tenantId, itemId, data);
|
||||
setItems(prev => prev.map(i => i.id === itemId ? updatedItem : i));
|
||||
toast.success(`Updated ${updatedItem.name} successfully`);
|
||||
return updatedItem;
|
||||
} catch (err: any) {
|
||||
const errorMessage = err.response?.data?.detail || err.message || 'Error updating item';
|
||||
setError(errorMessage);
|
||||
toast.error(errorMessage);
|
||||
return null;
|
||||
}
|
||||
}, [tenantId]);
|
||||
|
||||
// Delete item
|
||||
const deleteItem = useCallback(async (itemId: string): Promise<boolean> => {
|
||||
if (!tenantId) return false;
|
||||
|
||||
try {
|
||||
await inventoryService.deleteInventoryItem(tenantId, itemId);
|
||||
setItems(prev => prev.filter(i => i.id !== itemId));
|
||||
toast.success('Item deleted successfully');
|
||||
return true;
|
||||
} catch (err: any) {
|
||||
const errorMessage = err.response?.data?.detail || err.message || 'Error deleting item';
|
||||
setError(errorMessage);
|
||||
toast.error(errorMessage);
|
||||
return false;
|
||||
}
|
||||
}, [tenantId]);
|
||||
|
||||
// Load stock levels
|
||||
const loadStockLevels = useCallback(async () => {
|
||||
if (!tenantId) return;
|
||||
|
||||
try {
|
||||
const levels = await inventoryService.getAllStockLevels(tenantId);
|
||||
const levelMap = levels.reduce((acc, level) => {
|
||||
acc[level.item_id] = level;
|
||||
return acc;
|
||||
}, {} as Record<string, StockLevel>);
|
||||
setStockLevels(levelMap);
|
||||
} catch (err: any) {
|
||||
console.error('Error loading stock levels:', err);
|
||||
// Don't show toast error for this as it's not critical for forecast page
|
||||
}
|
||||
}, [tenantId]);
|
||||
|
||||
// Adjust stock
|
||||
const adjustStock = useCallback(async (
|
||||
itemId: string,
|
||||
adjustment: StockAdjustmentRequest
|
||||
): Promise<StockMovement | null> => {
|
||||
if (!tenantId) return null;
|
||||
|
||||
try {
|
||||
const movement = await inventoryService.adjustStock(tenantId, itemId, adjustment);
|
||||
|
||||
// Update local movements
|
||||
setMovements(prev => [movement, ...prev.slice(0, 49)]); // Keep last 50
|
||||
|
||||
// Reload stock level for this item
|
||||
const updatedLevel = await inventoryService.getStockLevel(tenantId, itemId);
|
||||
setStockLevels(prev => ({ ...prev, [itemId]: updatedLevel }));
|
||||
|
||||
toast.success('Stock adjusted successfully');
|
||||
return movement;
|
||||
} catch (err: any) {
|
||||
const errorMessage = err.response?.data?.detail || err.message || 'Error adjusting stock';
|
||||
setError(errorMessage);
|
||||
toast.error(errorMessage);
|
||||
return null;
|
||||
}
|
||||
}, [tenantId]);
|
||||
|
||||
// Load movements
|
||||
const loadMovements = useCallback(async (params?: any) => {
|
||||
if (!tenantId) return;
|
||||
|
||||
try {
|
||||
const response = await inventoryService.getStockMovements(tenantId, params);
|
||||
setMovements(response.items);
|
||||
} catch (err: any) {
|
||||
console.error('Error loading movements:', err);
|
||||
}
|
||||
}, [tenantId]);
|
||||
|
||||
|
||||
// Load dashboard
|
||||
const loadDashboard = useCallback(async () => {
|
||||
if (!tenantId) return;
|
||||
|
||||
try {
|
||||
const data = await inventoryService.getDashboardData(tenantId);
|
||||
setDashboardData(data);
|
||||
} catch (err: any) {
|
||||
console.error('Error loading dashboard:', err);
|
||||
// Don't show toast error for this as it's not critical for forecast page
|
||||
}
|
||||
}, [tenantId]);
|
||||
|
||||
// Search items
|
||||
const searchItems = useCallback(async (query: string): Promise<InventoryItem[]> => {
|
||||
if (!tenantId || !query.trim()) return [];
|
||||
|
||||
try {
|
||||
return await inventoryService.searchItems(tenantId, query);
|
||||
} catch (err: any) {
|
||||
console.error('Error searching items:', err);
|
||||
return [];
|
||||
}
|
||||
}, [tenantId]);
|
||||
|
||||
// Refresh all data
|
||||
const refresh = useCallback(async () => {
|
||||
await Promise.all([
|
||||
loadItems(),
|
||||
loadStockLevels(),
|
||||
loadDashboard()
|
||||
]);
|
||||
}, [loadItems, loadStockLevels, loadDashboard]);
|
||||
|
||||
// Auto-load on mount
|
||||
useEffect(() => {
|
||||
if (autoLoad && tenantId) {
|
||||
refresh();
|
||||
}
|
||||
}, [autoLoad, tenantId, refresh]);
|
||||
|
||||
return {
|
||||
// State
|
||||
items,
|
||||
stockLevels,
|
||||
movements,
|
||||
dashboardData,
|
||||
isLoading,
|
||||
error,
|
||||
pagination,
|
||||
|
||||
// Actions
|
||||
loadItems,
|
||||
loadItem,
|
||||
createItem,
|
||||
updateItem,
|
||||
deleteItem,
|
||||
|
||||
// Stock operations
|
||||
loadStockLevels,
|
||||
adjustStock,
|
||||
loadMovements,
|
||||
|
||||
// Dashboard
|
||||
loadDashboard,
|
||||
|
||||
// Utility
|
||||
searchItems,
|
||||
refresh,
|
||||
clearError
|
||||
};
|
||||
};
|
||||
|
||||
// ========== DASHBOARD HOOK ==========
|
||||
|
||||
export const useInventoryDashboard = (): UseInventoryDashboardReturn => {
|
||||
const { tenantId } = useTenantId();
|
||||
const [dashboardData, setDashboardData] = useState<InventoryDashboardData | null>(null);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const refresh = useCallback(async () => {
|
||||
if (!tenantId) return;
|
||||
|
||||
setIsLoading(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
const dashboard = await inventoryService.getDashboardData(tenantId);
|
||||
|
||||
setDashboardData(dashboard);
|
||||
} catch (err: any) {
|
||||
const errorMessage = err.response?.data?.detail || err.message || 'Error loading dashboard';
|
||||
setError(errorMessage);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, [tenantId]);
|
||||
|
||||
useEffect(() => {
|
||||
if (tenantId) {
|
||||
refresh();
|
||||
}
|
||||
}, [tenantId, refresh]);
|
||||
|
||||
return {
|
||||
dashboardData,
|
||||
isLoading,
|
||||
error,
|
||||
refresh
|
||||
};
|
||||
};
|
||||
|
||||
// ========== SINGLE ITEM HOOK ==========
|
||||
|
||||
export const useInventoryItem = (itemId: string): UseInventoryItemReturn => {
|
||||
const { tenantId } = useTenantId();
|
||||
const [item, setItem] = useState<InventoryItem | null>(null);
|
||||
const [stockLevel, setStockLevel] = useState<StockLevel | null>(null);
|
||||
const [recentMovements, setRecentMovements] = useState<StockMovement[]>([]);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const refresh = useCallback(async () => {
|
||||
if (!tenantId || !itemId) return;
|
||||
|
||||
setIsLoading(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
const [itemData, stockData, movementsData] = await Promise.all([
|
||||
inventoryService.getInventoryItem(tenantId, itemId),
|
||||
inventoryService.getStockLevel(tenantId, itemId),
|
||||
inventoryService.getStockMovements(tenantId, { item_id: itemId, limit: 10 })
|
||||
]);
|
||||
|
||||
setItem(itemData);
|
||||
setStockLevel(stockData);
|
||||
setRecentMovements(movementsData.items);
|
||||
} catch (err: any) {
|
||||
const errorMessage = err.response?.data?.detail || err.message || 'Error loading item';
|
||||
setError(errorMessage);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, [tenantId, itemId]);
|
||||
|
||||
const updateItem = useCallback(async (data: UpdateInventoryItemRequest): Promise<boolean> => {
|
||||
if (!tenantId || !itemId) return false;
|
||||
|
||||
try {
|
||||
const updatedItem = await inventoryService.updateInventoryItem(tenantId, itemId, data);
|
||||
setItem(updatedItem);
|
||||
toast.success('Item updated successfully');
|
||||
return true;
|
||||
} catch (err: any) {
|
||||
const errorMessage = err.response?.data?.detail || err.message || 'Error updating item';
|
||||
setError(errorMessage);
|
||||
toast.error(errorMessage);
|
||||
return false;
|
||||
}
|
||||
}, [tenantId, itemId]);
|
||||
|
||||
const adjustStock = useCallback(async (adjustment: StockAdjustmentRequest): Promise<boolean> => {
|
||||
if (!tenantId || !itemId) return false;
|
||||
|
||||
try {
|
||||
const movement = await inventoryService.adjustStock(tenantId, itemId, adjustment);
|
||||
|
||||
// Refresh data
|
||||
const [updatedStock, updatedMovements] = await Promise.all([
|
||||
inventoryService.getStockLevel(tenantId, itemId),
|
||||
inventoryService.getStockMovements(tenantId, { item_id: itemId, limit: 10 })
|
||||
]);
|
||||
|
||||
setStockLevel(updatedStock);
|
||||
setRecentMovements(updatedMovements.items);
|
||||
|
||||
toast.success('Stock adjusted successfully');
|
||||
return true;
|
||||
} catch (err: any) {
|
||||
const errorMessage = err.response?.data?.detail || err.message || 'Error adjusting stock';
|
||||
setError(errorMessage);
|
||||
toast.error(errorMessage);
|
||||
return false;
|
||||
}
|
||||
}, [tenantId, itemId]);
|
||||
|
||||
useEffect(() => {
|
||||
if (tenantId && itemId) {
|
||||
refresh();
|
||||
}
|
||||
}, [tenantId, itemId, refresh]);
|
||||
|
||||
return {
|
||||
item,
|
||||
stockLevel,
|
||||
recentMovements,
|
||||
isLoading,
|
||||
error,
|
||||
updateItem,
|
||||
adjustStock,
|
||||
refresh
|
||||
};
|
||||
};
|
||||
|
||||
// ========== SIMPLE PRODUCTS HOOK FOR FORECASTING ==========
|
||||
|
||||
export const useInventoryProducts = () => {
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
/**
|
||||
* Get Products List for Forecasting
|
||||
*/
|
||||
const getProductsList = useCallback(async (tenantId: string): Promise<ProductInfo[]> => {
|
||||
try {
|
||||
setIsLoading(true);
|
||||
setError(null);
|
||||
|
||||
const products = await inventoryService.getProductsList(tenantId);
|
||||
|
||||
return products;
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : 'Failed to get products list';
|
||||
setError(message);
|
||||
throw error;
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
/**
|
||||
* Get Product by ID
|
||||
*/
|
||||
const getProductById = useCallback(async (tenantId: string, productId: string): Promise<ProductInfo | null> => {
|
||||
try {
|
||||
setIsLoading(true);
|
||||
setError(null);
|
||||
|
||||
const product = await inventoryService.getProductById(tenantId, productId);
|
||||
|
||||
return product;
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : 'Failed to get product';
|
||||
setError(message);
|
||||
throw error;
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
return {
|
||||
// State
|
||||
isLoading,
|
||||
error,
|
||||
|
||||
// Actions
|
||||
getProductsList,
|
||||
getProductById,
|
||||
};
|
||||
};
|
||||
@@ -1,151 +0,0 @@
|
||||
// frontend/src/api/hooks/useNotification.ts
|
||||
/**
|
||||
* Notification Operations Hooks
|
||||
*/
|
||||
|
||||
import { useState, useCallback } from 'react';
|
||||
import { notificationService } from '../services';
|
||||
import type {
|
||||
NotificationCreate,
|
||||
NotificationResponse,
|
||||
NotificationTemplate,
|
||||
NotificationStats,
|
||||
BulkNotificationRequest,
|
||||
} from '../types';
|
||||
|
||||
export const useNotification = () => {
|
||||
const [notifications, setNotifications] = useState<NotificationResponse[]>([]);
|
||||
const [templates, setTemplates] = useState<NotificationTemplate[]>([]);
|
||||
const [stats, setStats] = useState<NotificationStats | null>(null);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const sendNotification = useCallback(async (
|
||||
tenantId: string,
|
||||
notification: NotificationCreate
|
||||
): Promise<NotificationResponse> => {
|
||||
try {
|
||||
setIsLoading(true);
|
||||
setError(null);
|
||||
|
||||
const sentNotification = await notificationService.sendNotification(tenantId, notification);
|
||||
setNotifications(prev => [sentNotification, ...prev]);
|
||||
|
||||
return sentNotification;
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : 'Failed to send notification';
|
||||
setError(message);
|
||||
throw error;
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const sendBulkNotifications = useCallback(async (
|
||||
tenantId: string,
|
||||
request: BulkNotificationRequest
|
||||
): Promise<void> => {
|
||||
try {
|
||||
setIsLoading(true);
|
||||
setError(null);
|
||||
|
||||
await notificationService.sendBulkNotifications(tenantId, request);
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : 'Failed to send bulk notifications';
|
||||
setError(message);
|
||||
throw error;
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const getNotifications = useCallback(async (tenantId: string): Promise<NotificationResponse[]> => {
|
||||
try {
|
||||
setIsLoading(true);
|
||||
setError(null);
|
||||
|
||||
const response = await notificationService.getNotifications(tenantId);
|
||||
setNotifications(response.data);
|
||||
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : 'Failed to get notifications';
|
||||
setError(message);
|
||||
throw error;
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const getTemplates = useCallback(async (tenantId: string): Promise<NotificationTemplate[]> => {
|
||||
try {
|
||||
setIsLoading(true);
|
||||
setError(null);
|
||||
|
||||
const response = await notificationService.getTemplates(tenantId);
|
||||
setTemplates(response.data);
|
||||
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : 'Failed to get templates';
|
||||
setError(message);
|
||||
throw error;
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const createTemplate = useCallback(async (
|
||||
tenantId: string,
|
||||
template: Omit<NotificationTemplate, 'id' | 'tenant_id' | 'created_at' | 'updated_at'>
|
||||
): Promise<NotificationTemplate> => {
|
||||
try {
|
||||
setIsLoading(true);
|
||||
setError(null);
|
||||
|
||||
const newTemplate = await notificationService.createTemplate(tenantId, template);
|
||||
setTemplates(prev => [newTemplate, ...prev]);
|
||||
|
||||
return newTemplate;
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : 'Failed to create template';
|
||||
setError(message);
|
||||
throw error;
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const getNotificationStats = useCallback(async (tenantId: string): Promise<NotificationStats> => {
|
||||
try {
|
||||
setIsLoading(true);
|
||||
setError(null);
|
||||
|
||||
const notificationStats = await notificationService.getNotificationStats(tenantId);
|
||||
setStats(notificationStats);
|
||||
|
||||
return notificationStats;
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : 'Failed to get notification stats';
|
||||
setError(message);
|
||||
throw error;
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
return {
|
||||
notifications,
|
||||
templates,
|
||||
stats,
|
||||
isLoading,
|
||||
error,
|
||||
sendNotification,
|
||||
sendBulkNotifications,
|
||||
getNotifications,
|
||||
getTemplates,
|
||||
createTemplate,
|
||||
getNotificationStats,
|
||||
clearError: () => setError(null),
|
||||
};
|
||||
};
|
||||
@@ -1,194 +0,0 @@
|
||||
// frontend/src/api/hooks/useOnboarding.ts
|
||||
/**
|
||||
* Onboarding Hook
|
||||
* React hook for managing user onboarding flow and progress
|
||||
*/
|
||||
|
||||
import { useState, useEffect } from 'react';
|
||||
import { onboardingService } from '../services/onboarding.service';
|
||||
import type { UserProgress, UpdateStepRequest } from '../services/onboarding.service';
|
||||
|
||||
export interface UseOnboardingReturn {
|
||||
progress: UserProgress | null;
|
||||
isLoading: boolean;
|
||||
error: string | null;
|
||||
currentStep: string | null;
|
||||
nextStep: string | null;
|
||||
completionPercentage: number;
|
||||
isFullyComplete: boolean;
|
||||
|
||||
// Actions
|
||||
updateStep: (data: UpdateStepRequest) => Promise<void>;
|
||||
completeStep: (stepName: string, data?: Record<string, any>) => Promise<void>;
|
||||
resetStep: (stepName: string) => Promise<void>;
|
||||
getNextStep: () => Promise<string>;
|
||||
completeOnboarding: () => Promise<void>;
|
||||
canAccessStep: (stepName: string) => Promise<boolean>;
|
||||
refreshProgress: () => Promise<void>;
|
||||
}
|
||||
|
||||
export const useOnboarding = (): UseOnboardingReturn => {
|
||||
const [progress, setProgress] = useState<UserProgress | null>(null);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
// Derived state
|
||||
const currentStep = progress?.current_step || null;
|
||||
const nextStep = progress?.next_step || null;
|
||||
const completionPercentage = progress?.completion_percentage || 0;
|
||||
const isFullyComplete = progress?.fully_completed || false;
|
||||
|
||||
// Load initial progress
|
||||
const loadProgress = async () => {
|
||||
setIsLoading(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
const userProgress = await onboardingService.getUserProgress();
|
||||
setProgress(userProgress);
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : 'Failed to load onboarding progress';
|
||||
setError(message);
|
||||
console.error('Onboarding progress load error:', err);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Update step
|
||||
const updateStep = async (data: UpdateStepRequest) => {
|
||||
setIsLoading(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
const updatedProgress = await onboardingService.updateStep(data);
|
||||
setProgress(updatedProgress);
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : 'Failed to update step';
|
||||
setError(message);
|
||||
throw err; // Re-throw so calling component can handle it
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Complete step with data
|
||||
const completeStep = async (stepName: string, data?: Record<string, any>) => {
|
||||
await updateStep({
|
||||
step_name: stepName,
|
||||
completed: true,
|
||||
data
|
||||
});
|
||||
};
|
||||
|
||||
// Reset step
|
||||
const resetStep = async (stepName: string) => {
|
||||
await updateStep({
|
||||
step_name: stepName,
|
||||
completed: false
|
||||
});
|
||||
};
|
||||
|
||||
// Get next step
|
||||
const getNextStep = async (): Promise<string> => {
|
||||
try {
|
||||
const result = await onboardingService.getNextStep();
|
||||
return result.step;
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : 'Failed to get next step';
|
||||
setError(message);
|
||||
throw err;
|
||||
}
|
||||
};
|
||||
|
||||
// Complete entire onboarding
|
||||
const completeOnboarding = async () => {
|
||||
setIsLoading(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
await onboardingService.completeOnboarding();
|
||||
await loadProgress(); // Refresh progress after completion
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : 'Failed to complete onboarding';
|
||||
setError(message);
|
||||
throw err;
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Check if user can access step
|
||||
const canAccessStep = async (stepName: string): Promise<boolean> => {
|
||||
try {
|
||||
const result = await onboardingService.canAccessStep(stepName);
|
||||
return result.can_access;
|
||||
} catch (err) {
|
||||
console.error('Can access step check failed:', err);
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
// Refresh progress
|
||||
const refreshProgress = async () => {
|
||||
await loadProgress();
|
||||
};
|
||||
|
||||
// Load progress on mount
|
||||
useEffect(() => {
|
||||
loadProgress();
|
||||
}, []);
|
||||
|
||||
return {
|
||||
progress,
|
||||
isLoading,
|
||||
error,
|
||||
currentStep,
|
||||
nextStep,
|
||||
completionPercentage,
|
||||
isFullyComplete,
|
||||
updateStep,
|
||||
completeStep,
|
||||
resetStep,
|
||||
getNextStep,
|
||||
completeOnboarding,
|
||||
canAccessStep,
|
||||
refreshProgress,
|
||||
};
|
||||
};
|
||||
|
||||
// Helper hook for specific steps
|
||||
export const useOnboardingStep = (stepName: string) => {
|
||||
const onboarding = useOnboarding();
|
||||
|
||||
const stepStatus = onboarding.progress?.steps.find(
|
||||
step => step.step_name === stepName
|
||||
);
|
||||
|
||||
const isCompleted = stepStatus?.completed || false;
|
||||
const stepData = stepStatus?.data || {};
|
||||
const completedAt = stepStatus?.completed_at;
|
||||
|
||||
const completeThisStep = async (data?: Record<string, any>) => {
|
||||
await onboarding.completeStep(stepName, data);
|
||||
};
|
||||
|
||||
const resetThisStep = async () => {
|
||||
await onboarding.resetStep(stepName);
|
||||
};
|
||||
|
||||
const canAccessThisStep = async (): Promise<boolean> => {
|
||||
return await onboarding.canAccessStep(stepName);
|
||||
};
|
||||
|
||||
return {
|
||||
...onboarding,
|
||||
stepName,
|
||||
isCompleted,
|
||||
stepData,
|
||||
completedAt,
|
||||
completeThisStep,
|
||||
resetThisStep,
|
||||
canAccessThisStep,
|
||||
};
|
||||
};
|
||||
@@ -1,337 +0,0 @@
|
||||
// frontend/src/api/hooks/usePOS.ts
|
||||
/**
|
||||
* React hooks for POS Integration functionality
|
||||
*/
|
||||
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import {
|
||||
posService,
|
||||
POSConfiguration,
|
||||
CreatePOSConfigurationRequest,
|
||||
UpdatePOSConfigurationRequest,
|
||||
POSTransaction,
|
||||
POSSyncLog,
|
||||
POSAnalytics,
|
||||
SyncRequest
|
||||
} from '../services/pos.service';
|
||||
import { useTenantId } from './useTenant';
|
||||
|
||||
// ============================================================================
|
||||
// CONFIGURATION HOOKS
|
||||
// ============================================================================
|
||||
|
||||
export const usePOSConfigurations = (params?: {
|
||||
pos_system?: string;
|
||||
is_active?: boolean;
|
||||
limit?: number;
|
||||
offset?: number;
|
||||
}) => {
|
||||
const tenantId = useTenantId();
|
||||
|
||||
return useQuery({
|
||||
queryKey: ['pos-configurations', tenantId, params],
|
||||
queryFn: () => posService.getConfigurations(tenantId, params),
|
||||
enabled: !!tenantId,
|
||||
staleTime: 5 * 60 * 1000, // 5 minutes
|
||||
});
|
||||
};
|
||||
|
||||
export const usePOSConfiguration = (configId?: string) => {
|
||||
const tenantId = useTenantId();
|
||||
|
||||
return useQuery({
|
||||
queryKey: ['pos-configuration', tenantId, configId],
|
||||
queryFn: () => posService.getConfiguration(tenantId, configId!),
|
||||
enabled: !!tenantId && !!configId,
|
||||
staleTime: 5 * 60 * 1000,
|
||||
});
|
||||
};
|
||||
|
||||
export const useCreatePOSConfiguration = () => {
|
||||
const queryClient = useQueryClient();
|
||||
const tenantId = useTenantId();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: (data: CreatePOSConfigurationRequest) =>
|
||||
posService.createConfiguration(tenantId, data),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['pos-configurations', tenantId] });
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
export const useUpdatePOSConfiguration = () => {
|
||||
const queryClient = useQueryClient();
|
||||
const tenantId = useTenantId();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: ({ configId, data }: { configId: string; data: UpdatePOSConfigurationRequest }) =>
|
||||
posService.updateConfiguration(tenantId, configId, data),
|
||||
onSuccess: (_, { configId }) => {
|
||||
queryClient.invalidateQueries({ queryKey: ['pos-configurations', tenantId] });
|
||||
queryClient.invalidateQueries({ queryKey: ['pos-configuration', tenantId, configId] });
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
export const useDeletePOSConfiguration = () => {
|
||||
const queryClient = useQueryClient();
|
||||
const tenantId = useTenantId();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: (configId: string) =>
|
||||
posService.deleteConfiguration(tenantId, configId),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['pos-configurations', tenantId] });
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
export const useTestPOSConnection = () => {
|
||||
const tenantId = useTenantId();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: (configId: string) =>
|
||||
posService.testConnection(tenantId, configId),
|
||||
});
|
||||
};
|
||||
|
||||
// ============================================================================
|
||||
// SYNCHRONIZATION HOOKS
|
||||
// ============================================================================
|
||||
|
||||
export const useTriggerPOSSync = () => {
|
||||
const queryClient = useQueryClient();
|
||||
const tenantId = useTenantId();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: ({ configId, syncRequest }: { configId: string; syncRequest: SyncRequest }) =>
|
||||
posService.triggerSync(tenantId, configId, syncRequest),
|
||||
onSuccess: (_, { configId }) => {
|
||||
queryClient.invalidateQueries({ queryKey: ['pos-sync-status', tenantId, configId] });
|
||||
queryClient.invalidateQueries({ queryKey: ['pos-sync-logs', tenantId, configId] });
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
export const usePOSSyncStatus = (configId?: string, pollingInterval?: number) => {
|
||||
const tenantId = useTenantId();
|
||||
|
||||
return useQuery({
|
||||
queryKey: ['pos-sync-status', tenantId, configId],
|
||||
queryFn: () => posService.getSyncStatus(tenantId, configId!),
|
||||
enabled: !!tenantId && !!configId,
|
||||
refetchInterval: pollingInterval || 30000, // Poll every 30 seconds by default
|
||||
staleTime: 10 * 1000, // 10 seconds
|
||||
});
|
||||
};
|
||||
|
||||
export const usePOSSyncLogs = (configId?: string, params?: {
|
||||
limit?: number;
|
||||
offset?: number;
|
||||
status?: string;
|
||||
sync_type?: string;
|
||||
data_type?: string;
|
||||
}) => {
|
||||
const tenantId = useTenantId();
|
||||
|
||||
return useQuery({
|
||||
queryKey: ['pos-sync-logs', tenantId, configId, params],
|
||||
queryFn: () => posService.getSyncLogs(tenantId, configId!, params),
|
||||
enabled: !!tenantId && !!configId,
|
||||
staleTime: 2 * 60 * 1000, // 2 minutes
|
||||
});
|
||||
};
|
||||
|
||||
// ============================================================================
|
||||
// TRANSACTION HOOKS
|
||||
// ============================================================================
|
||||
|
||||
export const usePOSTransactions = (params?: {
|
||||
pos_system?: string;
|
||||
start_date?: string;
|
||||
end_date?: string;
|
||||
status?: string;
|
||||
is_synced?: boolean;
|
||||
limit?: number;
|
||||
offset?: number;
|
||||
}) => {
|
||||
const tenantId = useTenantId();
|
||||
|
||||
return useQuery({
|
||||
queryKey: ['pos-transactions', tenantId, params],
|
||||
queryFn: () => posService.getTransactions(tenantId, params),
|
||||
enabled: !!tenantId,
|
||||
staleTime: 2 * 60 * 1000, // 2 minutes
|
||||
});
|
||||
};
|
||||
|
||||
export const useSyncSingleTransaction = () => {
|
||||
const queryClient = useQueryClient();
|
||||
const tenantId = useTenantId();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: ({ transactionId, force }: { transactionId: string; force?: boolean }) =>
|
||||
posService.syncSingleTransaction(tenantId, transactionId, force),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['pos-transactions', tenantId] });
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
export const useResyncFailedTransactions = () => {
|
||||
const queryClient = useQueryClient();
|
||||
const tenantId = useTenantId();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: (daysBack: number) =>
|
||||
posService.resyncFailedTransactions(tenantId, daysBack),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['pos-transactions', tenantId] });
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
// ============================================================================
|
||||
// ANALYTICS HOOKS
|
||||
// ============================================================================
|
||||
|
||||
export const usePOSAnalytics = (days: number = 30) => {
|
||||
const tenantId = useTenantId();
|
||||
|
||||
return useQuery({
|
||||
queryKey: ['pos-analytics', tenantId, days],
|
||||
queryFn: () => posService.getSyncAnalytics(tenantId, days),
|
||||
enabled: !!tenantId,
|
||||
staleTime: 10 * 60 * 1000, // 10 minutes
|
||||
});
|
||||
};
|
||||
|
||||
// ============================================================================
|
||||
// SYSTEM INFO HOOKS
|
||||
// ============================================================================
|
||||
|
||||
export const useSupportedPOSSystems = () => {
|
||||
return useQuery({
|
||||
queryKey: ['supported-pos-systems'],
|
||||
queryFn: () => posService.getSupportedSystems(),
|
||||
staleTime: 60 * 60 * 1000, // 1 hour
|
||||
});
|
||||
};
|
||||
|
||||
export const useWebhookStatus = (posSystem?: string) => {
|
||||
return useQuery({
|
||||
queryKey: ['webhook-status', posSystem],
|
||||
queryFn: () => posService.getWebhookStatus(posSystem!),
|
||||
enabled: !!posSystem,
|
||||
staleTime: 5 * 60 * 1000, // 5 minutes
|
||||
});
|
||||
};
|
||||
|
||||
// ============================================================================
|
||||
// COMPOSITE HOOKS
|
||||
// ============================================================================
|
||||
|
||||
export const usePOSDashboard = () => {
|
||||
const tenantId = useTenantId();
|
||||
|
||||
// Get configurations
|
||||
const { data: configurationsData, isLoading: configurationsLoading } = usePOSConfigurations();
|
||||
|
||||
// Get recent transactions
|
||||
const { data: transactionsData, isLoading: transactionsLoading } = usePOSTransactions({
|
||||
limit: 10
|
||||
});
|
||||
|
||||
// Get analytics for last 7 days
|
||||
const { data: analyticsData, isLoading: analyticsLoading } = usePOSAnalytics(7);
|
||||
|
||||
const isLoading = configurationsLoading || transactionsLoading || analyticsLoading;
|
||||
|
||||
return {
|
||||
configurations: configurationsData?.configurations || [],
|
||||
transactions: transactionsData?.transactions || [],
|
||||
analytics: analyticsData,
|
||||
isLoading,
|
||||
summary: {
|
||||
total_configurations: configurationsData?.total || 0,
|
||||
active_configurations: configurationsData?.configurations?.filter(c => c.is_active).length || 0,
|
||||
connected_configurations: configurationsData?.configurations?.filter(c => c.is_connected).length || 0,
|
||||
total_transactions: transactionsData?.total || 0,
|
||||
total_revenue: transactionsData?.summary?.total_amount || 0,
|
||||
sync_health: analyticsData?.success_rate || 0,
|
||||
}
|
||||
};
|
||||
};
|
||||
|
||||
export const usePOSConfigurationManagement = () => {
|
||||
const createMutation = useCreatePOSConfiguration();
|
||||
const updateMutation = useUpdatePOSConfiguration();
|
||||
const deleteMutation = useDeletePOSConfiguration();
|
||||
const testConnectionMutation = useTestPOSConnection();
|
||||
|
||||
const [selectedConfiguration, setSelectedConfiguration] = useState<POSConfiguration | null>(null);
|
||||
const [isFormOpen, setIsFormOpen] = useState(false);
|
||||
|
||||
const handleCreate = async (data: CreatePOSConfigurationRequest) => {
|
||||
await createMutation.mutateAsync(data);
|
||||
setIsFormOpen(false);
|
||||
};
|
||||
|
||||
const handleUpdate = async (configId: string, data: UpdatePOSConfigurationRequest) => {
|
||||
await updateMutation.mutateAsync({ configId, data });
|
||||
setIsFormOpen(false);
|
||||
setSelectedConfiguration(null);
|
||||
};
|
||||
|
||||
const handleDelete = async (configId: string) => {
|
||||
await deleteMutation.mutateAsync(configId);
|
||||
};
|
||||
|
||||
const handleTestConnection = async (configId: string) => {
|
||||
return await testConnectionMutation.mutateAsync(configId);
|
||||
};
|
||||
|
||||
const openCreateForm = () => {
|
||||
setSelectedConfiguration(null);
|
||||
setIsFormOpen(true);
|
||||
};
|
||||
|
||||
const openEditForm = (configuration: POSConfiguration) => {
|
||||
setSelectedConfiguration(configuration);
|
||||
setIsFormOpen(true);
|
||||
};
|
||||
|
||||
const closeForm = () => {
|
||||
setIsFormOpen(false);
|
||||
setSelectedConfiguration(null);
|
||||
};
|
||||
|
||||
return {
|
||||
// State
|
||||
selectedConfiguration,
|
||||
isFormOpen,
|
||||
|
||||
// Actions
|
||||
handleCreate,
|
||||
handleUpdate,
|
||||
handleDelete,
|
||||
handleTestConnection,
|
||||
openCreateForm,
|
||||
openEditForm,
|
||||
closeForm,
|
||||
|
||||
// Loading states
|
||||
isCreating: createMutation.isPending,
|
||||
isUpdating: updateMutation.isPending,
|
||||
isDeleting: deleteMutation.isPending,
|
||||
isTesting: testConnectionMutation.isPending,
|
||||
|
||||
// Errors
|
||||
createError: createMutation.error,
|
||||
updateError: updateMutation.error,
|
||||
deleteError: deleteMutation.error,
|
||||
testError: testConnectionMutation.error,
|
||||
};
|
||||
};
|
||||
@@ -1,294 +0,0 @@
|
||||
// ================================================================
|
||||
// frontend/src/api/hooks/useProcurement.ts
|
||||
// ================================================================
|
||||
/**
|
||||
* React hooks for procurement planning functionality
|
||||
*/
|
||||
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { procurementService } from '../services/procurement.service';
|
||||
import type {
|
||||
ProcurementPlan,
|
||||
GeneratePlanRequest,
|
||||
GeneratePlanResponse,
|
||||
DashboardData,
|
||||
ProcurementRequirement,
|
||||
PaginatedProcurementPlans
|
||||
} from '../types/procurement';
|
||||
|
||||
// ================================================================
|
||||
// QUERY KEYS
|
||||
// ================================================================
|
||||
|
||||
export const procurementKeys = {
|
||||
all: ['procurement'] as const,
|
||||
plans: () => [...procurementKeys.all, 'plans'] as const,
|
||||
plan: (id: string) => [...procurementKeys.plans(), id] as const,
|
||||
currentPlan: () => [...procurementKeys.plans(), 'current'] as const,
|
||||
planByDate: (date: string) => [...procurementKeys.plans(), 'date', date] as const,
|
||||
plansList: (filters?: any) => [...procurementKeys.plans(), 'list', filters] as const,
|
||||
requirements: () => [...procurementKeys.all, 'requirements'] as const,
|
||||
planRequirements: (planId: string) => [...procurementKeys.requirements(), 'plan', planId] as const,
|
||||
criticalRequirements: () => [...procurementKeys.requirements(), 'critical'] as const,
|
||||
dashboard: () => [...procurementKeys.all, 'dashboard'] as const,
|
||||
};
|
||||
|
||||
// ================================================================
|
||||
// PROCUREMENT PLAN HOOKS
|
||||
// ================================================================
|
||||
|
||||
/**
|
||||
* Hook to fetch the current day's procurement plan
|
||||
*/
|
||||
export function useCurrentProcurementPlan() {
|
||||
return useQuery({
|
||||
queryKey: procurementKeys.currentPlan(),
|
||||
queryFn: () => procurementService.getCurrentPlan(),
|
||||
staleTime: 5 * 60 * 1000, // 5 minutes
|
||||
refetchInterval: 10 * 60 * 1000, // Refetch every 10 minutes
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook to fetch procurement plan by date
|
||||
*/
|
||||
export function useProcurementPlanByDate(date: string, enabled = true) {
|
||||
return useQuery({
|
||||
queryKey: procurementKeys.planByDate(date),
|
||||
queryFn: () => procurementService.getPlanByDate(date),
|
||||
enabled: enabled && !!date,
|
||||
staleTime: 30 * 60 * 1000, // 30 minutes for historical data
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook to fetch procurement plan by ID
|
||||
*/
|
||||
export function useProcurementPlan(planId: string, enabled = true) {
|
||||
return useQuery({
|
||||
queryKey: procurementKeys.plan(planId),
|
||||
queryFn: () => procurementService.getPlanById(planId),
|
||||
enabled: enabled && !!planId,
|
||||
staleTime: 10 * 60 * 1000, // 10 minutes
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook to fetch paginated list of procurement plans
|
||||
*/
|
||||
export function useProcurementPlans(params?: {
|
||||
status?: string;
|
||||
startDate?: string;
|
||||
endDate?: string;
|
||||
limit?: number;
|
||||
offset?: number;
|
||||
}) {
|
||||
return useQuery({
|
||||
queryKey: procurementKeys.plansList(params),
|
||||
queryFn: () => procurementService.listPlans(params),
|
||||
staleTime: 5 * 60 * 1000, // 5 minutes
|
||||
});
|
||||
}
|
||||
|
||||
// ================================================================
|
||||
// REQUIREMENTS HOOKS
|
||||
// ================================================================
|
||||
|
||||
/**
|
||||
* Hook to fetch requirements for a specific plan
|
||||
*/
|
||||
export function usePlanRequirements(
|
||||
planId: string,
|
||||
filters?: {
|
||||
status?: string;
|
||||
priority?: string;
|
||||
},
|
||||
enabled = true
|
||||
) {
|
||||
return useQuery({
|
||||
queryKey: procurementKeys.planRequirements(planId),
|
||||
queryFn: () => procurementService.getPlanRequirements(planId, filters),
|
||||
enabled: enabled && !!planId,
|
||||
staleTime: 5 * 60 * 1000, // 5 minutes
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook to fetch critical requirements across all plans
|
||||
*/
|
||||
export function useCriticalRequirements() {
|
||||
return useQuery({
|
||||
queryKey: procurementKeys.criticalRequirements(),
|
||||
queryFn: () => procurementService.getCriticalRequirements(),
|
||||
staleTime: 2 * 60 * 1000, // 2 minutes for critical data
|
||||
refetchInterval: 5 * 60 * 1000, // Refetch every 5 minutes
|
||||
});
|
||||
}
|
||||
|
||||
// ================================================================
|
||||
// DASHBOARD HOOKS
|
||||
// ================================================================
|
||||
|
||||
/**
|
||||
* Hook to fetch procurement dashboard data
|
||||
*/
|
||||
export function useProcurementDashboard() {
|
||||
return useQuery({
|
||||
queryKey: procurementKeys.dashboard(),
|
||||
queryFn: () => procurementService.getDashboardData(),
|
||||
staleTime: 2 * 60 * 1000, // 2 minutes
|
||||
refetchInterval: 5 * 60 * 1000, // Refetch every 5 minutes
|
||||
});
|
||||
}
|
||||
|
||||
// ================================================================
|
||||
// MUTATION HOOKS
|
||||
// ================================================================
|
||||
|
||||
/**
|
||||
* Hook to generate a new procurement plan
|
||||
*/
|
||||
export function useGenerateProcurementPlan() {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: (request: GeneratePlanRequest) =>
|
||||
procurementService.generatePlan(request),
|
||||
onSuccess: (data: GeneratePlanResponse) => {
|
||||
// Invalidate relevant queries
|
||||
queryClient.invalidateQueries({ queryKey: procurementKeys.plans() });
|
||||
queryClient.invalidateQueries({ queryKey: procurementKeys.dashboard() });
|
||||
|
||||
// If plan was generated successfully, update the cache
|
||||
if (data.success && data.plan) {
|
||||
queryClient.setQueryData(
|
||||
procurementKeys.plan(data.plan.id),
|
||||
data.plan
|
||||
);
|
||||
|
||||
// Update current plan cache if this is today's plan
|
||||
const today = new Date().toISOString().split('T')[0];
|
||||
if (data.plan.plan_date === today) {
|
||||
queryClient.setQueryData(
|
||||
procurementKeys.currentPlan(),
|
||||
data.plan
|
||||
);
|
||||
}
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook to update procurement plan status
|
||||
*/
|
||||
export function useUpdatePlanStatus() {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: ({ planId, status }: { planId: string; status: string }) =>
|
||||
procurementService.updatePlanStatus(planId, status),
|
||||
onSuccess: (updatedPlan: ProcurementPlan) => {
|
||||
// Update the specific plan in cache
|
||||
queryClient.setQueryData(
|
||||
procurementKeys.plan(updatedPlan.id),
|
||||
updatedPlan
|
||||
);
|
||||
|
||||
// Update current plan if this is the current plan
|
||||
const today = new Date().toISOString().split('T')[0];
|
||||
if (updatedPlan.plan_date === today) {
|
||||
queryClient.setQueryData(
|
||||
procurementKeys.currentPlan(),
|
||||
updatedPlan
|
||||
);
|
||||
}
|
||||
|
||||
// Invalidate lists to ensure they're refreshed
|
||||
queryClient.invalidateQueries({ queryKey: procurementKeys.plansList() });
|
||||
queryClient.invalidateQueries({ queryKey: procurementKeys.dashboard() });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook to trigger the daily scheduler manually
|
||||
*/
|
||||
export function useTriggerDailyScheduler() {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: () => procurementService.triggerDailyScheduler(),
|
||||
onSuccess: () => {
|
||||
// Invalidate all procurement data
|
||||
queryClient.invalidateQueries({ queryKey: procurementKeys.all });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// ================================================================
|
||||
// UTILITY HOOKS
|
||||
// ================================================================
|
||||
|
||||
/**
|
||||
* Hook to check procurement service health
|
||||
*/
|
||||
export function useProcurementHealth() {
|
||||
return useQuery({
|
||||
queryKey: [...procurementKeys.all, 'health'],
|
||||
queryFn: () => procurementService.healthCheck(),
|
||||
staleTime: 60 * 1000, // 1 minute
|
||||
refetchInterval: 5 * 60 * 1000, // Check every 5 minutes
|
||||
});
|
||||
}
|
||||
|
||||
// ================================================================
|
||||
// COMBINED HOOKS
|
||||
// ================================================================
|
||||
|
||||
/**
|
||||
* Combined hook for procurement plan dashboard
|
||||
* Fetches current plan, dashboard data, and critical requirements
|
||||
*/
|
||||
export function useProcurementPlanDashboard() {
|
||||
const currentPlan = useCurrentProcurementPlan();
|
||||
const dashboard = useProcurementDashboard();
|
||||
const criticalRequirements = useCriticalRequirements();
|
||||
const health = useProcurementHealth();
|
||||
|
||||
return {
|
||||
currentPlan,
|
||||
dashboard,
|
||||
criticalRequirements,
|
||||
health,
|
||||
isLoading: currentPlan.isLoading || dashboard.isLoading,
|
||||
error: currentPlan.error || dashboard.error || criticalRequirements.error,
|
||||
refetchAll: () => {
|
||||
currentPlan.refetch();
|
||||
dashboard.refetch();
|
||||
criticalRequirements.refetch();
|
||||
health.refetch();
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook for managing procurement plan lifecycle
|
||||
*/
|
||||
export function useProcurementPlanActions() {
|
||||
const generatePlan = useGenerateProcurementPlan();
|
||||
const updateStatus = useUpdatePlanStatus();
|
||||
const triggerScheduler = useTriggerDailyScheduler();
|
||||
|
||||
return {
|
||||
generatePlan: generatePlan.mutate,
|
||||
updateStatus: updateStatus.mutate,
|
||||
triggerScheduler: triggerScheduler.mutate,
|
||||
isGenerating: generatePlan.isPending,
|
||||
isUpdating: updateStatus.isPending,
|
||||
isTriggering: triggerScheduler.isPending,
|
||||
generateError: generatePlan.error,
|
||||
updateError: updateStatus.error,
|
||||
triggerError: triggerScheduler.error,
|
||||
};
|
||||
}
|
||||
@@ -1,682 +0,0 @@
|
||||
// frontend/src/api/hooks/useRecipes.ts
|
||||
/**
|
||||
* React hooks for recipe and production management
|
||||
*/
|
||||
|
||||
import { useState, useEffect, useCallback, useMemo } from 'react';
|
||||
import { toast } from 'react-hot-toast';
|
||||
import {
|
||||
RecipesService,
|
||||
Recipe,
|
||||
RecipeIngredient,
|
||||
CreateRecipeRequest,
|
||||
UpdateRecipeRequest,
|
||||
RecipeSearchParams,
|
||||
RecipeFeasibility,
|
||||
RecipeStatistics,
|
||||
ProductionBatch,
|
||||
CreateProductionBatchRequest,
|
||||
UpdateProductionBatchRequest,
|
||||
ProductionBatchSearchParams,
|
||||
ProductionStatistics
|
||||
} from '../services/recipes.service';
|
||||
import { useTenant } from './useTenant';
|
||||
import { useAuth } from './useAuth';
|
||||
|
||||
const recipesService = new RecipesService();
|
||||
|
||||
// Recipe Management Hook
|
||||
export interface UseRecipesReturn {
|
||||
// Data
|
||||
recipes: Recipe[];
|
||||
selectedRecipe: Recipe | null;
|
||||
categories: string[];
|
||||
statistics: RecipeStatistics | null;
|
||||
|
||||
// State
|
||||
isLoading: boolean;
|
||||
isCreating: boolean;
|
||||
isUpdating: boolean;
|
||||
isDeleting: boolean;
|
||||
error: string | null;
|
||||
|
||||
// Pagination
|
||||
pagination: {
|
||||
page: number;
|
||||
limit: number;
|
||||
total: number;
|
||||
totalPages: number;
|
||||
};
|
||||
|
||||
// Actions
|
||||
loadRecipes: (params?: RecipeSearchParams) => Promise<void>;
|
||||
loadRecipe: (recipeId: string) => Promise<void>;
|
||||
createRecipe: (data: CreateRecipeRequest) => Promise<Recipe | null>;
|
||||
updateRecipe: (recipeId: string, data: UpdateRecipeRequest) => Promise<Recipe | null>;
|
||||
deleteRecipe: (recipeId: string) => Promise<boolean>;
|
||||
duplicateRecipe: (recipeId: string, newName: string) => Promise<Recipe | null>;
|
||||
activateRecipe: (recipeId: string) => Promise<Recipe | null>;
|
||||
checkFeasibility: (recipeId: string, batchMultiplier?: number) => Promise<RecipeFeasibility | null>;
|
||||
loadStatistics: () => Promise<void>;
|
||||
loadCategories: () => Promise<void>;
|
||||
clearError: () => void;
|
||||
refresh: () => Promise<void>;
|
||||
setPage: (page: number) => void;
|
||||
}
|
||||
|
||||
export const useRecipes = (autoLoad: boolean = true): UseRecipesReturn => {
|
||||
const { currentTenant } = useTenant();
|
||||
const { user } = useAuth();
|
||||
|
||||
// State
|
||||
const [recipes, setRecipes] = useState<Recipe[]>([]);
|
||||
const [selectedRecipe, setSelectedRecipe] = useState<Recipe | null>(null);
|
||||
const [categories, setCategories] = useState<string[]>([]);
|
||||
const [statistics, setStatistics] = useState<RecipeStatistics | null>(null);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [isCreating, setIsCreating] = useState(false);
|
||||
const [isUpdating, setIsUpdating] = useState(false);
|
||||
const [isDeleting, setIsDeleting] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [currentParams, setCurrentParams] = useState<RecipeSearchParams>({});
|
||||
const [pagination, setPagination] = useState({
|
||||
page: 1,
|
||||
limit: 20,
|
||||
total: 0,
|
||||
totalPages: 0
|
||||
});
|
||||
|
||||
// Load recipes
|
||||
const loadRecipes = useCallback(async (params: RecipeSearchParams = {}) => {
|
||||
if (!currentTenant?.id) return;
|
||||
|
||||
setIsLoading(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
const searchParams = {
|
||||
...params,
|
||||
limit: pagination.limit,
|
||||
offset: (pagination.page - 1) * pagination.limit
|
||||
};
|
||||
|
||||
const recipesData = await recipesService.getRecipes(currentTenant.id, searchParams);
|
||||
setRecipes(recipesData);
|
||||
setCurrentParams(params);
|
||||
|
||||
// Calculate pagination (assuming we get total count somehow)
|
||||
const total = recipesData.length; // This would need to be from a proper paginated response
|
||||
setPagination(prev => ({
|
||||
...prev,
|
||||
total,
|
||||
totalPages: Math.ceil(total / prev.limit)
|
||||
}));
|
||||
|
||||
} catch (err: any) {
|
||||
const errorMessage = err.response?.data?.detail || err.message || 'Error loading recipes';
|
||||
setError(errorMessage);
|
||||
toast.error(errorMessage);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, [currentTenant?.id, pagination.page, pagination.limit]);
|
||||
|
||||
// Load single recipe
|
||||
const loadRecipe = useCallback(async (recipeId: string) => {
|
||||
if (!currentTenant?.id) return;
|
||||
|
||||
setIsLoading(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
const recipe = await recipesService.getRecipe(currentTenant.id, recipeId);
|
||||
setSelectedRecipe(recipe);
|
||||
} catch (err: any) {
|
||||
const errorMessage = err.response?.data?.detail || err.message || 'Error loading recipe';
|
||||
setError(errorMessage);
|
||||
toast.error(errorMessage);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, [currentTenant?.id]);
|
||||
|
||||
// Create recipe
|
||||
const createRecipe = useCallback(async (data: CreateRecipeRequest): Promise<Recipe | null> => {
|
||||
if (!currentTenant?.id || !user?.id) return null;
|
||||
|
||||
setIsCreating(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
const newRecipe = await recipesService.createRecipe(currentTenant.id, user.id, data);
|
||||
|
||||
// Add to local state
|
||||
setRecipes(prev => [newRecipe, ...prev]);
|
||||
|
||||
toast.success('Recipe created successfully');
|
||||
return newRecipe;
|
||||
} catch (err: any) {
|
||||
const errorMessage = err.response?.data?.detail || err.message || 'Error creating recipe';
|
||||
setError(errorMessage);
|
||||
toast.error(errorMessage);
|
||||
return null;
|
||||
} finally {
|
||||
setIsCreating(false);
|
||||
}
|
||||
}, [currentTenant?.id, user?.id]);
|
||||
|
||||
// Update recipe
|
||||
const updateRecipe = useCallback(async (recipeId: string, data: UpdateRecipeRequest): Promise<Recipe | null> => {
|
||||
if (!currentTenant?.id || !user?.id) return null;
|
||||
|
||||
setIsUpdating(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
const updatedRecipe = await recipesService.updateRecipe(currentTenant.id, user.id, recipeId, data);
|
||||
|
||||
// Update local state
|
||||
setRecipes(prev => prev.map(recipe =>
|
||||
recipe.id === recipeId ? updatedRecipe : recipe
|
||||
));
|
||||
|
||||
if (selectedRecipe?.id === recipeId) {
|
||||
setSelectedRecipe(updatedRecipe);
|
||||
}
|
||||
|
||||
toast.success('Recipe updated successfully');
|
||||
return updatedRecipe;
|
||||
} catch (err: any) {
|
||||
const errorMessage = err.response?.data?.detail || err.message || 'Error updating recipe';
|
||||
setError(errorMessage);
|
||||
toast.error(errorMessage);
|
||||
return null;
|
||||
} finally {
|
||||
setIsUpdating(false);
|
||||
}
|
||||
}, [currentTenant?.id, user?.id, selectedRecipe?.id]);
|
||||
|
||||
// Delete recipe
|
||||
const deleteRecipe = useCallback(async (recipeId: string): Promise<boolean> => {
|
||||
if (!currentTenant?.id) return false;
|
||||
|
||||
setIsDeleting(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
await recipesService.deleteRecipe(currentTenant.id, recipeId);
|
||||
|
||||
// Remove from local state
|
||||
setRecipes(prev => prev.filter(recipe => recipe.id !== recipeId));
|
||||
|
||||
if (selectedRecipe?.id === recipeId) {
|
||||
setSelectedRecipe(null);
|
||||
}
|
||||
|
||||
toast.success('Recipe deleted successfully');
|
||||
return true;
|
||||
} catch (err: any) {
|
||||
const errorMessage = err.response?.data?.detail || err.message || 'Error deleting recipe';
|
||||
setError(errorMessage);
|
||||
toast.error(errorMessage);
|
||||
return false;
|
||||
} finally {
|
||||
setIsDeleting(false);
|
||||
}
|
||||
}, [currentTenant?.id, selectedRecipe?.id]);
|
||||
|
||||
// Duplicate recipe
|
||||
const duplicateRecipe = useCallback(async (recipeId: string, newName: string): Promise<Recipe | null> => {
|
||||
if (!currentTenant?.id || !user?.id) return null;
|
||||
|
||||
setIsCreating(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
const duplicatedRecipe = await recipesService.duplicateRecipe(currentTenant.id, user.id, recipeId, newName);
|
||||
|
||||
// Add to local state
|
||||
setRecipes(prev => [duplicatedRecipe, ...prev]);
|
||||
|
||||
toast.success('Recipe duplicated successfully');
|
||||
return duplicatedRecipe;
|
||||
} catch (err: any) {
|
||||
const errorMessage = err.response?.data?.detail || err.message || 'Error duplicating recipe';
|
||||
setError(errorMessage);
|
||||
toast.error(errorMessage);
|
||||
return null;
|
||||
} finally {
|
||||
setIsCreating(false);
|
||||
}
|
||||
}, [currentTenant?.id, user?.id]);
|
||||
|
||||
// Activate recipe
|
||||
const activateRecipe = useCallback(async (recipeId: string): Promise<Recipe | null> => {
|
||||
if (!currentTenant?.id || !user?.id) return null;
|
||||
|
||||
setIsUpdating(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
const activatedRecipe = await recipesService.activateRecipe(currentTenant.id, user.id, recipeId);
|
||||
|
||||
// Update local state
|
||||
setRecipes(prev => prev.map(recipe =>
|
||||
recipe.id === recipeId ? activatedRecipe : recipe
|
||||
));
|
||||
|
||||
if (selectedRecipe?.id === recipeId) {
|
||||
setSelectedRecipe(activatedRecipe);
|
||||
}
|
||||
|
||||
toast.success('Recipe activated successfully');
|
||||
return activatedRecipe;
|
||||
} catch (err: any) {
|
||||
const errorMessage = err.response?.data?.detail || err.message || 'Error activating recipe';
|
||||
setError(errorMessage);
|
||||
toast.error(errorMessage);
|
||||
return null;
|
||||
} finally {
|
||||
setIsUpdating(false);
|
||||
}
|
||||
}, [currentTenant?.id, user?.id, selectedRecipe?.id]);
|
||||
|
||||
// Check feasibility
|
||||
const checkFeasibility = useCallback(async (recipeId: string, batchMultiplier: number = 1.0): Promise<RecipeFeasibility | null> => {
|
||||
if (!currentTenant?.id) return null;
|
||||
|
||||
try {
|
||||
const feasibility = await recipesService.checkRecipeFeasibility(currentTenant.id, recipeId, batchMultiplier);
|
||||
return feasibility;
|
||||
} catch (err: any) {
|
||||
const errorMessage = err.response?.data?.detail || err.message || 'Error checking recipe feasibility';
|
||||
setError(errorMessage);
|
||||
toast.error(errorMessage);
|
||||
return null;
|
||||
}
|
||||
}, [currentTenant?.id]);
|
||||
|
||||
// Load statistics
|
||||
const loadStatistics = useCallback(async () => {
|
||||
if (!currentTenant?.id) return;
|
||||
|
||||
try {
|
||||
const stats = await recipesService.getRecipeStatistics(currentTenant.id);
|
||||
setStatistics(stats);
|
||||
} catch (err: any) {
|
||||
console.error('Error loading recipe statistics:', err);
|
||||
}
|
||||
}, [currentTenant?.id]);
|
||||
|
||||
// Load categories
|
||||
const loadCategories = useCallback(async () => {
|
||||
if (!currentTenant?.id) return;
|
||||
|
||||
try {
|
||||
const cats = await recipesService.getRecipeCategories(currentTenant.id);
|
||||
setCategories(cats);
|
||||
} catch (err: any) {
|
||||
console.error('Error loading recipe categories:', err);
|
||||
}
|
||||
}, [currentTenant?.id]);
|
||||
|
||||
// Clear error
|
||||
const clearError = useCallback(() => {
|
||||
setError(null);
|
||||
}, []);
|
||||
|
||||
// Refresh
|
||||
const refresh = useCallback(async () => {
|
||||
await Promise.all([
|
||||
loadRecipes(currentParams),
|
||||
loadStatistics(),
|
||||
loadCategories()
|
||||
]);
|
||||
}, [loadRecipes, currentParams, loadStatistics, loadCategories]);
|
||||
|
||||
// Set page
|
||||
const setPage = useCallback((page: number) => {
|
||||
setPagination(prev => ({ ...prev, page }));
|
||||
}, []);
|
||||
|
||||
// Auto-load on mount and dependencies change
|
||||
useEffect(() => {
|
||||
if (autoLoad && currentTenant?.id) {
|
||||
refresh();
|
||||
}
|
||||
}, [autoLoad, currentTenant?.id, pagination.page]);
|
||||
|
||||
return {
|
||||
// Data
|
||||
recipes,
|
||||
selectedRecipe,
|
||||
categories,
|
||||
statistics,
|
||||
|
||||
// State
|
||||
isLoading,
|
||||
isCreating,
|
||||
isUpdating,
|
||||
isDeleting,
|
||||
error,
|
||||
pagination,
|
||||
|
||||
// Actions
|
||||
loadRecipes,
|
||||
loadRecipe,
|
||||
createRecipe,
|
||||
updateRecipe,
|
||||
deleteRecipe,
|
||||
duplicateRecipe,
|
||||
activateRecipe,
|
||||
checkFeasibility,
|
||||
loadStatistics,
|
||||
loadCategories,
|
||||
clearError,
|
||||
refresh,
|
||||
setPage
|
||||
};
|
||||
};
|
||||
|
||||
// Production Management Hook
|
||||
export interface UseProductionReturn {
|
||||
// Data
|
||||
batches: ProductionBatch[];
|
||||
selectedBatch: ProductionBatch | null;
|
||||
activeBatches: ProductionBatch[];
|
||||
statistics: ProductionStatistics | null;
|
||||
|
||||
// State
|
||||
isLoading: boolean;
|
||||
isCreating: boolean;
|
||||
isUpdating: boolean;
|
||||
isDeleting: boolean;
|
||||
error: string | null;
|
||||
|
||||
// Actions
|
||||
loadBatches: (params?: ProductionBatchSearchParams) => Promise<void>;
|
||||
loadBatch: (batchId: string) => Promise<void>;
|
||||
loadActiveBatches: () => Promise<void>;
|
||||
createBatch: (data: CreateProductionBatchRequest) => Promise<ProductionBatch | null>;
|
||||
updateBatch: (batchId: string, data: UpdateProductionBatchRequest) => Promise<ProductionBatch | null>;
|
||||
deleteBatch: (batchId: string) => Promise<boolean>;
|
||||
startBatch: (batchId: string, data: any) => Promise<ProductionBatch | null>;
|
||||
completeBatch: (batchId: string, data: any) => Promise<ProductionBatch | null>;
|
||||
loadStatistics: (startDate?: string, endDate?: string) => Promise<void>;
|
||||
clearError: () => void;
|
||||
refresh: () => Promise<void>;
|
||||
}
|
||||
|
||||
export const useProduction = (autoLoad: boolean = true): UseProductionReturn => {
|
||||
const { currentTenant } = useTenant();
|
||||
const { user } = useAuth();
|
||||
|
||||
// State
|
||||
const [batches, setBatches] = useState<ProductionBatch[]>([]);
|
||||
const [selectedBatch, setSelectedBatch] = useState<ProductionBatch | null>(null);
|
||||
const [activeBatches, setActiveBatches] = useState<ProductionBatch[]>([]);
|
||||
const [statistics, setStatistics] = useState<ProductionStatistics | null>(null);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [isCreating, setIsCreating] = useState(false);
|
||||
const [isUpdating, setIsUpdating] = useState(false);
|
||||
const [isDeleting, setIsDeleting] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
// Load batches
|
||||
const loadBatches = useCallback(async (params: ProductionBatchSearchParams = {}) => {
|
||||
if (!currentTenant?.id) return;
|
||||
|
||||
setIsLoading(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
const batchesData = await recipesService.getProductionBatches(currentTenant.id, params);
|
||||
setBatches(batchesData);
|
||||
} catch (err: any) {
|
||||
const errorMessage = err.response?.data?.detail || err.message || 'Error loading production batches';
|
||||
setError(errorMessage);
|
||||
toast.error(errorMessage);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, [currentTenant?.id]);
|
||||
|
||||
// Load single batch
|
||||
const loadBatch = useCallback(async (batchId: string) => {
|
||||
if (!currentTenant?.id) return;
|
||||
|
||||
setIsLoading(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
const batch = await recipesService.getProductionBatch(currentTenant.id, batchId);
|
||||
setSelectedBatch(batch);
|
||||
} catch (err: any) {
|
||||
const errorMessage = err.response?.data?.detail || err.message || 'Error loading production batch';
|
||||
setError(errorMessage);
|
||||
toast.error(errorMessage);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, [currentTenant?.id]);
|
||||
|
||||
// Load active batches
|
||||
const loadActiveBatches = useCallback(async () => {
|
||||
if (!currentTenant?.id) return;
|
||||
|
||||
try {
|
||||
const activeBatchesData = await recipesService.getActiveProductionBatches(currentTenant.id);
|
||||
setActiveBatches(activeBatchesData);
|
||||
} catch (err: any) {
|
||||
console.error('Error loading active batches:', err);
|
||||
}
|
||||
}, [currentTenant?.id]);
|
||||
|
||||
// Create batch
|
||||
const createBatch = useCallback(async (data: CreateProductionBatchRequest): Promise<ProductionBatch | null> => {
|
||||
if (!currentTenant?.id || !user?.id) return null;
|
||||
|
||||
setIsCreating(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
const newBatch = await recipesService.createProductionBatch(currentTenant.id, user.id, data);
|
||||
|
||||
// Add to local state
|
||||
setBatches(prev => [newBatch, ...prev]);
|
||||
|
||||
toast.success('Production batch created successfully');
|
||||
return newBatch;
|
||||
} catch (err: any) {
|
||||
const errorMessage = err.response?.data?.detail || err.message || 'Error creating production batch';
|
||||
setError(errorMessage);
|
||||
toast.error(errorMessage);
|
||||
return null;
|
||||
} finally {
|
||||
setIsCreating(false);
|
||||
}
|
||||
}, [currentTenant?.id, user?.id]);
|
||||
|
||||
// Update batch
|
||||
const updateBatch = useCallback(async (batchId: string, data: UpdateProductionBatchRequest): Promise<ProductionBatch | null> => {
|
||||
if (!currentTenant?.id || !user?.id) return null;
|
||||
|
||||
setIsUpdating(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
const updatedBatch = await recipesService.updateProductionBatch(currentTenant.id, user.id, batchId, data);
|
||||
|
||||
// Update local state
|
||||
setBatches(prev => prev.map(batch =>
|
||||
batch.id === batchId ? updatedBatch : batch
|
||||
));
|
||||
|
||||
if (selectedBatch?.id === batchId) {
|
||||
setSelectedBatch(updatedBatch);
|
||||
}
|
||||
|
||||
toast.success('Production batch updated successfully');
|
||||
return updatedBatch;
|
||||
} catch (err: any) {
|
||||
const errorMessage = err.response?.data?.detail || err.message || 'Error updating production batch';
|
||||
setError(errorMessage);
|
||||
toast.error(errorMessage);
|
||||
return null;
|
||||
} finally {
|
||||
setIsUpdating(false);
|
||||
}
|
||||
}, [currentTenant?.id, user?.id, selectedBatch?.id]);
|
||||
|
||||
// Delete batch
|
||||
const deleteBatch = useCallback(async (batchId: string): Promise<boolean> => {
|
||||
if (!currentTenant?.id) return false;
|
||||
|
||||
setIsDeleting(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
await recipesService.deleteProductionBatch(currentTenant.id, batchId);
|
||||
|
||||
// Remove from local state
|
||||
setBatches(prev => prev.filter(batch => batch.id !== batchId));
|
||||
|
||||
if (selectedBatch?.id === batchId) {
|
||||
setSelectedBatch(null);
|
||||
}
|
||||
|
||||
toast.success('Production batch deleted successfully');
|
||||
return true;
|
||||
} catch (err: any) {
|
||||
const errorMessage = err.response?.data?.detail || err.message || 'Error deleting production batch';
|
||||
setError(errorMessage);
|
||||
toast.error(errorMessage);
|
||||
return false;
|
||||
} finally {
|
||||
setIsDeleting(false);
|
||||
}
|
||||
}, [currentTenant?.id, selectedBatch?.id]);
|
||||
|
||||
// Start batch
|
||||
const startBatch = useCallback(async (batchId: string, data: any): Promise<ProductionBatch | null> => {
|
||||
if (!currentTenant?.id || !user?.id) return null;
|
||||
|
||||
setIsUpdating(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
const startedBatch = await recipesService.startProductionBatch(currentTenant.id, user.id, batchId, data);
|
||||
|
||||
// Update local state
|
||||
setBatches(prev => prev.map(batch =>
|
||||
batch.id === batchId ? startedBatch : batch
|
||||
));
|
||||
|
||||
if (selectedBatch?.id === batchId) {
|
||||
setSelectedBatch(startedBatch);
|
||||
}
|
||||
|
||||
toast.success('Production batch started successfully');
|
||||
return startedBatch;
|
||||
} catch (err: any) {
|
||||
const errorMessage = err.response?.data?.detail || err.message || 'Error starting production batch';
|
||||
setError(errorMessage);
|
||||
toast.error(errorMessage);
|
||||
return null;
|
||||
} finally {
|
||||
setIsUpdating(false);
|
||||
}
|
||||
}, [currentTenant?.id, user?.id, selectedBatch?.id]);
|
||||
|
||||
// Complete batch
|
||||
const completeBatch = useCallback(async (batchId: string, data: any): Promise<ProductionBatch | null> => {
|
||||
if (!currentTenant?.id || !user?.id) return null;
|
||||
|
||||
setIsUpdating(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
const completedBatch = await recipesService.completeProductionBatch(currentTenant.id, user.id, batchId, data);
|
||||
|
||||
// Update local state
|
||||
setBatches(prev => prev.map(batch =>
|
||||
batch.id === batchId ? completedBatch : batch
|
||||
));
|
||||
|
||||
if (selectedBatch?.id === batchId) {
|
||||
setSelectedBatch(completedBatch);
|
||||
}
|
||||
|
||||
toast.success('Production batch completed successfully');
|
||||
return completedBatch;
|
||||
} catch (err: any) {
|
||||
const errorMessage = err.response?.data?.detail || err.message || 'Error completing production batch';
|
||||
setError(errorMessage);
|
||||
toast.error(errorMessage);
|
||||
return null;
|
||||
} finally {
|
||||
setIsUpdating(false);
|
||||
}
|
||||
}, [currentTenant?.id, user?.id, selectedBatch?.id]);
|
||||
|
||||
// Load statistics
|
||||
const loadStatistics = useCallback(async (startDate?: string, endDate?: string) => {
|
||||
if (!currentTenant?.id) return;
|
||||
|
||||
try {
|
||||
const stats = await recipesService.getProductionStatistics(currentTenant.id, startDate, endDate);
|
||||
setStatistics(stats);
|
||||
} catch (err: any) {
|
||||
console.error('Error loading production statistics:', err);
|
||||
}
|
||||
}, [currentTenant?.id]);
|
||||
|
||||
// Clear error
|
||||
const clearError = useCallback(() => {
|
||||
setError(null);
|
||||
}, []);
|
||||
|
||||
// Refresh
|
||||
const refresh = useCallback(async () => {
|
||||
await Promise.all([
|
||||
loadBatches(),
|
||||
loadActiveBatches(),
|
||||
loadStatistics()
|
||||
]);
|
||||
}, [loadBatches, loadActiveBatches, loadStatistics]);
|
||||
|
||||
// Auto-load on mount
|
||||
useEffect(() => {
|
||||
if (autoLoad && currentTenant?.id) {
|
||||
refresh();
|
||||
}
|
||||
}, [autoLoad, currentTenant?.id]);
|
||||
|
||||
return {
|
||||
// Data
|
||||
batches,
|
||||
selectedBatch,
|
||||
activeBatches,
|
||||
statistics,
|
||||
|
||||
// State
|
||||
isLoading,
|
||||
isCreating,
|
||||
isUpdating,
|
||||
isDeleting,
|
||||
error,
|
||||
|
||||
// Actions
|
||||
loadBatches,
|
||||
loadBatch,
|
||||
loadActiveBatches,
|
||||
createBatch,
|
||||
updateBatch,
|
||||
deleteBatch,
|
||||
startBatch,
|
||||
completeBatch,
|
||||
loadStatistics,
|
||||
clearError,
|
||||
refresh
|
||||
};
|
||||
};
|
||||
@@ -1,200 +0,0 @@
|
||||
// frontend/src/api/hooks/useSales.ts
|
||||
/**
|
||||
* Sales Data Management Hooks
|
||||
*/
|
||||
|
||||
import { useState, useCallback } from 'react';
|
||||
import { salesService } from '../services/sales.service';
|
||||
import type {
|
||||
SalesData,
|
||||
SalesValidationResult,
|
||||
SalesDataQuery,
|
||||
SalesDataImport,
|
||||
SalesImportResult,
|
||||
DashboardStats,
|
||||
ActivityItem,
|
||||
} from '../types';
|
||||
|
||||
export const useSales = () => {
|
||||
const [salesData, setSalesData] = useState<SalesData[]>([]);
|
||||
const [dashboardStats, setDashboardStats] = useState<DashboardStats | null>(null);
|
||||
const [recentActivity, setRecentActivity] = useState<ActivityItem[]>([]);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [uploadProgress, setUploadProgress] = useState<number>(0);
|
||||
|
||||
const uploadSalesHistory = useCallback(async (
|
||||
tenantId: string,
|
||||
file: File,
|
||||
additionalData?: Record<string, any>
|
||||
): Promise<SalesImportResult> => {
|
||||
try {
|
||||
setIsLoading(true);
|
||||
setError(null);
|
||||
setUploadProgress(0);
|
||||
|
||||
const result = await salesService.uploadSalesHistory(tenantId, file, {
|
||||
...additionalData,
|
||||
onProgress: (progress) => {
|
||||
setUploadProgress(progress.percentage);
|
||||
},
|
||||
});
|
||||
|
||||
return result;
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : 'Upload failed';
|
||||
setError(message);
|
||||
throw error;
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
setUploadProgress(0);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const validateSalesData = useCallback(async (
|
||||
tenantId: string,
|
||||
file: File
|
||||
): Promise<SalesValidationResult> => {
|
||||
try {
|
||||
setIsLoading(true);
|
||||
setError(null);
|
||||
|
||||
const result = await salesService.validateSalesData(tenantId, file);
|
||||
return result;
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : 'Validation failed';
|
||||
setError(message);
|
||||
throw error;
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const getSalesData = useCallback(async (
|
||||
tenantId: string,
|
||||
query?: SalesDataQuery
|
||||
): Promise<SalesData[]> => {
|
||||
try {
|
||||
setIsLoading(true);
|
||||
setError(null);
|
||||
|
||||
const response = await salesService.getSalesData(tenantId, query);
|
||||
setSalesData(response.data);
|
||||
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : 'Failed to get sales data';
|
||||
setError(message);
|
||||
throw error;
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const getDashboardStats = useCallback(async (tenantId: string): Promise<DashboardStats> => {
|
||||
try {
|
||||
setIsLoading(true);
|
||||
setError(null);
|
||||
|
||||
const stats = await salesService.getDashboardStats(tenantId);
|
||||
setDashboardStats(stats);
|
||||
|
||||
return stats;
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : 'Failed to get dashboard stats';
|
||||
setError(message);
|
||||
throw error;
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const getRecentActivity = useCallback(async (tenantId: string, limit?: number): Promise<ActivityItem[]> => {
|
||||
try {
|
||||
setIsLoading(true);
|
||||
setError(null);
|
||||
|
||||
const activity = await salesService.getRecentActivity(tenantId, limit);
|
||||
setRecentActivity(activity);
|
||||
|
||||
return activity;
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : 'Failed to get recent activity';
|
||||
setError(message);
|
||||
throw error;
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const exportSalesData = useCallback(async (
|
||||
tenantId: string,
|
||||
format: 'csv' | 'excel' | 'json',
|
||||
query?: SalesDataQuery
|
||||
): Promise<void> => {
|
||||
try {
|
||||
setIsLoading(true);
|
||||
setError(null);
|
||||
|
||||
const blob = await salesService.exportSalesData(tenantId, format, query);
|
||||
|
||||
// Create download link
|
||||
const url = window.URL.createObjectURL(blob);
|
||||
const link = document.createElement('a');
|
||||
link.href = url;
|
||||
link.download = `sales-data.${format}`;
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
document.body.removeChild(link);
|
||||
window.URL.revokeObjectURL(url);
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : 'Export failed';
|
||||
setError(message);
|
||||
throw error;
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
|
||||
/**
|
||||
* Get Sales Analytics
|
||||
*/
|
||||
const getSalesAnalytics = useCallback(async (
|
||||
tenantId: string,
|
||||
startDate?: string,
|
||||
endDate?: string
|
||||
) => {
|
||||
try {
|
||||
setIsLoading(true);
|
||||
setError(null);
|
||||
|
||||
const analytics = await salesService.getSalesAnalytics(tenantId, startDate, endDate);
|
||||
|
||||
return analytics;
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : 'Failed to get sales analytics';
|
||||
setError(message);
|
||||
throw error;
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
return {
|
||||
salesData,
|
||||
dashboardStats,
|
||||
recentActivity,
|
||||
isLoading,
|
||||
error,
|
||||
uploadProgress,
|
||||
uploadSalesHistory,
|
||||
validateSalesData,
|
||||
getSalesData,
|
||||
getDashboardStats,
|
||||
getRecentActivity,
|
||||
exportSalesData,
|
||||
getSalesAnalytics,
|
||||
clearError: () => setError(null),
|
||||
};
|
||||
};
|
||||
@@ -1,101 +0,0 @@
|
||||
// Simplified useSuppliers hook for TypeScript compatibility
|
||||
import { useState } from 'react';
|
||||
import {
|
||||
SupplierSummary,
|
||||
CreateSupplierRequest,
|
||||
UpdateSupplierRequest,
|
||||
SupplierSearchParams,
|
||||
SupplierStatistics,
|
||||
PurchaseOrder,
|
||||
CreatePurchaseOrderRequest,
|
||||
PurchaseOrderSearchParams,
|
||||
PurchaseOrderStatistics,
|
||||
Delivery,
|
||||
DeliverySearchParams,
|
||||
DeliveryPerformanceStats
|
||||
} from '../services/suppliers.service';
|
||||
|
||||
export const useSuppliers = () => {
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
// Simple stub implementations
|
||||
const getSuppliers = async (params?: SupplierSearchParams) => {
|
||||
setIsLoading(true);
|
||||
try {
|
||||
// Mock data for now
|
||||
return [];
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Unknown error');
|
||||
throw err;
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const createSupplier = async (data: CreateSupplierRequest) => {
|
||||
setIsLoading(true);
|
||||
try {
|
||||
// Mock implementation
|
||||
return { id: '1', ...data } as any;
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Unknown error');
|
||||
throw err;
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const updateSupplier = async (id: string, data: UpdateSupplierRequest) => {
|
||||
setIsLoading(true);
|
||||
try {
|
||||
// Mock implementation
|
||||
return { id, ...data } as any;
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Unknown error');
|
||||
throw err;
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Return all the expected properties/methods
|
||||
return {
|
||||
suppliers: [],
|
||||
isLoading,
|
||||
error,
|
||||
getSuppliers,
|
||||
createSupplier,
|
||||
updateSupplier,
|
||||
deleteSupplier: async () => {},
|
||||
getSupplierStatistics: async () => ({} as SupplierStatistics),
|
||||
getActiveSuppliers: async () => [] as SupplierSummary[],
|
||||
getTopSuppliers: async () => [] as SupplierSummary[],
|
||||
getSuppliersNeedingReview: async () => [] as SupplierSummary[],
|
||||
approveSupplier: async () => {},
|
||||
// Purchase orders
|
||||
getPurchaseOrders: async () => [] as PurchaseOrder[],
|
||||
createPurchaseOrder: async () => ({} as PurchaseOrder),
|
||||
updatePurchaseOrderStatus: async () => ({} as PurchaseOrder),
|
||||
// Deliveries
|
||||
getDeliveries: async () => [] as Delivery[],
|
||||
getTodaysDeliveries: async () => [] as Delivery[],
|
||||
getDeliveryPerformanceStats: async () => ({} as DeliveryPerformanceStats),
|
||||
};
|
||||
};
|
||||
|
||||
// Re-export types
|
||||
export type {
|
||||
SupplierSummary,
|
||||
CreateSupplierRequest,
|
||||
UpdateSupplierRequest,
|
||||
SupplierSearchParams,
|
||||
SupplierStatistics,
|
||||
PurchaseOrder,
|
||||
CreatePurchaseOrderRequest,
|
||||
PurchaseOrderSearchParams,
|
||||
PurchaseOrderStatistics,
|
||||
Delivery,
|
||||
DeliverySearchParams,
|
||||
DeliveryPerformanceStats
|
||||
};
|
||||
@@ -1,209 +0,0 @@
|
||||
// frontend/src/api/hooks/useTenant.ts
|
||||
/**
|
||||
* Tenant Management Hooks
|
||||
*/
|
||||
|
||||
import { useState, useCallback } from 'react';
|
||||
import { tenantService } from '../services';
|
||||
import type {
|
||||
TenantInfo,
|
||||
TenantCreate,
|
||||
TenantUpdate,
|
||||
TenantMember,
|
||||
InviteUser,
|
||||
TenantStats,
|
||||
} from '../types';
|
||||
|
||||
export const useTenant = () => {
|
||||
const [tenants, setTenants] = useState<TenantInfo[]>([]);
|
||||
const [currentTenant, setCurrentTenant] = useState<TenantInfo | null>(null);
|
||||
const [members, setMembers] = useState<TenantMember[]>([]);
|
||||
const [stats, setStats] = useState<TenantStats | null>(null);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const createTenant = useCallback(async (data: TenantCreate): Promise<TenantInfo> => {
|
||||
try {
|
||||
setIsLoading(true);
|
||||
setError(null);
|
||||
|
||||
const tenant = await tenantService.createTenant(data);
|
||||
setTenants(prev => [...prev, tenant]);
|
||||
setCurrentTenant(tenant);
|
||||
|
||||
return tenant;
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : 'Failed to create tenant';
|
||||
setError(message);
|
||||
throw error;
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const getTenant = useCallback(async (tenantId: string): Promise<TenantInfo> => {
|
||||
try {
|
||||
setIsLoading(true);
|
||||
setError(null);
|
||||
|
||||
const tenant = await tenantService.getTenant(tenantId);
|
||||
setCurrentTenant(tenant);
|
||||
|
||||
return tenant;
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : 'Failed to get tenant';
|
||||
setError(message);
|
||||
throw error;
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const updateTenant = useCallback(async (tenantId: string, data: TenantUpdate): Promise<TenantInfo> => {
|
||||
try {
|
||||
setIsLoading(true);
|
||||
setError(null);
|
||||
|
||||
const updatedTenant = await tenantService.updateTenant(tenantId, data);
|
||||
setCurrentTenant(updatedTenant);
|
||||
setTenants(prev => prev.map(t => t.id === tenantId ? updatedTenant : t));
|
||||
|
||||
return updatedTenant;
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : 'Failed to update tenant';
|
||||
setError(message);
|
||||
throw error;
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const getUserTenants = useCallback(async (): Promise<TenantInfo[]> => {
|
||||
try {
|
||||
setIsLoading(true);
|
||||
setError(null);
|
||||
|
||||
const userTenants = await tenantService.getUserTenants();
|
||||
setTenants(userTenants);
|
||||
|
||||
return userTenants;
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : 'Failed to get user tenants';
|
||||
setError(message);
|
||||
throw error;
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const getTenantMembers = useCallback(async (tenantId: string): Promise<TenantMember[]> => {
|
||||
try {
|
||||
setIsLoading(true);
|
||||
setError(null);
|
||||
|
||||
const response = await tenantService.getTenantMembers(tenantId);
|
||||
setMembers(response.data);
|
||||
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : 'Failed to get tenant members';
|
||||
setError(message);
|
||||
throw error;
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const inviteUser = useCallback(async (tenantId: string, invitation: InviteUser): Promise<void> => {
|
||||
try {
|
||||
setIsLoading(true);
|
||||
setError(null);
|
||||
|
||||
await tenantService.inviteUser(tenantId, invitation);
|
||||
|
||||
// Refresh members list
|
||||
await getTenantMembers(tenantId);
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : 'Failed to invite user';
|
||||
setError(message);
|
||||
throw error;
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, [getTenantMembers]);
|
||||
|
||||
const removeMember = useCallback(async (tenantId: string, userId: string): Promise<void> => {
|
||||
try {
|
||||
setIsLoading(true);
|
||||
setError(null);
|
||||
|
||||
await tenantService.removeMember(tenantId, userId);
|
||||
setMembers(prev => prev.filter(m => m.user_id !== userId));
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : 'Failed to remove member';
|
||||
setError(message);
|
||||
throw error;
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const updateMemberRole = useCallback(async (tenantId: string, userId: string, role: string): Promise<void> => {
|
||||
try {
|
||||
setIsLoading(true);
|
||||
setError(null);
|
||||
|
||||
const updatedMember = await tenantService.updateMemberRole(tenantId, userId, role);
|
||||
setMembers(prev => prev.map(m => m.user_id === userId ? updatedMember : m));
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : 'Failed to update member role';
|
||||
setError(message);
|
||||
throw error;
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const getTenantStats = useCallback(async (tenantId: string): Promise<TenantStats> => {
|
||||
try {
|
||||
setIsLoading(true);
|
||||
setError(null);
|
||||
|
||||
const tenantStats = await tenantService.getTenantStats(tenantId);
|
||||
setStats(tenantStats);
|
||||
|
||||
return tenantStats;
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : 'Failed to get tenant stats';
|
||||
setError(message);
|
||||
throw error;
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
return {
|
||||
tenants,
|
||||
currentTenant,
|
||||
members,
|
||||
stats,
|
||||
isLoading,
|
||||
error,
|
||||
createTenant,
|
||||
getTenant,
|
||||
updateTenant,
|
||||
getUserTenants,
|
||||
getTenantMembers,
|
||||
inviteUser,
|
||||
removeMember,
|
||||
updateMemberRole,
|
||||
getTenantStats,
|
||||
clearError: () => setError(null),
|
||||
};
|
||||
};
|
||||
|
||||
// Hook to get current tenant ID from context or state
|
||||
export const useTenantId = () => {
|
||||
const { currentTenant } = useTenant();
|
||||
return currentTenant?.id || null;
|
||||
};
|
||||
@@ -1,265 +0,0 @@
|
||||
// frontend/src/api/hooks/useTraining.ts
|
||||
/**
|
||||
* Training Operations Hooks
|
||||
*/
|
||||
|
||||
import { useState, useCallback, useEffect } from 'react';
|
||||
import { trainingService } from '../services';
|
||||
import type {
|
||||
TrainingJobRequest,
|
||||
TrainingJobResponse,
|
||||
ModelInfo,
|
||||
ModelTrainingStats,
|
||||
SingleProductTrainingRequest,
|
||||
} from '../types';
|
||||
|
||||
interface UseTrainingOptions {
|
||||
disablePolling?: boolean; // New option to disable HTTP status polling
|
||||
}
|
||||
|
||||
export const useTraining = (options: UseTrainingOptions = {}) => {
|
||||
|
||||
const { disablePolling = false } = options;
|
||||
|
||||
// Debug logging for option changes
|
||||
console.log('🔧 useTraining initialized with options:', { disablePolling, options });
|
||||
const [jobs, setJobs] = useState<TrainingJobResponse[]>([]);
|
||||
const [currentJob, setCurrentJob] = useState<TrainingJobResponse | null>(null);
|
||||
const [models, setModels] = useState<ModelInfo[]>([]);
|
||||
const [stats, setStats] = useState<ModelTrainingStats | null>(null);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const startTrainingJob = useCallback(async (
|
||||
tenantId: string,
|
||||
request: TrainingJobRequest
|
||||
): Promise<TrainingJobResponse> => {
|
||||
try {
|
||||
setIsLoading(true);
|
||||
setError(null);
|
||||
|
||||
const job = await trainingService.startTrainingJob(tenantId, request);
|
||||
setCurrentJob(job);
|
||||
setJobs(prev => [job, ...prev]);
|
||||
|
||||
return job;
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : 'Failed to start training job';
|
||||
setError(message);
|
||||
throw error;
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const startSingleProductTraining = useCallback(async (
|
||||
tenantId: string,
|
||||
request: SingleProductTrainingRequest
|
||||
): Promise<TrainingJobResponse> => {
|
||||
try {
|
||||
setIsLoading(true);
|
||||
setError(null);
|
||||
|
||||
const job = await trainingService.startSingleProductTraining(tenantId, request);
|
||||
setCurrentJob(job);
|
||||
setJobs(prev => [job, ...prev]);
|
||||
|
||||
return job;
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : 'Failed to start product training';
|
||||
setError(message);
|
||||
throw error;
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const getTrainingJobStatus = useCallback(async (
|
||||
tenantId: string,
|
||||
jobId: string
|
||||
): Promise<TrainingJobResponse> => {
|
||||
try {
|
||||
const job = await trainingService.getTrainingJobStatus(tenantId, jobId);
|
||||
|
||||
// Update job in state
|
||||
setJobs(prev => prev.map(j => j.job_id === jobId ? job : j));
|
||||
if (currentJob?.job_id === jobId) {
|
||||
setCurrentJob(job);
|
||||
}
|
||||
|
||||
return job;
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : 'Failed to get job status';
|
||||
setError(message);
|
||||
throw error;
|
||||
}
|
||||
}, [currentJob]);
|
||||
|
||||
const cancelTrainingJob = useCallback(async (
|
||||
tenantId: string,
|
||||
jobId: string
|
||||
): Promise<void> => {
|
||||
try {
|
||||
setIsLoading(true);
|
||||
setError(null);
|
||||
|
||||
await trainingService.cancelTrainingJob(tenantId, jobId);
|
||||
|
||||
// Update job status in state
|
||||
setJobs(prev => prev.map(j =>
|
||||
j.job_id === jobId ? { ...j, status: 'cancelled' } : j
|
||||
));
|
||||
if (currentJob?.job_id === jobId) {
|
||||
setCurrentJob({ ...currentJob, status: 'cancelled' });
|
||||
}
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : 'Failed to cancel job';
|
||||
setError(message);
|
||||
throw error;
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, [currentJob]);
|
||||
|
||||
const getTrainingJobs = useCallback(async (tenantId: string): Promise<TrainingJobResponse[]> => {
|
||||
try {
|
||||
setIsLoading(true);
|
||||
setError(null);
|
||||
|
||||
const response = await trainingService.getTrainingJobs(tenantId);
|
||||
setJobs(response.data);
|
||||
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : 'Failed to get training jobs';
|
||||
setError(message);
|
||||
throw error;
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const getModels = useCallback(async (tenantId: string): Promise<ModelInfo[]> => {
|
||||
try {
|
||||
setIsLoading(true);
|
||||
setError(null);
|
||||
|
||||
const response = await trainingService.getModels(tenantId);
|
||||
setModels(response.data);
|
||||
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : 'Failed to get models';
|
||||
setError(message);
|
||||
throw error;
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const validateTrainingData = useCallback(async (tenantId: string): Promise<{
|
||||
is_valid: boolean;
|
||||
message: string;
|
||||
details?: any;
|
||||
}> => {
|
||||
try {
|
||||
setIsLoading(true);
|
||||
setError(null);
|
||||
|
||||
const result = await trainingService.validateTrainingData(tenantId);
|
||||
return result;
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : 'Data validation failed';
|
||||
setError(message);
|
||||
throw error;
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const getTrainingStats = useCallback(async (tenantId: string): Promise<ModelTrainingStats> => {
|
||||
try {
|
||||
setIsLoading(true);
|
||||
setError(null);
|
||||
|
||||
const trainingStats = await trainingService.getTrainingStats(tenantId);
|
||||
setStats(trainingStats);
|
||||
|
||||
return trainingStats;
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : 'Failed to get training stats';
|
||||
setError(message);
|
||||
throw error;
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
// Always check disablePolling first and log for debugging
|
||||
console.log('🔍 useTraining polling check:', {
|
||||
disablePolling,
|
||||
jobsCount: jobs.length,
|
||||
runningJobs: jobs.filter(job => job.status === 'running' || job.status === 'pending').length
|
||||
});
|
||||
|
||||
// STRICT CHECK: Skip polling if disabled - NO EXCEPTIONS
|
||||
if (disablePolling === true) {
|
||||
console.log('🚫 HTTP status polling STRICTLY DISABLED - using WebSocket instead');
|
||||
console.log('🚫 Effect triggered but polling prevented by disablePolling flag');
|
||||
return; // Early return - no cleanup needed, no interval creation
|
||||
}
|
||||
|
||||
const runningJobs = jobs.filter(job => job.status === 'running' || job.status === 'pending');
|
||||
|
||||
if (runningJobs.length === 0) {
|
||||
console.log('⏸️ No running jobs - skipping polling setup');
|
||||
return;
|
||||
}
|
||||
|
||||
console.log('🔄 Starting HTTP status polling for', runningJobs.length, 'jobs');
|
||||
|
||||
const interval = setInterval(async () => {
|
||||
// Double-check disablePolling inside interval to prevent race conditions
|
||||
if (disablePolling) {
|
||||
console.log('🚫 Polling disabled during interval - clearing');
|
||||
clearInterval(interval);
|
||||
return;
|
||||
}
|
||||
|
||||
for (const job of runningJobs) {
|
||||
try {
|
||||
const tenantId = job.tenant_id;
|
||||
console.log('📡 HTTP polling job status:', job.job_id);
|
||||
await getTrainingJobStatus(tenantId, job.job_id);
|
||||
} catch (error) {
|
||||
console.error('Failed to refresh job status:', error);
|
||||
}
|
||||
}
|
||||
}, 5000); // Refresh every 5 seconds
|
||||
|
||||
return () => {
|
||||
console.log('🛑 Stopping HTTP status polling (cleanup)');
|
||||
clearInterval(interval);
|
||||
};
|
||||
}, [jobs, getTrainingJobStatus, disablePolling]);
|
||||
|
||||
|
||||
return {
|
||||
jobs,
|
||||
currentJob,
|
||||
models,
|
||||
stats,
|
||||
isLoading,
|
||||
error,
|
||||
startTrainingJob,
|
||||
startSingleProductTraining,
|
||||
getTrainingJobStatus,
|
||||
cancelTrainingJob,
|
||||
getTrainingJobs,
|
||||
getModels,
|
||||
validateTrainingData,
|
||||
getTrainingStats,
|
||||
clearError: () => setError(null),
|
||||
};
|
||||
};
|
||||
@@ -1,64 +0,0 @@
|
||||
// frontend/src/api/index.ts
|
||||
/**
|
||||
* Main API Export
|
||||
* Central entry point for all API functionality
|
||||
*/
|
||||
|
||||
// Export main API client first
|
||||
export { apiClient } from './client';
|
||||
|
||||
// Export all services
|
||||
export {
|
||||
authService,
|
||||
tenantService,
|
||||
salesService,
|
||||
externalService,
|
||||
trainingService,
|
||||
forecastingService,
|
||||
notificationService,
|
||||
inventoryService,
|
||||
api
|
||||
} from './services';
|
||||
|
||||
// Export all hooks
|
||||
export {
|
||||
useAuth,
|
||||
useAuthHeaders,
|
||||
useTenant,
|
||||
useSales,
|
||||
useExternal,
|
||||
useTraining,
|
||||
useForecast,
|
||||
useNotification,
|
||||
useApiHooks,
|
||||
useOnboarding,
|
||||
useInventory,
|
||||
useInventoryProducts
|
||||
} from './hooks';
|
||||
|
||||
// Export WebSocket functionality
|
||||
export {
|
||||
WebSocketManager,
|
||||
useWebSocket,
|
||||
useTrainingWebSocket,
|
||||
useForecastWebSocket,
|
||||
} from './websocket';
|
||||
|
||||
// Export WebSocket types
|
||||
export type {
|
||||
WebSocketConfig,
|
||||
WebSocketMessage,
|
||||
WebSocketHandlers,
|
||||
WebSocketStatus,
|
||||
WebSocketMetrics,
|
||||
} from './websocket';
|
||||
|
||||
// Export types
|
||||
export * from './types';
|
||||
|
||||
// Export configuration
|
||||
export { apiConfig, serviceEndpoints, featureFlags } from './client/config';
|
||||
|
||||
// Setup interceptors on import (move to end to avoid circular deps)
|
||||
import { setupInterceptors } from './client/interceptors';
|
||||
setupInterceptors();
|
||||
@@ -1,107 +0,0 @@
|
||||
// frontend/src/api/services/auth.service.ts
|
||||
/**
|
||||
* Authentication Service
|
||||
* Handles all authentication-related API calls
|
||||
*/
|
||||
|
||||
import { apiClient } from '../client';
|
||||
import { serviceEndpoints } from '../client/config';
|
||||
import type {
|
||||
LoginRequest,
|
||||
LoginResponse,
|
||||
RegisterRequest,
|
||||
UserResponse,
|
||||
PasswordResetRequest,
|
||||
PasswordResetResponse,
|
||||
PasswordResetConfirmRequest,
|
||||
TokenVerification,
|
||||
LogoutResponse,
|
||||
} from '../types';
|
||||
|
||||
export class AuthService {
|
||||
private baseEndpoint = serviceEndpoints.auth;
|
||||
|
||||
/**
|
||||
* User Registration
|
||||
*/
|
||||
async register(data: RegisterRequest): Promise<LoginResponse> {
|
||||
return apiClient.post(`${this.baseEndpoint}/register`, data);
|
||||
}
|
||||
|
||||
/**
|
||||
* User Login
|
||||
*/
|
||||
async login(credentials: LoginRequest): Promise<LoginResponse> {
|
||||
return apiClient.post(`${this.baseEndpoint}/login`, credentials);
|
||||
}
|
||||
|
||||
/**
|
||||
* User Logout
|
||||
*/
|
||||
async logout(): Promise<LogoutResponse> {
|
||||
return apiClient.post(`${this.baseEndpoint}/logout`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get Current User Profile
|
||||
*/
|
||||
async getCurrentUser(): Promise<UserResponse> {
|
||||
return apiClient.get(`/users/me`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Update User Profile
|
||||
*/
|
||||
async updateProfile(data: Partial<UserResponse>): Promise<UserResponse> {
|
||||
return apiClient.put(`/users/me`, data);
|
||||
}
|
||||
|
||||
/**
|
||||
* Verify Token
|
||||
*/
|
||||
async verifyToken(token: string): Promise<TokenVerification> {
|
||||
return apiClient.post(`${this.baseEndpoint}/verify-token`, { token });
|
||||
}
|
||||
|
||||
/**
|
||||
* Refresh Access Token
|
||||
*/
|
||||
async refreshToken(refreshToken: string): Promise<LoginResponse> {
|
||||
return apiClient.post(`${this.baseEndpoint}/refresh`, {
|
||||
refresh_token: refreshToken,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Request Password Reset
|
||||
*/
|
||||
async requestPasswordReset(data: PasswordResetRequest): Promise<PasswordResetResponse> {
|
||||
return apiClient.post(`${this.baseEndpoint}/password-reset`, data);
|
||||
}
|
||||
|
||||
/**
|
||||
* Confirm Password Reset
|
||||
*/
|
||||
async confirmPasswordReset(data: PasswordResetConfirmRequest): Promise<{ message: string }> {
|
||||
return apiClient.post(`${this.baseEndpoint}/password-reset/confirm`, data);
|
||||
}
|
||||
|
||||
/**
|
||||
* Change Password (for authenticated users)
|
||||
*/
|
||||
async changePassword(currentPassword: string, newPassword: string): Promise<{ message: string }> {
|
||||
return apiClient.post(`/users/me/change-password`, {
|
||||
current_password: currentPassword,
|
||||
new_password: newPassword,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete User Account
|
||||
*/
|
||||
async deleteAccount(): Promise<{ message: string }> {
|
||||
return apiClient.delete(`/users/me`);
|
||||
}
|
||||
}
|
||||
|
||||
export const authService = new AuthService();
|
||||
@@ -1,296 +0,0 @@
|
||||
// frontend/src/api/services/external.service.ts
|
||||
/**
|
||||
* External Data Service
|
||||
* Handles weather and traffic data operations for the external microservice
|
||||
*/
|
||||
|
||||
import { apiClient } from '../client';
|
||||
import { RequestTimeouts } from '../client/config';
|
||||
|
||||
// Align with backend WeatherDataResponse schema
|
||||
export interface WeatherData {
|
||||
date: string;
|
||||
temperature?: number;
|
||||
precipitation?: number;
|
||||
humidity?: number;
|
||||
wind_speed?: number;
|
||||
pressure?: number;
|
||||
description?: string;
|
||||
source: string;
|
||||
}
|
||||
|
||||
// Align with backend TrafficDataResponse schema
|
||||
export interface TrafficData {
|
||||
date: string;
|
||||
traffic_volume?: number;
|
||||
pedestrian_count?: number;
|
||||
congestion_level?: string;
|
||||
average_speed?: number;
|
||||
source: string;
|
||||
}
|
||||
|
||||
export interface WeatherForecast {
|
||||
date: string;
|
||||
temperature_min: number;
|
||||
temperature_max: number;
|
||||
temperature_avg: number;
|
||||
precipitation: number;
|
||||
description: string;
|
||||
humidity?: number;
|
||||
wind_speed?: number;
|
||||
}
|
||||
|
||||
export interface HourlyForecast {
|
||||
forecast_datetime: string;
|
||||
generated_at: string;
|
||||
temperature: number;
|
||||
precipitation: number;
|
||||
humidity: number;
|
||||
wind_speed: number;
|
||||
description: string;
|
||||
source: string;
|
||||
hour: number;
|
||||
}
|
||||
|
||||
export class ExternalService {
|
||||
/**
|
||||
* Get Current Weather Data
|
||||
*/
|
||||
async getCurrentWeather(
|
||||
tenantId: string,
|
||||
lat: number,
|
||||
lon: number
|
||||
): Promise<WeatherData> {
|
||||
try {
|
||||
// ✅ FIX 1: Correct endpoint path with tenant ID
|
||||
const endpoint = `/tenants/${tenantId}/weather/current`;
|
||||
|
||||
// ✅ FIX 2: Correct parameter names (latitude/longitude, not lat/lon)
|
||||
const response = await apiClient.get(endpoint, {
|
||||
params: {
|
||||
latitude: lat, // Backend expects 'latitude'
|
||||
longitude: lon // Backend expects 'longitude'
|
||||
}
|
||||
});
|
||||
|
||||
console.log('Weather API response:', response);
|
||||
|
||||
// Return backend response directly (matches WeatherData interface)
|
||||
return response;
|
||||
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch weather from AEMET API via backend:', error);
|
||||
throw new Error(`Weather data unavailable: ${error instanceof Error ? error.message : 'AEMET API connection failed'}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get Weather Forecast
|
||||
*/
|
||||
async getWeatherForecast(
|
||||
tenantId: string,
|
||||
lat: number,
|
||||
lon: number,
|
||||
days: number = 7
|
||||
): Promise<WeatherForecast[]> {
|
||||
try {
|
||||
// Fix: Use POST with JSON body as expected by backend
|
||||
const response = await apiClient.post(`/tenants/${tenantId}/weather/forecast`, {
|
||||
latitude: lat,
|
||||
longitude: lon,
|
||||
days: days
|
||||
});
|
||||
|
||||
// Handle response format
|
||||
if (Array.isArray(response)) {
|
||||
return response;
|
||||
} else if (response && response.forecasts) {
|
||||
return response.forecasts;
|
||||
} else {
|
||||
console.warn('Unexpected weather forecast response format:', response);
|
||||
return [];
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch weather forecast from AEMET API:', error);
|
||||
throw new Error(`Weather forecast unavailable: ${error instanceof Error ? error.message : 'AEMET API connection failed'}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get Hourly Weather Forecast (NEW)
|
||||
*/
|
||||
async getHourlyWeatherForecast(
|
||||
tenantId: string,
|
||||
lat: number,
|
||||
lon: number,
|
||||
hours: number = 48
|
||||
): Promise<HourlyForecast[]> {
|
||||
try {
|
||||
console.log(`🕒 Fetching hourly weather forecast from AEMET API for tenant ${tenantId}`, {
|
||||
latitude: lat,
|
||||
longitude: lon,
|
||||
hours: hours
|
||||
});
|
||||
|
||||
const response = await apiClient.post(`/tenants/${tenantId}/weather/hourly-forecast`, {
|
||||
latitude: lat,
|
||||
longitude: lon,
|
||||
hours: hours
|
||||
});
|
||||
|
||||
// Handle response format
|
||||
if (Array.isArray(response)) {
|
||||
return response;
|
||||
} else if (response && response.data) {
|
||||
return response.data;
|
||||
} else {
|
||||
console.warn('Unexpected hourly forecast response format:', response);
|
||||
return [];
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch hourly forecast from AEMET API:', error);
|
||||
throw new Error(`Hourly forecast unavailable: ${error instanceof Error ? error.message : 'AEMET API connection failed'}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get Historical Weather Data
|
||||
*/
|
||||
async getHistoricalWeather(
|
||||
tenantId: string,
|
||||
lat: number,
|
||||
lon: number,
|
||||
startDate: string,
|
||||
endDate: string
|
||||
): Promise<WeatherData[]> {
|
||||
try {
|
||||
// Fix: Use POST with JSON body as expected by backend
|
||||
const response = await apiClient.post(`/tenants/${tenantId}/weather/historical`, {
|
||||
latitude: lat,
|
||||
longitude: lon,
|
||||
start_date: startDate,
|
||||
end_date: endDate
|
||||
});
|
||||
|
||||
// Return backend response directly (matches WeatherData interface)
|
||||
return Array.isArray(response) ? response : response.data || [];
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch historical weather from AEMET API:', error);
|
||||
throw new Error(`Historical weather data unavailable: ${error instanceof Error ? error.message : 'AEMET API connection failed'}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get Current Traffic Data
|
||||
*/
|
||||
async getCurrentTraffic(
|
||||
tenantId: string,
|
||||
lat: number,
|
||||
lon: number
|
||||
): Promise<TrafficData> {
|
||||
try {
|
||||
const response = await apiClient.get(`/tenants/${tenantId}/traffic/current`, {
|
||||
params: {
|
||||
latitude: lat,
|
||||
longitude: lon
|
||||
}
|
||||
});
|
||||
|
||||
// Return backend response directly (matches TrafficData interface)
|
||||
return response;
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch traffic data from external API:', error);
|
||||
throw new Error(`Traffic data unavailable: ${error instanceof Error ? error.message : 'External API connection failed'}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get Traffic Forecast
|
||||
*/
|
||||
async getTrafficForecast(
|
||||
tenantId: string,
|
||||
lat: number,
|
||||
lon: number,
|
||||
hours: number = 24
|
||||
): Promise<TrafficData[]> {
|
||||
try {
|
||||
// Fix: Use POST with JSON body as expected by backend
|
||||
const response = await apiClient.post(`/tenants/${tenantId}/traffic/forecast`, {
|
||||
latitude: lat,
|
||||
longitude: lon,
|
||||
hours: hours
|
||||
});
|
||||
|
||||
// Return backend response directly (matches TrafficData interface)
|
||||
return Array.isArray(response) ? response : response.data || [];
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch traffic forecast from external API:', error);
|
||||
throw new Error(`Traffic forecast unavailable: ${error instanceof Error ? error.message : 'External API connection failed'}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get Historical Traffic Data
|
||||
*/
|
||||
async getHistoricalTraffic(
|
||||
tenantId: string,
|
||||
lat: number,
|
||||
lon: number,
|
||||
startDate: string,
|
||||
endDate: string
|
||||
): Promise<TrafficData[]> {
|
||||
try {
|
||||
// Fix: Use POST with JSON body as expected by backend
|
||||
const response = await apiClient.post(`/tenants/${tenantId}/traffic/historical`, {
|
||||
latitude: lat,
|
||||
longitude: lon,
|
||||
start_date: startDate,
|
||||
end_date: endDate
|
||||
});
|
||||
|
||||
// Return backend response directly (matches TrafficData interface)
|
||||
return Array.isArray(response) ? response : response.data || [];
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch historical traffic from external API:', error);
|
||||
throw new Error(`Historical traffic data unavailable: ${error instanceof Error ? error.message : 'External API connection failed'}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Test External Service Connectivity
|
||||
*/
|
||||
async testConnectivity(tenantId: string): Promise<{
|
||||
weather: boolean;
|
||||
traffic: boolean;
|
||||
overall: boolean;
|
||||
}> {
|
||||
const results = {
|
||||
weather: false,
|
||||
traffic: false,
|
||||
overall: false
|
||||
};
|
||||
|
||||
try {
|
||||
// Test weather service (AEMET API)
|
||||
await this.getCurrentWeather(tenantId, 40.4168, -3.7038); // Madrid coordinates
|
||||
results.weather = true;
|
||||
} catch (error) {
|
||||
console.warn('AEMET weather service connectivity test failed:', error);
|
||||
results.weather = false;
|
||||
}
|
||||
|
||||
try {
|
||||
// Test traffic service
|
||||
await this.getCurrentTraffic(tenantId, 40.4168, -3.7038); // Madrid coordinates
|
||||
results.traffic = true;
|
||||
} catch (error) {
|
||||
console.warn('Traffic service connectivity test failed:', error);
|
||||
results.traffic = false;
|
||||
}
|
||||
|
||||
results.overall = results.weather && results.traffic;
|
||||
return results;
|
||||
}
|
||||
}
|
||||
|
||||
export const externalService = new ExternalService();
|
||||
@@ -1,301 +0,0 @@
|
||||
// frontend/src/api/services/forecasting.service.ts
|
||||
/**
|
||||
* Forecasting Service
|
||||
* Handles forecast operations and predictions
|
||||
*/
|
||||
|
||||
import { apiClient } from '../client';
|
||||
import { RequestTimeouts } from '../client/config';
|
||||
import type {
|
||||
SingleForecastRequest,
|
||||
BatchForecastRequest,
|
||||
ForecastResponse,
|
||||
BatchForecastResponse,
|
||||
ForecastAlert,
|
||||
QuickForecast,
|
||||
PaginatedResponse,
|
||||
BaseQueryParams,
|
||||
} from '../types';
|
||||
|
||||
export class ForecastingService {
|
||||
/**
|
||||
* Create Single Product Forecast
|
||||
*/
|
||||
async createSingleForecast(
|
||||
tenantId: string,
|
||||
request: SingleForecastRequest
|
||||
): Promise<ForecastResponse[]> {
|
||||
console.log('🔮 Creating single forecast:', { tenantId, request });
|
||||
|
||||
try {
|
||||
// Backend returns single ForecastResponse object
|
||||
const response = await apiClient.post(
|
||||
`/tenants/${tenantId}/forecasts/single`,
|
||||
request,
|
||||
{
|
||||
timeout: RequestTimeouts.MEDIUM,
|
||||
}
|
||||
);
|
||||
|
||||
console.log('🔮 Forecast API Response:', response);
|
||||
console.log('- Type:', typeof response);
|
||||
console.log('- Is Array:', Array.isArray(response));
|
||||
|
||||
// ✅ FIX: Convert single response to array
|
||||
if (response && typeof response === 'object' && !Array.isArray(response)) {
|
||||
// Single forecast response - wrap in array
|
||||
const forecastArray = [response as ForecastResponse];
|
||||
console.log('✅ Converted single forecast to array:', forecastArray);
|
||||
return forecastArray;
|
||||
} else if (Array.isArray(response)) {
|
||||
// Already an array (unexpected but handle gracefully)
|
||||
console.log('✅ Response is already an array:', response);
|
||||
return response;
|
||||
} else {
|
||||
console.error('❌ Unexpected response format:', response);
|
||||
throw new Error('Invalid forecast response format');
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
console.error('❌ Forecast API Error:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create Batch Forecast
|
||||
*/
|
||||
async createBatchForecast(
|
||||
tenantId: string,
|
||||
request: BatchForecastRequest
|
||||
): Promise<BatchForecastResponse> {
|
||||
return apiClient.post(
|
||||
`/tenants/${tenantId}/forecasts/batch`,
|
||||
request,
|
||||
{
|
||||
timeout: RequestTimeouts.LONG,
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get Forecast by ID
|
||||
*/
|
||||
async getForecast(tenantId: string, forecastId: string): Promise<ForecastResponse> {
|
||||
return apiClient.get(`/tenants/${tenantId}/forecasts/${forecastId}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get Forecasts
|
||||
*/
|
||||
async getForecasts(
|
||||
tenantId: string,
|
||||
params?: BaseQueryParams & {
|
||||
inventory_product_id?: string; // Primary way to filter by product
|
||||
product_name?: string; // For backward compatibility - will need inventory service lookup
|
||||
start_date?: string;
|
||||
end_date?: string;
|
||||
model_id?: string;
|
||||
}
|
||||
): Promise<PaginatedResponse<ForecastResponse>> {
|
||||
return apiClient.get(`/tenants/${tenantId}/forecasts`, { params });
|
||||
}
|
||||
|
||||
/**
|
||||
* Get Batch Forecast Status
|
||||
*/
|
||||
async getBatchForecastStatus(
|
||||
tenantId: string,
|
||||
batchId: string
|
||||
): Promise<BatchForecastResponse> {
|
||||
return apiClient.get(`/tenants/${tenantId}/forecasts/batch/${batchId}/status`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get Batch Forecasts
|
||||
*/
|
||||
async getBatchForecasts(
|
||||
tenantId: string,
|
||||
params?: BaseQueryParams & {
|
||||
status?: string;
|
||||
start_date?: string;
|
||||
end_date?: string;
|
||||
}
|
||||
): Promise<PaginatedResponse<BatchForecastResponse>> {
|
||||
return apiClient.get(`/tenants/${tenantId}/forecasts/batch`, { params });
|
||||
}
|
||||
|
||||
/**
|
||||
* Cancel Batch Forecast
|
||||
*/
|
||||
async cancelBatchForecast(tenantId: string, batchId: string): Promise<{ message: string }> {
|
||||
return apiClient.post(`/tenants/${tenantId}/forecasts/batch/${batchId}/cancel`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get Quick Forecasts for Dashboard
|
||||
*/
|
||||
async getQuickForecasts(tenantId: string, limit?: number): Promise<QuickForecast[]> {
|
||||
try {
|
||||
// TODO: Replace with actual /forecasts/quick endpoint when available
|
||||
// For now, use regular forecasts endpoint and transform the data
|
||||
const forecasts = await apiClient.get(`/tenants/${tenantId}/forecasts`, {
|
||||
params: { limit: limit || 10 },
|
||||
});
|
||||
|
||||
// Transform regular forecasts to QuickForecast format
|
||||
// Handle response structure: { tenant_id, forecasts: [...], total_returned }
|
||||
let forecastsArray: any[] = [];
|
||||
|
||||
if (Array.isArray(forecasts)) {
|
||||
// Direct array response (unexpected)
|
||||
forecastsArray = forecasts;
|
||||
} else if (forecasts && typeof forecasts === 'object' && Array.isArray(forecasts.forecasts)) {
|
||||
// Expected object response with forecasts array
|
||||
forecastsArray = forecasts.forecasts;
|
||||
} else {
|
||||
console.warn('Unexpected forecasts response format:', forecasts);
|
||||
return [];
|
||||
}
|
||||
|
||||
return forecastsArray.map((forecast: any) => ({
|
||||
inventory_product_id: forecast.inventory_product_id,
|
||||
product_name: forecast.product_name, // Optional - for display
|
||||
next_day_prediction: forecast.predicted_demand || 0,
|
||||
next_week_avg: forecast.predicted_demand || 0,
|
||||
trend_direction: 'stable' as const,
|
||||
confidence_score: forecast.confidence_level || 0.8,
|
||||
last_updated: forecast.created_at || new Date().toISOString()
|
||||
}));
|
||||
} catch (error) {
|
||||
console.error('QuickForecasts API call failed, using fallback data:', error);
|
||||
|
||||
// Return mock data for common bakery products (using mock inventory_product_ids)
|
||||
return [
|
||||
{
|
||||
inventory_product_id: 'mock-pan-de-molde-001',
|
||||
product_name: 'Pan de Molde',
|
||||
next_day_prediction: 25,
|
||||
next_week_avg: 175,
|
||||
trend_direction: 'stable',
|
||||
confidence_score: 0.85,
|
||||
last_updated: new Date().toISOString()
|
||||
},
|
||||
{
|
||||
inventory_product_id: 'mock-baguettes-002',
|
||||
product_name: 'Baguettes',
|
||||
next_day_prediction: 20,
|
||||
next_week_avg: 140,
|
||||
trend_direction: 'up',
|
||||
confidence_score: 0.92,
|
||||
last_updated: new Date().toISOString()
|
||||
},
|
||||
{
|
||||
inventory_product_id: 'mock-croissants-003',
|
||||
product_name: 'Croissants',
|
||||
next_day_prediction: 15,
|
||||
next_week_avg: 105,
|
||||
trend_direction: 'stable',
|
||||
confidence_score: 0.78,
|
||||
last_updated: new Date().toISOString()
|
||||
},
|
||||
{
|
||||
inventory_product_id: 'mock-magdalenas-004',
|
||||
product_name: 'Magdalenas',
|
||||
next_day_prediction: 12,
|
||||
next_week_avg: 84,
|
||||
trend_direction: 'down',
|
||||
confidence_score: 0.76,
|
||||
last_updated: new Date().toISOString()
|
||||
}
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get Forecast Alerts
|
||||
*/
|
||||
async getForecastAlerts(
|
||||
tenantId: string,
|
||||
params?: BaseQueryParams & {
|
||||
is_active?: boolean;
|
||||
severity?: string;
|
||||
alert_type?: string;
|
||||
}
|
||||
): Promise<PaginatedResponse<ForecastAlert>> {
|
||||
return apiClient.get(`/tenants/${tenantId}/forecasts/alerts`, { params });
|
||||
}
|
||||
|
||||
/**
|
||||
* Acknowledge Forecast Alert
|
||||
*/
|
||||
async acknowledgeForecastAlert(
|
||||
tenantId: string,
|
||||
alertId: string
|
||||
): Promise<ForecastAlert> {
|
||||
return apiClient.post(`/tenants/${tenantId}/forecasts/alerts/${alertId}/acknowledge`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete Forecast
|
||||
*/
|
||||
async deleteForecast(tenantId: string, forecastId: string): Promise<{ message: string }> {
|
||||
return apiClient.delete(`/tenants/${tenantId}/forecasts/${forecastId}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Export Forecasts
|
||||
*/
|
||||
async exportForecasts(
|
||||
tenantId: string,
|
||||
format: 'csv' | 'excel' | 'json',
|
||||
params?: {
|
||||
inventory_product_id?: string; // Primary way to filter by product
|
||||
product_name?: string; // For backward compatibility
|
||||
start_date?: string;
|
||||
end_date?: string;
|
||||
}
|
||||
): Promise<Blob> {
|
||||
const response = await apiClient.request(`/tenants/${tenantId}/forecasts/export`, {
|
||||
method: 'GET',
|
||||
params: { ...params, format },
|
||||
headers: {
|
||||
'Accept': format === 'csv' ? 'text/csv' :
|
||||
format === 'excel' ? 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' :
|
||||
'application/json',
|
||||
},
|
||||
});
|
||||
|
||||
return new Blob([response], {
|
||||
type: format === 'csv' ? 'text/csv' :
|
||||
format === 'excel' ? 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' :
|
||||
'application/json',
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Get Forecast Accuracy Metrics
|
||||
*/
|
||||
async getForecastAccuracy(
|
||||
tenantId: string,
|
||||
params?: {
|
||||
inventory_product_id?: string; // Primary way to filter by product
|
||||
product_name?: string; // For backward compatibility
|
||||
model_id?: string;
|
||||
start_date?: string;
|
||||
end_date?: string;
|
||||
}
|
||||
): Promise<{
|
||||
overall_accuracy: number;
|
||||
product_accuracy: Array<{
|
||||
inventory_product_id: string;
|
||||
product_name?: string; // Optional - for display
|
||||
accuracy: number;
|
||||
sample_size: number;
|
||||
}>;
|
||||
}> {
|
||||
return apiClient.get(`/tenants/${tenantId}/forecasts/accuracy`, { params });
|
||||
}
|
||||
}
|
||||
|
||||
export const forecastingService = new ForecastingService();
|
||||
@@ -1,145 +0,0 @@
|
||||
// frontend/src/api/services/index.ts
|
||||
/**
|
||||
* Main Services Export
|
||||
* Central export point for all API services
|
||||
*/
|
||||
|
||||
// Import and export individual services
|
||||
import { AuthService } from './auth.service';
|
||||
import { TenantService } from './tenant.service';
|
||||
import { SalesService } from './sales.service';
|
||||
import { ExternalService } from './external.service';
|
||||
import { TrainingService } from './training.service';
|
||||
import { ForecastingService } from './forecasting.service';
|
||||
import { NotificationService } from './notification.service';
|
||||
import { OnboardingService } from './onboarding.service';
|
||||
import { InventoryService } from './inventory.service';
|
||||
import { RecipesService } from './recipes.service';
|
||||
import { ProductionService } from './production.service';
|
||||
import { OrdersService } from './orders.service';
|
||||
import { SuppliersService } from './suppliers.service';
|
||||
import { ProcurementService } from './procurement.service';
|
||||
|
||||
// Create service instances
|
||||
export const authService = new AuthService();
|
||||
export const tenantService = new TenantService();
|
||||
export const salesService = new SalesService();
|
||||
export const externalService = new ExternalService();
|
||||
export const trainingService = new TrainingService();
|
||||
export const forecastingService = new ForecastingService();
|
||||
export const notificationService = new NotificationService();
|
||||
export const onboardingService = new OnboardingService();
|
||||
export const inventoryService = new InventoryService();
|
||||
export const recipesService = new RecipesService();
|
||||
export const productionService = new ProductionService();
|
||||
export const ordersService = new OrdersService();
|
||||
export const suppliersService = new SuppliersService();
|
||||
export const procurementService = new ProcurementService();
|
||||
|
||||
// Export the classes as well
|
||||
export {
|
||||
AuthService,
|
||||
TenantService,
|
||||
SalesService,
|
||||
ExternalService,
|
||||
TrainingService,
|
||||
ForecastingService,
|
||||
NotificationService,
|
||||
OnboardingService,
|
||||
InventoryService,
|
||||
RecipesService,
|
||||
ProductionService,
|
||||
OrdersService,
|
||||
SuppliersService,
|
||||
ProcurementService
|
||||
};
|
||||
|
||||
// Import base client
|
||||
import { apiClient } from '../client';
|
||||
export { apiClient };
|
||||
|
||||
// Re-export all types
|
||||
export * from '../types';
|
||||
|
||||
// Create unified API object
|
||||
export const api = {
|
||||
auth: authService,
|
||||
tenant: tenantService,
|
||||
sales: salesService,
|
||||
external: externalService,
|
||||
training: trainingService,
|
||||
forecasting: forecastingService,
|
||||
notification: notificationService,
|
||||
onboarding: onboardingService,
|
||||
inventory: inventoryService,
|
||||
recipes: recipesService,
|
||||
production: productionService,
|
||||
orders: ordersService,
|
||||
suppliers: suppliersService,
|
||||
procurement: procurementService,
|
||||
} as const;
|
||||
|
||||
// Service status checking
|
||||
export interface ServiceHealth {
|
||||
service: string;
|
||||
status: 'healthy' | 'degraded' | 'down';
|
||||
lastChecked: Date;
|
||||
responseTime?: number;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export class HealthService {
|
||||
async checkServiceHealth(): Promise<ServiceHealth[]> {
|
||||
const services = [
|
||||
{ name: 'Auth', endpoint: '/auth/health' },
|
||||
{ name: 'Tenant', endpoint: '/tenants/health' },
|
||||
{ name: 'Sales', endpoint: '/sales/health' },
|
||||
{ name: 'External', endpoint: '/external/health' },
|
||||
{ name: 'Training', endpoint: '/training/health' },
|
||||
{ name: 'Inventory', endpoint: '/inventory/health' },
|
||||
{ name: 'Production', endpoint: '/production/health' },
|
||||
{ name: 'Orders', endpoint: '/orders/health' },
|
||||
{ name: 'Suppliers', endpoint: '/suppliers/health' },
|
||||
{ name: 'Forecasting', endpoint: '/forecasting/health' },
|
||||
{ name: 'Notification', endpoint: '/notifications/health' },
|
||||
{ name: 'Procurement', endpoint: '/procurement-plans/health' },
|
||||
];
|
||||
|
||||
const healthChecks = await Promise.allSettled(
|
||||
services.map(async (service) => {
|
||||
const startTime = Date.now();
|
||||
try {
|
||||
await apiClient.get(service.endpoint, { timeout: 5000 });
|
||||
const responseTime = Date.now() - startTime;
|
||||
|
||||
return {
|
||||
service: service.name,
|
||||
status: 'healthy' as const,
|
||||
lastChecked: new Date(),
|
||||
responseTime,
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
service: service.name,
|
||||
status: 'down' as const,
|
||||
lastChecked: new Date(),
|
||||
error: error instanceof Error ? error.message : 'Unknown error',
|
||||
};
|
||||
}
|
||||
})
|
||||
);
|
||||
|
||||
return healthChecks.map((result, index) =>
|
||||
result.status === 'fulfilled'
|
||||
? result.value
|
||||
: {
|
||||
service: services[index].name,
|
||||
status: 'down' as const,
|
||||
lastChecked: new Date(),
|
||||
error: 'Health check failed',
|
||||
}
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export const healthService = new HealthService();
|
||||
@@ -1,749 +0,0 @@
|
||||
// frontend/src/api/services/inventory.service.ts
|
||||
/**
|
||||
* Inventory Service
|
||||
* Handles inventory management, stock tracking, and product operations
|
||||
*/
|
||||
|
||||
import { apiClient } from '../client';
|
||||
import type { ProductInfo } from '../types';
|
||||
|
||||
// ========== TYPES AND INTERFACES ==========
|
||||
|
||||
export type ProductType = 'ingredient' | 'finished_product';
|
||||
|
||||
export type UnitOfMeasure =
|
||||
| 'kilograms' | 'grams' | 'liters' | 'milliliters'
|
||||
| 'units' | 'pieces' | 'dozens' | 'boxes';
|
||||
|
||||
export type IngredientCategory =
|
||||
| 'flour' | 'yeast' | 'dairy' | 'eggs' | 'sugar'
|
||||
| 'fats' | 'salt' | 'spices' | 'additives' | 'packaging';
|
||||
|
||||
export type ProductCategory =
|
||||
| 'bread' | 'croissants' | 'pastries' | 'cakes'
|
||||
| 'cookies' | 'muffins' | 'sandwiches' | 'beverages' | 'other_products';
|
||||
|
||||
export type StockMovementType =
|
||||
| 'purchase' | 'consumption' | 'adjustment'
|
||||
| 'waste' | 'transfer' | 'return';
|
||||
|
||||
export interface InventoryItem {
|
||||
id: string;
|
||||
tenant_id: string;
|
||||
name: string;
|
||||
product_type: ProductType;
|
||||
category: IngredientCategory | ProductCategory;
|
||||
unit_of_measure: UnitOfMeasure;
|
||||
estimated_shelf_life_days?: number;
|
||||
requires_refrigeration: boolean;
|
||||
requires_freezing: boolean;
|
||||
is_seasonal: boolean;
|
||||
minimum_stock_level?: number;
|
||||
maximum_stock_level?: number;
|
||||
reorder_point?: number;
|
||||
supplier?: string;
|
||||
notes?: string;
|
||||
barcode?: string;
|
||||
sku?: string;
|
||||
cost_per_unit?: number;
|
||||
is_active: boolean;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
|
||||
// Computed fields
|
||||
current_stock?: StockLevel;
|
||||
low_stock_alert?: boolean;
|
||||
expiring_soon_alert?: boolean;
|
||||
recent_movements?: StockMovement[];
|
||||
}
|
||||
|
||||
export interface StockLevel {
|
||||
item_id: string;
|
||||
current_quantity: number;
|
||||
available_quantity: number;
|
||||
reserved_quantity: number;
|
||||
unit_of_measure: UnitOfMeasure;
|
||||
value_estimate?: number;
|
||||
last_updated: string;
|
||||
|
||||
// Batch information
|
||||
batches?: StockBatch[];
|
||||
oldest_batch_date?: string;
|
||||
newest_batch_date?: string;
|
||||
}
|
||||
|
||||
export interface StockBatch {
|
||||
id: string;
|
||||
item_id: string;
|
||||
batch_number?: string;
|
||||
quantity: number;
|
||||
unit_cost?: number;
|
||||
purchase_date?: string;
|
||||
expiration_date?: string;
|
||||
supplier?: string;
|
||||
notes?: string;
|
||||
is_expired: boolean;
|
||||
days_until_expiration?: number;
|
||||
}
|
||||
|
||||
export interface StockMovement {
|
||||
id: string;
|
||||
item_id: string;
|
||||
movement_type: StockMovementType;
|
||||
quantity: number;
|
||||
unit_cost?: number;
|
||||
total_cost?: number;
|
||||
batch_id?: string;
|
||||
reference_id?: string;
|
||||
notes?: string;
|
||||
movement_date: string;
|
||||
created_by: string;
|
||||
created_at: string;
|
||||
|
||||
// Related data
|
||||
item_name?: string;
|
||||
batch_info?: StockBatch;
|
||||
}
|
||||
|
||||
|
||||
// ========== REQUEST/RESPONSE TYPES ==========
|
||||
|
||||
export interface CreateInventoryItemRequest {
|
||||
name: string;
|
||||
product_type: ProductType;
|
||||
category: IngredientCategory | ProductCategory;
|
||||
unit_of_measure: UnitOfMeasure;
|
||||
estimated_shelf_life_days?: number;
|
||||
requires_refrigeration?: boolean;
|
||||
requires_freezing?: boolean;
|
||||
is_seasonal?: boolean;
|
||||
minimum_stock_level?: number;
|
||||
maximum_stock_level?: number;
|
||||
reorder_point?: number;
|
||||
supplier?: string;
|
||||
notes?: string;
|
||||
barcode?: string;
|
||||
cost_per_unit?: number;
|
||||
}
|
||||
|
||||
export interface UpdateInventoryItemRequest extends Partial<CreateInventoryItemRequest> {
|
||||
is_active?: boolean;
|
||||
}
|
||||
|
||||
export interface StockAdjustmentRequest {
|
||||
movement_type: StockMovementType;
|
||||
quantity: number;
|
||||
unit_cost?: number;
|
||||
batch_number?: string;
|
||||
expiration_date?: string;
|
||||
supplier?: string;
|
||||
notes?: string;
|
||||
}
|
||||
|
||||
export interface InventorySearchParams {
|
||||
search?: string;
|
||||
product_type?: ProductType;
|
||||
category?: string;
|
||||
is_active?: boolean;
|
||||
low_stock_only?: boolean;
|
||||
expiring_soon_only?: boolean;
|
||||
page?: number;
|
||||
limit?: number;
|
||||
sort_by?: 'name' | 'category' | 'stock_level' | 'last_movement' | 'created_at';
|
||||
sort_order?: 'asc' | 'desc';
|
||||
}
|
||||
|
||||
export interface StockMovementSearchParams {
|
||||
item_id?: string;
|
||||
movement_type?: StockMovementType;
|
||||
date_from?: string;
|
||||
date_to?: string;
|
||||
page?: number;
|
||||
limit?: number;
|
||||
}
|
||||
|
||||
export interface InventoryDashboardData {
|
||||
total_items: number;
|
||||
total_value: number;
|
||||
low_stock_count: number;
|
||||
expiring_soon_count: number;
|
||||
recent_movements: StockMovement[];
|
||||
top_items_by_value: InventoryItem[];
|
||||
category_breakdown: {
|
||||
category: string;
|
||||
count: number;
|
||||
value: number;
|
||||
}[];
|
||||
movement_trends: {
|
||||
date: string;
|
||||
purchases: number;
|
||||
consumption: number;
|
||||
waste: number;
|
||||
}[];
|
||||
}
|
||||
|
||||
export interface PaginatedResponse<T> {
|
||||
items: T[];
|
||||
total: number;
|
||||
page: number;
|
||||
limit: number;
|
||||
total_pages: number;
|
||||
}
|
||||
|
||||
// ========== INVENTORY SERVICE CLASS ==========
|
||||
|
||||
export class InventoryService {
|
||||
private baseEndpoint = '';
|
||||
|
||||
// ========== INVENTORY ITEMS ==========
|
||||
|
||||
/**
|
||||
* Get inventory items with filtering and pagination
|
||||
*/
|
||||
async getInventoryItems(
|
||||
tenantId: string,
|
||||
params?: InventorySearchParams
|
||||
): Promise<PaginatedResponse<InventoryItem>> {
|
||||
const searchParams = new URLSearchParams();
|
||||
|
||||
if (params) {
|
||||
Object.entries(params).forEach(([key, value]) => {
|
||||
if (value !== undefined && value !== null) {
|
||||
searchParams.append(key, value.toString());
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
const query = searchParams.toString();
|
||||
const url = `/tenants/${tenantId}/ingredients${query ? `?${query}` : ''}`;
|
||||
|
||||
console.log('🔍 InventoryService: Fetching inventory items from:', url);
|
||||
|
||||
try {
|
||||
console.log('🔑 InventoryService: Making request with auth token:', localStorage.getItem('auth_token') ? 'Present' : 'Missing');
|
||||
const response = await apiClient.get(url);
|
||||
console.log('📋 InventoryService: Raw response:', response);
|
||||
console.log('📋 InventoryService: Response type:', typeof response);
|
||||
console.log('📋 InventoryService: Response keys:', response ? Object.keys(response) : 'null');
|
||||
|
||||
// Handle different response formats
|
||||
if (Array.isArray(response)) {
|
||||
// Direct array response
|
||||
console.log('✅ InventoryService: Array response with', response.length, 'items');
|
||||
return {
|
||||
items: response,
|
||||
total: response.length,
|
||||
page: 1,
|
||||
limit: response.length,
|
||||
total_pages: 1
|
||||
};
|
||||
} else if (response && typeof response === 'object') {
|
||||
// Check if it's already paginated
|
||||
if ('items' in response && Array.isArray(response.items)) {
|
||||
console.log('✅ InventoryService: Paginated response with', response.items.length, 'items');
|
||||
return response;
|
||||
}
|
||||
|
||||
// Handle object with numeric keys (convert to array)
|
||||
const keys = Object.keys(response);
|
||||
if (keys.length > 0 && keys.every(key => !isNaN(Number(key)))) {
|
||||
const items = Object.values(response);
|
||||
console.log('✅ InventoryService: Numeric keys response with', items.length, 'items');
|
||||
return {
|
||||
items,
|
||||
total: items.length,
|
||||
page: 1,
|
||||
limit: items.length,
|
||||
total_pages: 1
|
||||
};
|
||||
}
|
||||
|
||||
// Handle empty object - this seems to be what we're getting
|
||||
if (keys.length === 0) {
|
||||
console.log('📭 InventoryService: Empty object response - backend has no inventory items for this tenant');
|
||||
throw new Error('NO_INVENTORY_ITEMS'); // This will trigger fallback in useInventory
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback: unexpected response format
|
||||
console.warn('⚠️ InventoryService: Unexpected response format, keys:', Object.keys(response || {}));
|
||||
throw new Error('UNEXPECTED_RESPONSE_FORMAT');
|
||||
} catch (error) {
|
||||
console.error('❌ InventoryService: Failed to fetch inventory items:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get single inventory item by ID
|
||||
*/
|
||||
async getInventoryItem(tenantId: string, itemId: string): Promise<InventoryItem> {
|
||||
return apiClient.get(`/tenants/${tenantId}/ingredients/${itemId}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create new inventory item
|
||||
*/
|
||||
async createInventoryItem(
|
||||
tenantId: string,
|
||||
data: CreateInventoryItemRequest
|
||||
): Promise<InventoryItem> {
|
||||
return apiClient.post(`/tenants/${tenantId}/ingredients`, data);
|
||||
}
|
||||
|
||||
/**
|
||||
* Update existing inventory item
|
||||
*/
|
||||
async updateInventoryItem(
|
||||
tenantId: string,
|
||||
itemId: string,
|
||||
data: UpdateInventoryItemRequest
|
||||
): Promise<InventoryItem> {
|
||||
return apiClient.put(`/tenants/${tenantId}/ingredients/${itemId}`, data);
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete inventory item (soft delete)
|
||||
*/
|
||||
async deleteInventoryItem(tenantId: string, itemId: string): Promise<void> {
|
||||
return apiClient.delete(`/tenants/${tenantId}/ingredients/${itemId}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Bulk update inventory items
|
||||
*/
|
||||
async bulkUpdateInventoryItems(
|
||||
tenantId: string,
|
||||
updates: { id: string; data: UpdateInventoryItemRequest }[]
|
||||
): Promise<{ success: number; failed: number; errors: string[] }> {
|
||||
return apiClient.post(`/tenants/${tenantId}/ingredients/bulk-update`, {
|
||||
updates
|
||||
});
|
||||
}
|
||||
|
||||
// ========== STOCK MANAGEMENT ==========
|
||||
|
||||
/**
|
||||
* Get current stock level for an item
|
||||
*/
|
||||
async getStockLevel(tenantId: string, itemId: string): Promise<StockLevel> {
|
||||
return apiClient.get(`/tenants/${tenantId}/ingredients/${itemId}/stock`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get stock levels for all items
|
||||
*/
|
||||
async getAllStockLevels(tenantId: string): Promise<StockLevel[]> {
|
||||
// TODO: Map to correct endpoint when available
|
||||
return [];
|
||||
// return apiClient.get(`/stock/summary`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Adjust stock level (purchase, consumption, waste, etc.)
|
||||
*/
|
||||
async adjustStock(
|
||||
tenantId: string,
|
||||
itemId: string,
|
||||
adjustment: StockAdjustmentRequest
|
||||
): Promise<StockMovement> {
|
||||
return apiClient.post(
|
||||
`/stock/consume`,
|
||||
adjustment
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Bulk stock adjustments
|
||||
*/
|
||||
async bulkAdjustStock(
|
||||
tenantId: string,
|
||||
adjustments: { item_id: string; adjustment: StockAdjustmentRequest }[]
|
||||
): Promise<{ success: number; failed: number; movements: StockMovement[]; errors: string[] }> {
|
||||
return apiClient.post(`${this.baseEndpoint}/tenants/${tenantId}/inventory/stock/bulk-adjust`, {
|
||||
adjustments
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Get stock movements with filtering
|
||||
*/
|
||||
async getStockMovements(
|
||||
tenantId: string,
|
||||
params?: StockMovementSearchParams
|
||||
): Promise<PaginatedResponse<StockMovement>> {
|
||||
const searchParams = new URLSearchParams();
|
||||
|
||||
if (params) {
|
||||
Object.entries(params).forEach(([key, value]) => {
|
||||
if (value !== undefined && value !== null) {
|
||||
searchParams.append(key, value.toString());
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
const query = searchParams.toString();
|
||||
const url = `${this.baseEndpoint}/tenants/${tenantId}/inventory/movements${query ? `?${query}` : ''}`;
|
||||
|
||||
return apiClient.get(url);
|
||||
}
|
||||
|
||||
|
||||
// ========== DASHBOARD & ANALYTICS ==========
|
||||
|
||||
|
||||
/**
|
||||
* Get inventory value report
|
||||
*/
|
||||
async getInventoryValue(tenantId: string): Promise<{
|
||||
total_value: number;
|
||||
by_category: { category: string; value: number; percentage: number }[];
|
||||
by_product_type: { type: ProductType; value: number; percentage: number }[];
|
||||
}> {
|
||||
return apiClient.get(`${this.baseEndpoint}/tenants/${tenantId}/inventory/value`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get low stock report
|
||||
*/
|
||||
async getLowStockReport(tenantId: string): Promise<{
|
||||
items: InventoryItem[];
|
||||
total_affected: number;
|
||||
estimated_loss: number;
|
||||
}> {
|
||||
return apiClient.get(`${this.baseEndpoint}/tenants/${tenantId}/inventory/reports/low-stock`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get expiring items report
|
||||
*/
|
||||
async getExpiringItemsReport(tenantId: string, days?: number): Promise<{
|
||||
items: (InventoryItem & { batches: StockBatch[] })[];
|
||||
total_affected: number;
|
||||
estimated_loss: number;
|
||||
}> {
|
||||
const params = days ? `?days=${days}` : '';
|
||||
return apiClient.get(`${this.baseEndpoint}/tenants/${tenantId}/inventory/reports/expiring${params}`);
|
||||
}
|
||||
|
||||
// ========== IMPORT/EXPORT ==========
|
||||
|
||||
/**
|
||||
* Export inventory data to CSV
|
||||
*/
|
||||
async exportInventory(tenantId: string, format: 'csv' | 'excel' = 'csv'): Promise<Blob> {
|
||||
const response = await apiClient.getRaw(
|
||||
`${this.baseEndpoint}/tenants/${tenantId}/inventory/export?format=${format}`
|
||||
);
|
||||
return response.blob();
|
||||
}
|
||||
|
||||
/**
|
||||
* Import inventory from file
|
||||
*/
|
||||
async importInventory(tenantId: string, file: File): Promise<{
|
||||
success: number;
|
||||
failed: number;
|
||||
errors: string[];
|
||||
created_items: InventoryItem[];
|
||||
}> {
|
||||
const formData = new FormData();
|
||||
formData.append('file', file);
|
||||
|
||||
return apiClient.post(`${this.baseEndpoint}/tenants/${tenantId}/inventory/import`, formData, {
|
||||
headers: {
|
||||
'Content-Type': 'multipart/form-data',
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// ========== SEARCH & SUGGESTIONS ==========
|
||||
|
||||
/**
|
||||
* Search inventory items with autocomplete
|
||||
*/
|
||||
async searchItems(tenantId: string, query: string, limit = 10): Promise<InventoryItem[]> {
|
||||
return apiClient.get(
|
||||
`${this.baseEndpoint}/tenants/${tenantId}/inventory/search?q=${encodeURIComponent(query)}&limit=${limit}`
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get category suggestions based on product type
|
||||
*/
|
||||
async getCategorySuggestions(productType: ProductType): Promise<string[]> {
|
||||
return apiClient.get(`${this.baseEndpoint}/inventory/categories?type=${productType}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get supplier suggestions
|
||||
*/
|
||||
async getSupplierSuggestions(tenantId: string): Promise<string[]> {
|
||||
return apiClient.get(`${this.baseEndpoint}/tenants/${tenantId}/inventory/suppliers`);
|
||||
}
|
||||
|
||||
// ========== PRODUCTS FOR FORECASTING ==========
|
||||
|
||||
/**
|
||||
* Get Products List with IDs for Forecasting
|
||||
*/
|
||||
async getProductsList(tenantId: string): Promise<ProductInfo[]> {
|
||||
try {
|
||||
console.log('🔍 Fetching products for forecasting...', { tenantId });
|
||||
|
||||
// First try to get finished products (preferred for forecasting)
|
||||
const response = await apiClient.get(`/tenants/${tenantId}/ingredients`, {
|
||||
params: {
|
||||
limit: 100,
|
||||
product_type: 'finished_product'
|
||||
},
|
||||
});
|
||||
|
||||
console.log('🔍 Inventory Products API Response:', response);
|
||||
console.log('🔍 Raw response data:', response.data);
|
||||
console.log('🔍 Response status:', response.status);
|
||||
console.log('🔍 Response headers:', response.headers);
|
||||
console.log('🔍 Full response object keys:', Object.keys(response || {}));
|
||||
console.log('🔍 Response data type:', typeof response);
|
||||
console.log('🔍 Response data constructor:', response?.constructor?.name);
|
||||
|
||||
// Check if response.data exists and what type it is
|
||||
if (response && 'data' in response) {
|
||||
console.log('🔍 Response.data exists:', typeof response.data);
|
||||
console.log('🔍 Response.data keys:', Object.keys(response.data || {}));
|
||||
console.log('🔍 Response.data constructor:', response.data?.constructor?.name);
|
||||
}
|
||||
|
||||
let productsArray: any[] = [];
|
||||
|
||||
// Check response.data first (typical API client behavior)
|
||||
const dataToProcess = response?.data || response;
|
||||
|
||||
if (Array.isArray(dataToProcess)) {
|
||||
productsArray = dataToProcess;
|
||||
console.log('✅ Found array data with', productsArray.length, 'items');
|
||||
} else if (dataToProcess && typeof dataToProcess === 'object') {
|
||||
// Handle different response formats
|
||||
const keys = Object.keys(dataToProcess);
|
||||
if (keys.length > 0 && keys.every(key => !isNaN(Number(key)))) {
|
||||
productsArray = Object.values(dataToProcess);
|
||||
console.log('✅ Found object with numeric keys, converted to array with', productsArray.length, 'items');
|
||||
} else {
|
||||
console.warn('⚠️ Response is object but not with numeric keys:', dataToProcess);
|
||||
console.warn('⚠️ Object keys:', keys);
|
||||
return [];
|
||||
}
|
||||
} else {
|
||||
console.warn('⚠️ Response data is not array or object:', dataToProcess);
|
||||
return [];
|
||||
}
|
||||
|
||||
// Convert to ProductInfo objects
|
||||
const products: ProductInfo[] = productsArray
|
||||
.map((product: any) => ({
|
||||
inventory_product_id: product.id || product.inventory_product_id,
|
||||
name: product.name || product.product_name || `Product ${product.id || ''}`,
|
||||
category: product.category,
|
||||
// Add additional fields if available from inventory
|
||||
current_stock: product.current_stock,
|
||||
unit: product.unit,
|
||||
cost_per_unit: product.cost_per_unit
|
||||
}))
|
||||
.filter(product => product.inventory_product_id && product.name);
|
||||
|
||||
console.log('📋 Processed finished products:', products);
|
||||
|
||||
// If no finished products found, try to get all products as fallback
|
||||
if (products.length === 0) {
|
||||
console.log('⚠️ No finished products found, trying to get all products as fallback...');
|
||||
|
||||
const fallbackResponse = await apiClient.get(`/tenants/${tenantId}/ingredients`, {
|
||||
params: {
|
||||
limit: 100,
|
||||
// No product_type filter to get all products
|
||||
},
|
||||
});
|
||||
|
||||
console.log('🔍 Fallback API Response:', fallbackResponse);
|
||||
|
||||
const fallbackDataToProcess = fallbackResponse?.data || fallbackResponse;
|
||||
let fallbackProductsArray: any[] = [];
|
||||
|
||||
if (Array.isArray(fallbackDataToProcess)) {
|
||||
fallbackProductsArray = fallbackDataToProcess;
|
||||
} else if (fallbackDataToProcess && typeof fallbackDataToProcess === 'object') {
|
||||
const keys = Object.keys(fallbackDataToProcess);
|
||||
if (keys.length > 0 && keys.every(key => !isNaN(Number(key)))) {
|
||||
fallbackProductsArray = Object.values(fallbackDataToProcess);
|
||||
}
|
||||
}
|
||||
|
||||
const fallbackProducts: ProductInfo[] = fallbackProductsArray
|
||||
.map((product: any) => ({
|
||||
inventory_product_id: product.id || product.inventory_product_id,
|
||||
name: product.name || product.product_name || `Product ${product.id || ''}`,
|
||||
category: product.category,
|
||||
current_stock: product.current_stock,
|
||||
unit: product.unit,
|
||||
cost_per_unit: product.cost_per_unit
|
||||
}))
|
||||
.filter(product => product.inventory_product_id && product.name);
|
||||
|
||||
console.log('📋 Processed fallback products (all inventory items):', fallbackProducts);
|
||||
return fallbackProducts;
|
||||
}
|
||||
|
||||
return products;
|
||||
|
||||
} catch (error) {
|
||||
console.error('❌ Failed to fetch inventory products:', error);
|
||||
console.error('❌ Error details:', {
|
||||
message: error instanceof Error ? error.message : 'Unknown error',
|
||||
response: (error as any)?.response,
|
||||
status: (error as any)?.response?.status,
|
||||
data: (error as any)?.response?.data
|
||||
});
|
||||
|
||||
// If it's an authentication error, throw it to trigger auth flow
|
||||
if ((error as any)?.response?.status === 401) {
|
||||
throw error;
|
||||
}
|
||||
|
||||
// Return empty array on other errors - let dashboard handle fallback
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get Product by ID
|
||||
*/
|
||||
async getProductById(tenantId: string, productId: string): Promise<ProductInfo | null> {
|
||||
try {
|
||||
const response = await apiClient.get(`/tenants/${tenantId}/ingredients/${productId}`);
|
||||
|
||||
if (response) {
|
||||
return {
|
||||
inventory_product_id: response.id || response.inventory_product_id,
|
||||
name: response.name || response.product_name,
|
||||
category: response.category,
|
||||
current_stock: response.current_stock,
|
||||
unit: response.unit,
|
||||
cost_per_unit: response.cost_per_unit
|
||||
};
|
||||
}
|
||||
|
||||
return null;
|
||||
} catch (error) {
|
||||
console.error('❌ Failed to fetch product by ID:', error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// ========== ENHANCED DASHBOARD FEATURES ==========
|
||||
|
||||
/**
|
||||
* Get inventory dashboard data with analytics
|
||||
*/
|
||||
async getDashboardData(tenantId: string, params?: {
|
||||
date_from?: string;
|
||||
date_to?: string;
|
||||
location?: string;
|
||||
}): Promise<{
|
||||
summary: {
|
||||
total_items: number;
|
||||
low_stock_count: number;
|
||||
out_of_stock_items: number;
|
||||
expiring_soon: number;
|
||||
total_value: number;
|
||||
};
|
||||
recent_movements: any[];
|
||||
active_alerts: any[];
|
||||
stock_trends: {
|
||||
dates: string[];
|
||||
stock_levels: number[];
|
||||
movements_in: number[];
|
||||
movements_out: number[];
|
||||
};
|
||||
}> {
|
||||
try {
|
||||
return await apiClient.get(`/tenants/${tenantId}/inventory/dashboard`, { params });
|
||||
} catch (error) {
|
||||
console.error('❌ Error fetching inventory dashboard:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get food safety compliance data
|
||||
*/
|
||||
async getFoodSafetyCompliance(tenantId: string): Promise<{
|
||||
compliant_items: number;
|
||||
non_compliant_items: number;
|
||||
expiring_items: any[];
|
||||
temperature_violations: any[];
|
||||
compliance_score: number;
|
||||
}> {
|
||||
try {
|
||||
return await apiClient.get(`/tenants/${tenantId}/inventory/food-safety/compliance`);
|
||||
} catch (error) {
|
||||
console.error('❌ Error fetching food safety compliance:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get temperature monitoring data
|
||||
*/
|
||||
async getTemperatureMonitoring(tenantId: string, params?: {
|
||||
item_id?: string;
|
||||
location?: string;
|
||||
date_from?: string;
|
||||
date_to?: string;
|
||||
}): Promise<{
|
||||
readings: any[];
|
||||
violations: any[];
|
||||
}> {
|
||||
try {
|
||||
return await apiClient.get(`/tenants/${tenantId}/inventory/food-safety/temperature-monitoring`, { params });
|
||||
} catch (error) {
|
||||
console.error('❌ Error fetching temperature monitoring:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Record temperature reading
|
||||
*/
|
||||
async recordTemperatureReading(tenantId: string, params: {
|
||||
item_id: string;
|
||||
temperature: number;
|
||||
humidity?: number;
|
||||
location: string;
|
||||
notes?: string;
|
||||
}): Promise<void> {
|
||||
try {
|
||||
return await apiClient.post(`/tenants/${tenantId}/inventory/food-safety/temperature-reading`, params);
|
||||
} catch (error) {
|
||||
console.error('❌ Error recording temperature reading:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Get restock recommendations
|
||||
*/
|
||||
async getRestockRecommendations(tenantId: string): Promise<{
|
||||
urgent_restocks: any[];
|
||||
optimal_orders: any[];
|
||||
}> {
|
||||
try {
|
||||
return await apiClient.get(`/tenants/${tenantId}/inventory/forecasting/restock-recommendations`);
|
||||
} catch (error) {
|
||||
console.error('❌ Error fetching restock recommendations:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const inventoryService = new InventoryService();
|
||||
@@ -1,185 +0,0 @@
|
||||
// frontend/src/api/services/notification.service.ts
|
||||
/**
|
||||
* Notification Service
|
||||
* Handles notification operations
|
||||
*/
|
||||
|
||||
import { apiClient } from '../client';
|
||||
import type {
|
||||
NotificationCreate,
|
||||
NotificationResponse,
|
||||
NotificationTemplate,
|
||||
NotificationHistory,
|
||||
NotificationStats,
|
||||
BulkNotificationRequest,
|
||||
BulkNotificationStatus,
|
||||
PaginatedResponse,
|
||||
BaseQueryParams,
|
||||
} from '../types';
|
||||
|
||||
export class NotificationService {
|
||||
/**
|
||||
* Send Notification
|
||||
*/
|
||||
async sendNotification(
|
||||
tenantId: string,
|
||||
notification: NotificationCreate
|
||||
): Promise<NotificationResponse> {
|
||||
return apiClient.post(`/tenants/${tenantId}/notifications`, notification);
|
||||
}
|
||||
|
||||
/**
|
||||
* Send Bulk Notifications
|
||||
*/
|
||||
async sendBulkNotifications(
|
||||
tenantId: string,
|
||||
request: BulkNotificationRequest
|
||||
): Promise<BulkNotificationStatus> {
|
||||
return apiClient.post(`/tenants/${tenantId}/notifications/bulk`, request);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get Notifications
|
||||
*/
|
||||
async getNotifications(
|
||||
tenantId: string,
|
||||
params?: BaseQueryParams & {
|
||||
channel?: string;
|
||||
status?: string;
|
||||
recipient_email?: string;
|
||||
start_date?: string;
|
||||
end_date?: string;
|
||||
}
|
||||
): Promise<PaginatedResponse<NotificationResponse>> {
|
||||
return apiClient.get(`/tenants/${tenantId}/notifications`, { params });
|
||||
}
|
||||
|
||||
/**
|
||||
* Get Notification by ID
|
||||
*/
|
||||
async getNotification(tenantId: string, notificationId: string): Promise<NotificationResponse> {
|
||||
return apiClient.get(`/tenants/${tenantId}/notifications/${notificationId}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get Notification History
|
||||
*/
|
||||
async getNotificationHistory(
|
||||
tenantId: string,
|
||||
notificationId: string
|
||||
): Promise<NotificationHistory[]> {
|
||||
return apiClient.get(`/tenants/${tenantId}/notifications/${notificationId}/history`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Cancel Scheduled Notification
|
||||
*/
|
||||
async cancelNotification(
|
||||
tenantId: string,
|
||||
notificationId: string
|
||||
): Promise<{ message: string }> {
|
||||
return apiClient.post(`/tenants/${tenantId}/notifications/${notificationId}/cancel`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get Bulk Notification Status
|
||||
*/
|
||||
async getBulkNotificationStatus(
|
||||
tenantId: string,
|
||||
batchId: string
|
||||
): Promise<BulkNotificationStatus> {
|
||||
return apiClient.get(`/tenants/${tenantId}/notifications/bulk/${batchId}/status`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get Notification Templates
|
||||
*/
|
||||
async getTemplates(
|
||||
tenantId: string,
|
||||
params?: BaseQueryParams & {
|
||||
channel?: string;
|
||||
is_active?: boolean;
|
||||
}
|
||||
): Promise<PaginatedResponse<NotificationTemplate>> {
|
||||
return apiClient.get(`/tenants/${tenantId}/notifications/templates`, { params });
|
||||
}
|
||||
|
||||
/**
|
||||
* Create Notification Template
|
||||
*/
|
||||
async createTemplate(
|
||||
tenantId: string,
|
||||
template: Omit<NotificationTemplate, 'id' | 'tenant_id' | 'created_at' | 'updated_at'>
|
||||
): Promise<NotificationTemplate> {
|
||||
return apiClient.post(`/tenants/${tenantId}/notifications/templates`, template);
|
||||
}
|
||||
|
||||
/**
|
||||
* Update Notification Template
|
||||
*/
|
||||
async updateTemplate(
|
||||
tenantId: string,
|
||||
templateId: string,
|
||||
template: Partial<NotificationTemplate>
|
||||
): Promise<NotificationTemplate> {
|
||||
return apiClient.put(`/tenants/${tenantId}/notifications/templates/${templateId}`, template);
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete Notification Template
|
||||
*/
|
||||
async deleteTemplate(tenantId: string, templateId: string): Promise<{ message: string }> {
|
||||
return apiClient.delete(`/tenants/${tenantId}/notifications/templates/${templateId}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get Notification Statistics
|
||||
*/
|
||||
async getNotificationStats(
|
||||
tenantId: string,
|
||||
params?: {
|
||||
start_date?: string;
|
||||
end_date?: string;
|
||||
channel?: string;
|
||||
}
|
||||
): Promise<NotificationStats> {
|
||||
return apiClient.get(`/tenants/${tenantId}/notifications/stats`, { params });
|
||||
}
|
||||
|
||||
/**
|
||||
* Test Notification Configuration
|
||||
*/
|
||||
async testNotificationConfig(
|
||||
tenantId: string,
|
||||
config: {
|
||||
channel: string;
|
||||
recipient: string;
|
||||
test_message: string;
|
||||
}
|
||||
): Promise<{ success: boolean; message: string }> {
|
||||
return apiClient.post(`/tenants/${tenantId}/notifications/test`, config);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get User Notification Preferences
|
||||
*/
|
||||
async getUserPreferences(tenantId: string, userId: string): Promise<Record<string, boolean>> {
|
||||
return apiClient.get(`/tenants/${tenantId}/notifications/preferences/${userId}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Update User Notification Preferences
|
||||
*/
|
||||
async updateUserPreferences(
|
||||
tenantId: string,
|
||||
userId: string,
|
||||
preferences: Record<string, boolean>
|
||||
): Promise<{ message: string }> {
|
||||
return apiClient.put(
|
||||
`/tenants/${tenantId}/notifications/preferences/${userId}`,
|
||||
preferences
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export const notificationService = new NotificationService();
|
||||
@@ -1,288 +0,0 @@
|
||||
// frontend/src/api/services/onboarding.service.ts
|
||||
/**
|
||||
* Onboarding Service
|
||||
* Handles user progress tracking and onboarding flow management
|
||||
*/
|
||||
|
||||
import { apiClient } from '../client';
|
||||
|
||||
export interface OnboardingStepStatus {
|
||||
step_name: string;
|
||||
completed: boolean;
|
||||
completed_at?: string;
|
||||
data?: Record<string, any>;
|
||||
}
|
||||
|
||||
export interface UserProgress {
|
||||
user_id: string;
|
||||
steps: OnboardingStepStatus[];
|
||||
current_step: string;
|
||||
next_step?: string;
|
||||
completion_percentage: number;
|
||||
fully_completed: boolean;
|
||||
last_updated: string;
|
||||
}
|
||||
|
||||
export interface UpdateStepRequest {
|
||||
step_name: string;
|
||||
completed: boolean;
|
||||
data?: Record<string, any>;
|
||||
}
|
||||
|
||||
export interface InventorySuggestion {
|
||||
suggestion_id: string;
|
||||
original_name: string;
|
||||
suggested_name: string;
|
||||
product_type: 'ingredient' | 'finished_product';
|
||||
category: string;
|
||||
unit_of_measure: string;
|
||||
confidence_score: number;
|
||||
estimated_shelf_life_days?: number;
|
||||
requires_refrigeration: boolean;
|
||||
requires_freezing: boolean;
|
||||
is_seasonal: boolean;
|
||||
suggested_supplier?: string;
|
||||
notes?: string;
|
||||
user_approved?: boolean;
|
||||
user_modifications?: Record<string, any>;
|
||||
}
|
||||
|
||||
export interface BusinessModelAnalysis {
|
||||
model: 'production' | 'retail' | 'hybrid';
|
||||
confidence: number;
|
||||
ingredient_count: number;
|
||||
finished_product_count: number;
|
||||
ingredient_ratio: number;
|
||||
recommendations: string[];
|
||||
}
|
||||
|
||||
// Step 1: File validation result
|
||||
export interface FileValidationResult {
|
||||
is_valid: boolean;
|
||||
total_records: number;
|
||||
unique_products: number;
|
||||
product_list: string[];
|
||||
validation_errors: any[];
|
||||
validation_warnings: any[];
|
||||
summary: Record<string, any>;
|
||||
}
|
||||
|
||||
// Step 2: AI suggestions result
|
||||
export interface ProductSuggestionsResult {
|
||||
suggestions: InventorySuggestion[];
|
||||
business_model_analysis: BusinessModelAnalysis;
|
||||
total_products: number;
|
||||
high_confidence_count: number;
|
||||
low_confidence_count: number;
|
||||
processing_time_seconds: number;
|
||||
}
|
||||
|
||||
// Legacy support - will be deprecated
|
||||
export interface OnboardingAnalysisResult {
|
||||
total_products_found: number;
|
||||
inventory_suggestions: InventorySuggestion[];
|
||||
business_model_analysis: BusinessModelAnalysis;
|
||||
import_job_id: string;
|
||||
status: string;
|
||||
processed_rows: number;
|
||||
errors: string[];
|
||||
warnings: string[];
|
||||
}
|
||||
|
||||
export interface InventoryCreationResult {
|
||||
created_items: any[];
|
||||
failed_items: any[];
|
||||
total_approved: number;
|
||||
success_rate: number;
|
||||
}
|
||||
|
||||
export interface SalesImportResult {
|
||||
import_job_id: string;
|
||||
status: string;
|
||||
processed_rows: number;
|
||||
successful_imports: number;
|
||||
failed_imports: number;
|
||||
errors: string[];
|
||||
warnings: string[];
|
||||
}
|
||||
|
||||
export class OnboardingService {
|
||||
private baseEndpoint = '/users/me/onboarding';
|
||||
|
||||
/**
|
||||
* Get user's current onboarding progress
|
||||
*/
|
||||
async getUserProgress(): Promise<UserProgress> {
|
||||
return apiClient.get(`${this.baseEndpoint}/progress`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Update a specific onboarding step
|
||||
*/
|
||||
async updateStep(data: UpdateStepRequest): Promise<UserProgress> {
|
||||
return apiClient.put(`${this.baseEndpoint}/step`, data);
|
||||
}
|
||||
|
||||
/**
|
||||
* Mark step as completed with optional data
|
||||
*/
|
||||
async completeStep(stepName: string, data?: Record<string, any>): Promise<UserProgress> {
|
||||
return this.updateStep({
|
||||
step_name: stepName,
|
||||
completed: true,
|
||||
data
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Reset a step (mark as incomplete)
|
||||
*/
|
||||
async resetStep(stepName: string): Promise<UserProgress> {
|
||||
return this.updateStep({
|
||||
step_name: stepName,
|
||||
completed: false
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Get next required step for user
|
||||
*/
|
||||
async getNextStep(): Promise<{ step: string; data?: Record<string, any> }> {
|
||||
return apiClient.get(`${this.baseEndpoint}/next-step`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Complete entire onboarding process
|
||||
*/
|
||||
async completeOnboarding(): Promise<{ success: boolean; message: string }> {
|
||||
return apiClient.post(`${this.baseEndpoint}/complete`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if user can access a specific step
|
||||
*/
|
||||
async canAccessStep(stepName: string): Promise<{ can_access: boolean; reason?: string }> {
|
||||
return apiClient.get(`${this.baseEndpoint}/can-access/${stepName}`);
|
||||
}
|
||||
|
||||
// ========== NEW 4-STEP AUTOMATED INVENTORY CREATION METHODS ==========
|
||||
|
||||
/**
|
||||
* Step 1: Validate file and extract unique products
|
||||
*/
|
||||
async validateFileAndExtractProducts(tenantId: string, file: File): Promise<FileValidationResult> {
|
||||
const formData = new FormData();
|
||||
formData.append('file', file);
|
||||
|
||||
return apiClient.post(`/tenants/${tenantId}/onboarding/validate-file`, formData, {
|
||||
headers: {
|
||||
'Content-Type': 'multipart/form-data',
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Step 2: Generate AI-powered inventory suggestions
|
||||
*/
|
||||
async generateInventorySuggestions(
|
||||
tenantId: string,
|
||||
file: File,
|
||||
productList: string[]
|
||||
): Promise<ProductSuggestionsResult> {
|
||||
const formData = new FormData();
|
||||
formData.append('file', file);
|
||||
formData.append('product_list', JSON.stringify(productList));
|
||||
|
||||
return apiClient.post(`/tenants/${tenantId}/onboarding/generate-suggestions`, formData, {
|
||||
headers: {
|
||||
'Content-Type': 'multipart/form-data',
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Step 3: Create inventory from approved suggestions
|
||||
*/
|
||||
async createInventoryFromSuggestions(
|
||||
tenantId: string,
|
||||
suggestions: InventorySuggestion[]
|
||||
): Promise<InventoryCreationResult> {
|
||||
return apiClient.post(`/tenants/${tenantId}/onboarding/create-inventory`, {
|
||||
suggestions: suggestions.map(s => ({
|
||||
suggestion_id: s.suggestion_id,
|
||||
approved: s.user_approved ?? true,
|
||||
modifications: s.user_modifications || {},
|
||||
// Include full suggestion data for backend processing
|
||||
original_name: s.original_name,
|
||||
suggested_name: s.suggested_name,
|
||||
product_type: s.product_type,
|
||||
category: s.category,
|
||||
unit_of_measure: s.unit_of_measure,
|
||||
confidence_score: s.confidence_score,
|
||||
estimated_shelf_life_days: s.estimated_shelf_life_days,
|
||||
requires_refrigeration: s.requires_refrigeration,
|
||||
requires_freezing: s.requires_freezing,
|
||||
is_seasonal: s.is_seasonal,
|
||||
suggested_supplier: s.suggested_supplier,
|
||||
notes: s.notes
|
||||
}))
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Step 4: Final sales data import with inventory mapping
|
||||
*/
|
||||
async importSalesWithInventory(
|
||||
tenantId: string,
|
||||
file: File,
|
||||
inventoryMapping: Record<string, string>
|
||||
): Promise<SalesImportResult> {
|
||||
const formData = new FormData();
|
||||
formData.append('file', file);
|
||||
formData.append('inventory_mapping', JSON.stringify(inventoryMapping));
|
||||
|
||||
return apiClient.post(`/tenants/${tenantId}/onboarding/import-sales`, formData, {
|
||||
headers: {
|
||||
'Content-Type': 'multipart/form-data',
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// ========== LEGACY METHODS (for backward compatibility) ==========
|
||||
|
||||
/**
|
||||
* @deprecated Use the new 4-step flow instead
|
||||
* Phase 1: Analyze sales data and get AI suggestions (OLD METHOD)
|
||||
*/
|
||||
async analyzeSalesDataForOnboarding(tenantId: string, file: File): Promise<OnboardingAnalysisResult> {
|
||||
// This method will use the new flow under the hood for backward compatibility
|
||||
const validationResult = await this.validateFileAndExtractProducts(tenantId, file);
|
||||
|
||||
if (!validationResult.is_valid) {
|
||||
throw new Error(`File validation failed: ${validationResult.validation_errors.map(e => e.message || e).join(', ')}`);
|
||||
}
|
||||
|
||||
const suggestionsResult = await this.generateInventorySuggestions(tenantId, file, validationResult.product_list);
|
||||
|
||||
// Convert to legacy format
|
||||
return {
|
||||
total_products_found: suggestionsResult.total_products,
|
||||
inventory_suggestions: suggestionsResult.suggestions,
|
||||
business_model_analysis: suggestionsResult.business_model_analysis,
|
||||
import_job_id: `legacy-${Date.now()}`,
|
||||
status: 'completed',
|
||||
processed_rows: validationResult.total_records,
|
||||
errors: validationResult.validation_errors.map(e => e.message || String(e)),
|
||||
warnings: validationResult.validation_warnings.map(w => w.message || String(w))
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Get business model guidance based on analysis
|
||||
*/
|
||||
async getBusinessModelGuide(tenantId: string, model: string): Promise<any> {
|
||||
return apiClient.get(`/tenants/${tenantId}/onboarding/business-model-guide?model=${model}`);
|
||||
}
|
||||
}
|
||||
|
||||
export const onboardingService = new OnboardingService();
|
||||
@@ -1,349 +0,0 @@
|
||||
// ================================================================
|
||||
// frontend/src/api/services/orders.service.ts
|
||||
// ================================================================
|
||||
/**
|
||||
* Orders Service - API client for Orders Service endpoints
|
||||
*/
|
||||
|
||||
import { apiClient } from '../client';
|
||||
|
||||
// Order Types
|
||||
export interface Order {
|
||||
id: string;
|
||||
tenant_id: string;
|
||||
customer_id?: string;
|
||||
customer_name?: string;
|
||||
customer_email?: string;
|
||||
customer_phone?: string;
|
||||
order_number: string;
|
||||
status: 'pending' | 'confirmed' | 'in_production' | 'ready' | 'delivered' | 'cancelled';
|
||||
order_type: 'walk_in' | 'online' | 'phone' | 'catering';
|
||||
business_model: 'individual_bakery' | 'central_bakery';
|
||||
items: OrderItem[];
|
||||
subtotal: number;
|
||||
tax_amount: number;
|
||||
discount_amount: number;
|
||||
total_amount: number;
|
||||
delivery_date?: string;
|
||||
delivery_address?: string;
|
||||
notes?: string;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
export interface OrderItem {
|
||||
id: string;
|
||||
recipe_id: string;
|
||||
recipe_name: string;
|
||||
quantity: number;
|
||||
unit_price: number;
|
||||
total_price: number;
|
||||
customizations?: Record<string, any>;
|
||||
production_notes?: string;
|
||||
}
|
||||
|
||||
export interface Customer {
|
||||
id: string;
|
||||
name: string;
|
||||
email?: string;
|
||||
phone?: string;
|
||||
address?: string;
|
||||
customer_type: 'individual' | 'business' | 'catering';
|
||||
preferences?: string[];
|
||||
loyalty_points?: number;
|
||||
total_orders: number;
|
||||
total_spent: number;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
export interface OrderDashboardData {
|
||||
summary: {
|
||||
total_orders_today: number;
|
||||
pending_orders: number;
|
||||
orders_in_production: number;
|
||||
completed_orders: number;
|
||||
revenue_today: number;
|
||||
average_order_value: number;
|
||||
};
|
||||
recent_orders: Order[];
|
||||
peak_hours: { hour: number; orders: number }[];
|
||||
popular_items: { recipe_name: string; quantity: number }[];
|
||||
business_model_distribution: { model: string; count: number; revenue: number }[];
|
||||
}
|
||||
|
||||
export interface ProcurementPlan {
|
||||
id: string;
|
||||
date: string;
|
||||
status: 'draft' | 'approved' | 'ordered' | 'completed';
|
||||
total_cost: number;
|
||||
items: ProcurementItem[];
|
||||
supplier_orders: SupplierOrder[];
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
export interface ProcurementItem {
|
||||
ingredient_id: string;
|
||||
ingredient_name: string;
|
||||
required_quantity: number;
|
||||
current_stock: number;
|
||||
quantity_to_order: number;
|
||||
unit: string;
|
||||
estimated_cost: number;
|
||||
priority: 'low' | 'medium' | 'high' | 'critical';
|
||||
supplier_id?: string;
|
||||
supplier_name?: string;
|
||||
}
|
||||
|
||||
export interface SupplierOrder {
|
||||
supplier_id: string;
|
||||
supplier_name: string;
|
||||
items: ProcurementItem[];
|
||||
total_cost: number;
|
||||
delivery_date?: string;
|
||||
notes?: string;
|
||||
}
|
||||
|
||||
export interface OrderCreateRequest {
|
||||
customer_id?: string;
|
||||
customer_name?: string;
|
||||
customer_email?: string;
|
||||
customer_phone?: string;
|
||||
order_type: 'walk_in' | 'online' | 'phone' | 'catering';
|
||||
business_model: 'individual_bakery' | 'central_bakery';
|
||||
items: {
|
||||
recipe_id: string;
|
||||
quantity: number;
|
||||
customizations?: Record<string, any>;
|
||||
}[];
|
||||
delivery_date?: string;
|
||||
delivery_address?: string;
|
||||
notes?: string;
|
||||
}
|
||||
|
||||
export interface OrderUpdateRequest {
|
||||
status?: 'pending' | 'confirmed' | 'in_production' | 'ready' | 'delivered' | 'cancelled';
|
||||
items?: {
|
||||
recipe_id: string;
|
||||
quantity: number;
|
||||
customizations?: Record<string, any>;
|
||||
}[];
|
||||
delivery_date?: string;
|
||||
delivery_address?: string;
|
||||
notes?: string;
|
||||
}
|
||||
|
||||
export class OrdersService {
|
||||
private readonly basePath = '/orders';
|
||||
|
||||
// Dashboard
|
||||
async getDashboardData(params?: {
|
||||
date_from?: string;
|
||||
date_to?: string;
|
||||
}): Promise<OrderDashboardData> {
|
||||
return apiClient.get(`${this.basePath}/dashboard`, { params });
|
||||
}
|
||||
|
||||
async getDashboardMetrics(params?: {
|
||||
date_from?: string;
|
||||
date_to?: string;
|
||||
granularity?: 'hour' | 'day' | 'week' | 'month';
|
||||
}): Promise<{
|
||||
dates: string[];
|
||||
order_counts: number[];
|
||||
revenue: number[];
|
||||
average_order_values: number[];
|
||||
business_model_breakdown: { model: string; orders: number[]; revenue: number[] }[];
|
||||
}> {
|
||||
return apiClient.get(`${this.basePath}/dashboard/metrics`, { params });
|
||||
}
|
||||
|
||||
// Orders
|
||||
async getOrders(params?: {
|
||||
status?: string;
|
||||
order_type?: string;
|
||||
business_model?: string;
|
||||
customer_id?: string;
|
||||
date_from?: string;
|
||||
date_to?: string;
|
||||
limit?: number;
|
||||
offset?: number;
|
||||
}): Promise<Order[]> {
|
||||
return apiClient.get(`${this.basePath}`, { params });
|
||||
}
|
||||
|
||||
async getOrder(orderId: string): Promise<Order> {
|
||||
return apiClient.get(`${this.basePath}/${orderId}`);
|
||||
}
|
||||
|
||||
async createOrder(order: OrderCreateRequest): Promise<Order> {
|
||||
return apiClient.post(`${this.basePath}`, order);
|
||||
}
|
||||
|
||||
async updateOrder(orderId: string, updates: OrderUpdateRequest): Promise<Order> {
|
||||
return apiClient.put(`${this.basePath}/${orderId}`, updates);
|
||||
}
|
||||
|
||||
async deleteOrder(orderId: string): Promise<void> {
|
||||
return apiClient.delete(`${this.basePath}/${orderId}`);
|
||||
}
|
||||
|
||||
async updateOrderStatus(orderId: string, status: Order['status']): Promise<Order> {
|
||||
return apiClient.patch(`${this.basePath}/${orderId}/status`, { status });
|
||||
}
|
||||
|
||||
async getOrderHistory(orderId: string): Promise<{
|
||||
order: Order;
|
||||
status_changes: {
|
||||
status: string;
|
||||
timestamp: string;
|
||||
user: string;
|
||||
notes?: string
|
||||
}[];
|
||||
}> {
|
||||
return apiClient.get(`${this.basePath}/${orderId}/history`);
|
||||
}
|
||||
|
||||
// Customers
|
||||
async getCustomers(params?: {
|
||||
search?: string;
|
||||
customer_type?: string;
|
||||
limit?: number;
|
||||
offset?: number;
|
||||
}): Promise<Customer[]> {
|
||||
return apiClient.get(`${this.basePath}/customers`, { params });
|
||||
}
|
||||
|
||||
async getCustomer(customerId: string): Promise<Customer> {
|
||||
return apiClient.get(`${this.basePath}/customers/${customerId}`);
|
||||
}
|
||||
|
||||
async createCustomer(customer: {
|
||||
name: string;
|
||||
email?: string;
|
||||
phone?: string;
|
||||
address?: string;
|
||||
customer_type: 'individual' | 'business' | 'catering';
|
||||
preferences?: string[];
|
||||
}): Promise<Customer> {
|
||||
return apiClient.post(`${this.basePath}/customers`, customer);
|
||||
}
|
||||
|
||||
async updateCustomer(customerId: string, updates: {
|
||||
name?: string;
|
||||
email?: string;
|
||||
phone?: string;
|
||||
address?: string;
|
||||
customer_type?: 'individual' | 'business' | 'catering';
|
||||
preferences?: string[];
|
||||
}): Promise<Customer> {
|
||||
return apiClient.put(`${this.basePath}/customers/${customerId}`, updates);
|
||||
}
|
||||
|
||||
async getCustomerOrders(customerId: string, params?: {
|
||||
limit?: number;
|
||||
offset?: number;
|
||||
}): Promise<Order[]> {
|
||||
return apiClient.get(`${this.basePath}/customers/${customerId}/orders`, { params });
|
||||
}
|
||||
|
||||
// Procurement Planning
|
||||
async getProcurementPlans(params?: {
|
||||
status?: string;
|
||||
date_from?: string;
|
||||
date_to?: string;
|
||||
limit?: number;
|
||||
offset?: number;
|
||||
}): Promise<ProcurementPlan[]> {
|
||||
return apiClient.get(`${this.basePath}/procurement/plans`, { params });
|
||||
}
|
||||
|
||||
async getProcurementPlan(planId: string): Promise<ProcurementPlan> {
|
||||
return apiClient.get(`${this.basePath}/procurement/plans/${planId}`);
|
||||
}
|
||||
|
||||
async createProcurementPlan(params: {
|
||||
date: string;
|
||||
orders?: string[];
|
||||
forecast_days?: number;
|
||||
}): Promise<ProcurementPlan> {
|
||||
return apiClient.post(`${this.basePath}/procurement/plans`, params);
|
||||
}
|
||||
|
||||
async updateProcurementPlan(planId: string, updates: {
|
||||
items?: ProcurementItem[];
|
||||
notes?: string;
|
||||
}): Promise<ProcurementPlan> {
|
||||
return apiClient.put(`${this.basePath}/procurement/plans/${planId}`, updates);
|
||||
}
|
||||
|
||||
async approveProcurementPlan(planId: string): Promise<ProcurementPlan> {
|
||||
return apiClient.post(`${this.basePath}/procurement/plans/${planId}/approve`);
|
||||
}
|
||||
|
||||
async generateSupplierOrders(planId: string): Promise<SupplierOrder[]> {
|
||||
return apiClient.post(`${this.basePath}/procurement/plans/${planId}/generate-orders`);
|
||||
}
|
||||
|
||||
// Business Model Detection
|
||||
async detectBusinessModel(): Promise<{
|
||||
detected_model: 'individual_bakery' | 'central_bakery';
|
||||
confidence: number;
|
||||
factors: {
|
||||
daily_order_volume: number;
|
||||
delivery_ratio: number;
|
||||
catering_ratio: number;
|
||||
average_order_size: number;
|
||||
};
|
||||
recommendations: string[];
|
||||
}> {
|
||||
return apiClient.post(`${this.basePath}/business-model/detect`);
|
||||
}
|
||||
|
||||
async updateBusinessModel(model: 'individual_bakery' | 'central_bakery'): Promise<void> {
|
||||
return apiClient.put(`${this.basePath}/business-model`, { business_model: model });
|
||||
}
|
||||
|
||||
// Analytics
|
||||
async getOrderTrends(params?: {
|
||||
date_from?: string;
|
||||
date_to?: string;
|
||||
granularity?: 'hour' | 'day' | 'week' | 'month';
|
||||
}): Promise<{
|
||||
dates: string[];
|
||||
order_counts: number[];
|
||||
revenue: number[];
|
||||
popular_items: { recipe_name: string; count: number }[];
|
||||
}> {
|
||||
return apiClient.get(`${this.basePath}/analytics/trends`, { params });
|
||||
}
|
||||
|
||||
async getCustomerAnalytics(params?: {
|
||||
date_from?: string;
|
||||
date_to?: string;
|
||||
}): Promise<{
|
||||
new_customers: number;
|
||||
returning_customers: number;
|
||||
customer_retention_rate: number;
|
||||
average_lifetime_value: number;
|
||||
top_customers: Customer[];
|
||||
}> {
|
||||
return apiClient.get(`${this.basePath}/analytics/customers`, { params });
|
||||
}
|
||||
|
||||
async getSeasonalAnalysis(params?: {
|
||||
date_from?: string;
|
||||
date_to?: string;
|
||||
}): Promise<{
|
||||
seasonal_patterns: { month: string; order_count: number; revenue: number }[];
|
||||
weekly_patterns: { day: string; order_count: number }[];
|
||||
hourly_patterns: { hour: number; order_count: number }[];
|
||||
trending_products: { recipe_name: string; growth_rate: number }[];
|
||||
}> {
|
||||
return apiClient.get(`${this.basePath}/analytics/seasonal`, { params });
|
||||
}
|
||||
|
||||
|
||||
|
||||
}
|
||||
@@ -1,392 +0,0 @@
|
||||
// frontend/src/api/services/pos.service.ts
|
||||
/**
|
||||
* POS Integration API Service
|
||||
* Handles all communication with the POS service backend
|
||||
*/
|
||||
|
||||
import { apiClient } from '../client';
|
||||
|
||||
// ============================================================================
|
||||
// TYPES & INTERFACES
|
||||
// ============================================================================
|
||||
|
||||
export interface POSConfiguration {
|
||||
id: string;
|
||||
tenant_id: string;
|
||||
pos_system: 'square' | 'toast' | 'lightspeed';
|
||||
provider_name: string;
|
||||
is_active: boolean;
|
||||
is_connected: boolean;
|
||||
environment: 'sandbox' | 'production';
|
||||
location_id?: string;
|
||||
merchant_id?: string;
|
||||
sync_enabled: boolean;
|
||||
sync_interval_minutes: string;
|
||||
auto_sync_products: boolean;
|
||||
auto_sync_transactions: boolean;
|
||||
webhook_url?: string;
|
||||
last_sync_at?: string;
|
||||
last_successful_sync_at?: string;
|
||||
last_sync_status?: 'success' | 'failed' | 'partial';
|
||||
last_sync_message?: string;
|
||||
provider_settings?: Record<string, any>;
|
||||
last_health_check_at?: string;
|
||||
health_status: 'healthy' | 'unhealthy' | 'warning' | 'unknown';
|
||||
health_message?: string;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
created_by?: string;
|
||||
notes?: string;
|
||||
}
|
||||
|
||||
export interface CreatePOSConfigurationRequest {
|
||||
pos_system: 'square' | 'toast' | 'lightspeed';
|
||||
provider_name: string;
|
||||
environment: 'sandbox' | 'production';
|
||||
location_id?: string;
|
||||
merchant_id?: string;
|
||||
sync_enabled?: boolean;
|
||||
sync_interval_minutes?: string;
|
||||
auto_sync_products?: boolean;
|
||||
auto_sync_transactions?: boolean;
|
||||
notes?: string;
|
||||
// Credentials
|
||||
api_key?: string;
|
||||
api_secret?: string;
|
||||
access_token?: string;
|
||||
application_id?: string;
|
||||
webhook_secret?: string;
|
||||
}
|
||||
|
||||
export interface UpdatePOSConfigurationRequest {
|
||||
provider_name?: string;
|
||||
is_active?: boolean;
|
||||
environment?: 'sandbox' | 'production';
|
||||
location_id?: string;
|
||||
merchant_id?: string;
|
||||
sync_enabled?: boolean;
|
||||
sync_interval_minutes?: string;
|
||||
auto_sync_products?: boolean;
|
||||
auto_sync_transactions?: boolean;
|
||||
notes?: string;
|
||||
// Credentials (only if updating)
|
||||
api_key?: string;
|
||||
api_secret?: string;
|
||||
access_token?: string;
|
||||
application_id?: string;
|
||||
webhook_secret?: string;
|
||||
}
|
||||
|
||||
export interface POSTransaction {
|
||||
id: string;
|
||||
tenant_id: string;
|
||||
pos_config_id: string;
|
||||
pos_system: string;
|
||||
external_transaction_id: string;
|
||||
external_order_id?: string;
|
||||
transaction_type: 'sale' | 'refund' | 'void' | 'exchange';
|
||||
status: 'completed' | 'pending' | 'failed' | 'refunded' | 'voided';
|
||||
subtotal: number;
|
||||
tax_amount: number;
|
||||
tip_amount: number;
|
||||
discount_amount: number;
|
||||
total_amount: number;
|
||||
currency: string;
|
||||
payment_method?: string;
|
||||
payment_status?: string;
|
||||
transaction_date: string;
|
||||
pos_created_at: string;
|
||||
pos_updated_at?: string;
|
||||
location_id?: string;
|
||||
location_name?: string;
|
||||
staff_id?: string;
|
||||
staff_name?: string;
|
||||
customer_id?: string;
|
||||
customer_email?: string;
|
||||
customer_phone?: string;
|
||||
order_type?: string;
|
||||
table_number?: string;
|
||||
receipt_number?: string;
|
||||
is_synced_to_sales: boolean;
|
||||
sales_record_id?: string;
|
||||
sync_attempted_at?: string;
|
||||
sync_completed_at?: string;
|
||||
sync_error?: string;
|
||||
sync_retry_count: number;
|
||||
is_processed: boolean;
|
||||
processing_error?: string;
|
||||
is_duplicate: boolean;
|
||||
duplicate_of?: string;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
items: POSTransactionItem[];
|
||||
}
|
||||
|
||||
export interface POSTransactionItem {
|
||||
id: string;
|
||||
transaction_id: string;
|
||||
external_item_id?: string;
|
||||
sku?: string;
|
||||
product_name: string;
|
||||
product_category?: string;
|
||||
product_subcategory?: string;
|
||||
quantity: number;
|
||||
unit_price: number;
|
||||
total_price: number;
|
||||
discount_amount: number;
|
||||
tax_amount: number;
|
||||
modifiers?: Record<string, any>;
|
||||
inventory_product_id?: string;
|
||||
is_mapped_to_inventory: boolean;
|
||||
is_synced_to_sales: boolean;
|
||||
sync_error?: string;
|
||||
}
|
||||
|
||||
export interface POSSyncLog {
|
||||
id: string;
|
||||
tenant_id: string;
|
||||
pos_config_id: string;
|
||||
sync_type: 'full' | 'incremental' | 'manual' | 'webhook_triggered';
|
||||
sync_direction: 'inbound' | 'outbound' | 'bidirectional';
|
||||
data_type: 'transactions' | 'products' | 'customers' | 'orders';
|
||||
pos_system: string;
|
||||
status: 'started' | 'in_progress' | 'completed' | 'failed' | 'cancelled';
|
||||
started_at: string;
|
||||
completed_at?: string;
|
||||
duration_seconds?: number;
|
||||
sync_from_date?: string;
|
||||
sync_to_date?: string;
|
||||
records_requested: number;
|
||||
records_processed: number;
|
||||
records_created: number;
|
||||
records_updated: number;
|
||||
records_skipped: number;
|
||||
records_failed: number;
|
||||
api_calls_made: number;
|
||||
error_message?: string;
|
||||
error_code?: string;
|
||||
retry_attempt: number;
|
||||
max_retries: number;
|
||||
progress_percentage?: number;
|
||||
revenue_synced?: number;
|
||||
transactions_synced: number;
|
||||
triggered_by?: string;
|
||||
triggered_by_user_id?: string;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
export interface SyncRequest {
|
||||
sync_type?: 'full' | 'incremental';
|
||||
data_types?: ('transactions' | 'products' | 'customers')[];
|
||||
from_date?: string;
|
||||
to_date?: string;
|
||||
}
|
||||
|
||||
export interface SyncStatus {
|
||||
current_sync?: POSSyncLog;
|
||||
last_successful_sync?: POSSyncLog;
|
||||
recent_syncs: POSSyncLog[];
|
||||
sync_health: {
|
||||
status: 'healthy' | 'unhealthy' | 'warning';
|
||||
success_rate: number;
|
||||
average_duration_minutes: number;
|
||||
last_error?: string;
|
||||
};
|
||||
}
|
||||
|
||||
export interface SupportedPOSSystem {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string;
|
||||
features: string[];
|
||||
supported_regions: string[];
|
||||
}
|
||||
|
||||
export interface POSAnalytics {
|
||||
period_days: number;
|
||||
total_syncs: number;
|
||||
successful_syncs: number;
|
||||
failed_syncs: number;
|
||||
success_rate: number;
|
||||
average_duration_minutes: number;
|
||||
total_transactions_synced: number;
|
||||
total_revenue_synced: number;
|
||||
sync_frequency: {
|
||||
daily_average: number;
|
||||
peak_day?: string;
|
||||
peak_count: number;
|
||||
};
|
||||
error_analysis: {
|
||||
common_errors: Array<{ error: string; count: number }>;
|
||||
error_trends: Array<{ date: string; count: number }>;
|
||||
};
|
||||
}
|
||||
|
||||
export interface ConnectionTestResult {
|
||||
status: 'success' | 'error';
|
||||
message: string;
|
||||
tested_at: string;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// API FUNCTIONS
|
||||
// ============================================================================
|
||||
|
||||
export const posService = {
|
||||
// Configuration Management
|
||||
async getConfigurations(tenantId: string, params?: {
|
||||
pos_system?: string;
|
||||
is_active?: boolean;
|
||||
limit?: number;
|
||||
offset?: number;
|
||||
}): Promise<{ configurations: POSConfiguration[]; total: number }> {
|
||||
const response = await apiClient.get(`/pos-service/api/v1/tenants/${tenantId}/pos/configurations`, {
|
||||
params
|
||||
});
|
||||
return response.data;
|
||||
},
|
||||
|
||||
async createConfiguration(tenantId: string, data: CreatePOSConfigurationRequest): Promise<POSConfiguration> {
|
||||
const response = await apiClient.post(`/pos-service/api/v1/tenants/${tenantId}/pos/configurations`, data);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
async getConfiguration(tenantId: string, configId: string): Promise<POSConfiguration> {
|
||||
const response = await apiClient.get(`/pos-service/api/v1/tenants/${tenantId}/pos/configurations/${configId}`);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
async updateConfiguration(tenantId: string, configId: string, data: UpdatePOSConfigurationRequest): Promise<POSConfiguration> {
|
||||
const response = await apiClient.put(`/pos-service/api/v1/tenants/${tenantId}/pos/configurations/${configId}`, data);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
async deleteConfiguration(tenantId: string, configId: string): Promise<void> {
|
||||
await apiClient.delete(`/pos-service/api/v1/tenants/${tenantId}/pos/configurations/${configId}`);
|
||||
},
|
||||
|
||||
async testConnection(tenantId: string, configId: string): Promise<ConnectionTestResult> {
|
||||
const response = await apiClient.post(`/pos-service/api/v1/tenants/${tenantId}/pos/configurations/${configId}/test-connection`);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
// Synchronization
|
||||
async triggerSync(tenantId: string, configId: string, syncRequest: SyncRequest): Promise<{
|
||||
message: string;
|
||||
sync_id: string;
|
||||
status: string;
|
||||
sync_type: string;
|
||||
data_types: string[];
|
||||
estimated_duration: string;
|
||||
}> {
|
||||
const response = await apiClient.post(`/pos-service/api/v1/tenants/${tenantId}/pos/configurations/${configId}/sync`, syncRequest);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
async getSyncStatus(tenantId: string, configId: string, limit?: number): Promise<SyncStatus> {
|
||||
const response = await apiClient.get(`/pos-service/api/v1/tenants/${tenantId}/pos/configurations/${configId}/sync/status`, {
|
||||
params: { limit }
|
||||
});
|
||||
return response.data;
|
||||
},
|
||||
|
||||
async getSyncLogs(tenantId: string, configId: string, params?: {
|
||||
limit?: number;
|
||||
offset?: number;
|
||||
status?: string;
|
||||
sync_type?: string;
|
||||
data_type?: string;
|
||||
}): Promise<{ logs: POSSyncLog[]; total: number; has_more: boolean }> {
|
||||
const response = await apiClient.get(`/pos-service/api/v1/tenants/${tenantId}/pos/configurations/${configId}/sync/logs`, {
|
||||
params
|
||||
});
|
||||
return response.data;
|
||||
},
|
||||
|
||||
// Transaction Management
|
||||
async getTransactions(tenantId: string, params?: {
|
||||
pos_system?: string;
|
||||
start_date?: string;
|
||||
end_date?: string;
|
||||
status?: string;
|
||||
is_synced?: boolean;
|
||||
limit?: number;
|
||||
offset?: number;
|
||||
}): Promise<{
|
||||
transactions: POSTransaction[];
|
||||
total: number;
|
||||
has_more: boolean;
|
||||
summary: {
|
||||
total_amount: number;
|
||||
transaction_count: number;
|
||||
sync_status: {
|
||||
synced: number;
|
||||
pending: number;
|
||||
failed: number;
|
||||
};
|
||||
};
|
||||
}> {
|
||||
const response = await apiClient.get(`/pos-service/api/v1/tenants/${tenantId}/pos/transactions`, {
|
||||
params
|
||||
});
|
||||
return response.data;
|
||||
},
|
||||
|
||||
async syncSingleTransaction(tenantId: string, transactionId: string, force?: boolean): Promise<{
|
||||
message: string;
|
||||
transaction_id: string;
|
||||
sync_status: string;
|
||||
sales_record_id?: string;
|
||||
}> {
|
||||
const response = await apiClient.post(`/pos-service/api/v1/tenants/${tenantId}/pos/transactions/${transactionId}/sync`,
|
||||
{}, { params: { force } }
|
||||
);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
async resyncFailedTransactions(tenantId: string, daysBack: number): Promise<{
|
||||
message: string;
|
||||
job_id: string;
|
||||
scope: string;
|
||||
estimated_transactions: number;
|
||||
}> {
|
||||
const response = await apiClient.post(`/pos-service/api/v1/tenants/${tenantId}/pos/data/resync`,
|
||||
{}, { params: { days_back: daysBack } }
|
||||
);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
// Analytics
|
||||
async getSyncAnalytics(tenantId: string, days: number = 30): Promise<POSAnalytics> {
|
||||
const response = await apiClient.get(`/pos-service/api/v1/tenants/${tenantId}/pos/analytics/sync-performance`, {
|
||||
params: { days }
|
||||
});
|
||||
return response.data;
|
||||
},
|
||||
|
||||
// System Information
|
||||
async getSupportedSystems(): Promise<{ systems: SupportedPOSSystem[] }> {
|
||||
const response = await apiClient.get('/pos-service/api/v1/pos/supported-systems');
|
||||
return response.data;
|
||||
},
|
||||
|
||||
// Webhook Status
|
||||
async getWebhookStatus(posSystem: string): Promise<{
|
||||
pos_system: string;
|
||||
status: string;
|
||||
endpoint: string;
|
||||
supported_events: {
|
||||
events: string[];
|
||||
format: string;
|
||||
authentication: string;
|
||||
};
|
||||
last_received?: string;
|
||||
total_received: number;
|
||||
}> {
|
||||
const response = await apiClient.get(`/pos-service/api/v1/webhooks/${posSystem}/status`);
|
||||
return response.data;
|
||||
}
|
||||
};
|
||||
|
||||
export default posService;
|
||||
@@ -1,135 +0,0 @@
|
||||
// ================================================================
|
||||
// frontend/src/api/services/procurement.service.ts
|
||||
// ================================================================
|
||||
/**
|
||||
* Procurement Service - API client for procurement planning endpoints
|
||||
*/
|
||||
|
||||
import { ApiClient } from '../client';
|
||||
import type {
|
||||
ProcurementPlan,
|
||||
GeneratePlanRequest,
|
||||
GeneratePlanResponse,
|
||||
DashboardData,
|
||||
ProcurementRequirement,
|
||||
PaginatedProcurementPlans
|
||||
} from '../types/procurement';
|
||||
|
||||
export class ProcurementService {
|
||||
constructor(private client: ApiClient) {}
|
||||
|
||||
// ================================================================
|
||||
// PROCUREMENT PLAN OPERATIONS
|
||||
// ================================================================
|
||||
|
||||
/**
|
||||
* Get the procurement plan for the current day
|
||||
*/
|
||||
async getCurrentPlan(): Promise<ProcurementPlan | null> {
|
||||
return this.client.get('/procurement-plans/current');
|
||||
}
|
||||
|
||||
/**
|
||||
* Get procurement plan for a specific date
|
||||
*/
|
||||
async getPlanByDate(date: string): Promise<ProcurementPlan | null> {
|
||||
return this.client.get(`/procurement-plans/${date}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get procurement plan by ID
|
||||
*/
|
||||
async getPlanById(planId: string): Promise<ProcurementPlan | null> {
|
||||
return this.client.get(`/procurement-plans/id/${planId}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* List procurement plans with optional filters
|
||||
*/
|
||||
async listPlans(params?: {
|
||||
status?: string;
|
||||
startDate?: string;
|
||||
endDate?: string;
|
||||
limit?: number;
|
||||
offset?: number;
|
||||
}): Promise<PaginatedProcurementPlans> {
|
||||
return this.client.get('/procurement-plans/', { params });
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a new procurement plan
|
||||
*/
|
||||
async generatePlan(request: GeneratePlanRequest): Promise<GeneratePlanResponse> {
|
||||
return this.client.post('/procurement-plans/generate', request);
|
||||
}
|
||||
|
||||
/**
|
||||
* Update procurement plan status
|
||||
*/
|
||||
async updatePlanStatus(planId: string, status: string): Promise<ProcurementPlan> {
|
||||
return this.client.put(`/procurement-plans/${planId}/status`, null, {
|
||||
params: { status }
|
||||
});
|
||||
}
|
||||
|
||||
// ================================================================
|
||||
// REQUIREMENTS OPERATIONS
|
||||
// ================================================================
|
||||
|
||||
/**
|
||||
* Get all requirements for a specific procurement plan
|
||||
*/
|
||||
async getPlanRequirements(
|
||||
planId: string,
|
||||
params?: {
|
||||
status?: string;
|
||||
priority?: string;
|
||||
}
|
||||
): Promise<ProcurementRequirement[]> {
|
||||
return this.client.get(`/procurement-plans/${planId}/requirements`, { params });
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all critical priority requirements
|
||||
*/
|
||||
async getCriticalRequirements(): Promise<ProcurementRequirement[]> {
|
||||
return this.client.get('/procurement-plans/requirements/critical');
|
||||
}
|
||||
|
||||
// ================================================================
|
||||
// DASHBOARD OPERATIONS
|
||||
// ================================================================
|
||||
|
||||
/**
|
||||
* Get procurement dashboard data
|
||||
*/
|
||||
async getDashboardData(): Promise<DashboardData | null> {
|
||||
return this.client.get('/procurement-plans/dashboard/data');
|
||||
}
|
||||
|
||||
// ================================================================
|
||||
// UTILITY OPERATIONS
|
||||
// ================================================================
|
||||
|
||||
/**
|
||||
* Manually trigger the daily scheduler
|
||||
*/
|
||||
async triggerDailyScheduler(): Promise<{ success: boolean; message: string; tenant_id: string }> {
|
||||
return this.client.post('/procurement-plans/scheduler/trigger');
|
||||
}
|
||||
|
||||
/**
|
||||
* Health check for procurement service
|
||||
*/
|
||||
async healthCheck(): Promise<{
|
||||
status: string;
|
||||
service: string;
|
||||
procurement_enabled: boolean;
|
||||
timestamp: string;
|
||||
}> {
|
||||
return this.client.get('/procurement-plans/health');
|
||||
}
|
||||
}
|
||||
|
||||
// Export singleton instance
|
||||
export const procurementService = new ProcurementService(new ApiClient());
|
||||
@@ -1,296 +0,0 @@
|
||||
// ================================================================
|
||||
// frontend/src/api/services/production.service.ts
|
||||
// ================================================================
|
||||
/**
|
||||
* Production Service - API client for Production Service endpoints
|
||||
*/
|
||||
|
||||
import { apiClient } from '../client';
|
||||
|
||||
// Production Types
|
||||
export interface ProductionBatch {
|
||||
id: string;
|
||||
recipe_id: string;
|
||||
recipe_name: string;
|
||||
quantity: number;
|
||||
unit: string;
|
||||
status: 'scheduled' | 'in_progress' | 'completed' | 'delayed' | 'failed';
|
||||
scheduled_start: string;
|
||||
actual_start?: string;
|
||||
expected_end: string;
|
||||
actual_end?: string;
|
||||
equipment_id: string;
|
||||
equipment_name: string;
|
||||
operator_id: string;
|
||||
operator_name: string;
|
||||
temperature?: number;
|
||||
humidity?: number;
|
||||
quality_score?: number;
|
||||
notes?: string;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
export interface ProductionPlan {
|
||||
id: string;
|
||||
date: string;
|
||||
total_capacity: number;
|
||||
allocated_capacity: number;
|
||||
efficiency_target: number;
|
||||
quality_target: number;
|
||||
batches: ProductionBatch[];
|
||||
status: 'draft' | 'approved' | 'in_progress' | 'completed';
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
export interface Equipment {
|
||||
id: string;
|
||||
name: string;
|
||||
type: string;
|
||||
status: 'active' | 'idle' | 'maintenance' | 'error';
|
||||
location: string;
|
||||
capacity: number;
|
||||
current_batch_id?: string;
|
||||
temperature?: number;
|
||||
utilization: number;
|
||||
last_maintenance: string;
|
||||
next_maintenance: string;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
export interface ProductionDashboardData {
|
||||
summary: {
|
||||
active_batches: number;
|
||||
equipment_in_use: number;
|
||||
current_efficiency: number;
|
||||
todays_production: number;
|
||||
};
|
||||
efficiency_trend: { date: string; efficiency: number }[];
|
||||
quality_trend: { date: string; quality: number }[];
|
||||
equipment_status: Equipment[];
|
||||
active_batches: ProductionBatch[];
|
||||
}
|
||||
|
||||
export interface BatchCreateRequest {
|
||||
recipe_id: string;
|
||||
quantity: number;
|
||||
scheduled_start: string;
|
||||
expected_end: string;
|
||||
equipment_id: string;
|
||||
operator_id: string;
|
||||
notes?: string;
|
||||
priority?: number;
|
||||
}
|
||||
|
||||
export interface BatchUpdateRequest {
|
||||
status?: 'scheduled' | 'in_progress' | 'completed' | 'delayed' | 'failed';
|
||||
actual_start?: string;
|
||||
actual_end?: string;
|
||||
temperature?: number;
|
||||
humidity?: number;
|
||||
quality_score?: number;
|
||||
notes?: string;
|
||||
}
|
||||
|
||||
export interface PlanCreateRequest {
|
||||
date: string;
|
||||
batches: BatchCreateRequest[];
|
||||
efficiency_target?: number;
|
||||
quality_target?: number;
|
||||
}
|
||||
|
||||
export class ProductionService {
|
||||
private readonly basePath = '/production';
|
||||
|
||||
// Dashboard
|
||||
async getDashboardData(params?: {
|
||||
date_from?: string;
|
||||
date_to?: string;
|
||||
}): Promise<ProductionDashboardData> {
|
||||
return apiClient.get(`${this.basePath}/dashboard`, { params });
|
||||
}
|
||||
|
||||
async getDashboardMetrics(params?: {
|
||||
date_from?: string;
|
||||
date_to?: string;
|
||||
granularity?: 'hour' | 'day' | 'week' | 'month';
|
||||
}): Promise<{
|
||||
dates: string[];
|
||||
efficiency: number[];
|
||||
quality: number[];
|
||||
production_volume: number[];
|
||||
equipment_utilization: number[];
|
||||
}> {
|
||||
return apiClient.get(`${this.basePath}/dashboard/metrics`, { params });
|
||||
}
|
||||
|
||||
// Batches
|
||||
async getBatches(params?: {
|
||||
status?: string;
|
||||
equipment_id?: string;
|
||||
date_from?: string;
|
||||
date_to?: string;
|
||||
limit?: number;
|
||||
offset?: number;
|
||||
}): Promise<ProductionBatch[]> {
|
||||
return apiClient.get(`${this.basePath}/batches`, { params });
|
||||
}
|
||||
|
||||
async getBatch(batchId: string): Promise<ProductionBatch> {
|
||||
return apiClient.get(`${this.basePath}/batches/${batchId}`);
|
||||
}
|
||||
|
||||
async createBatch(batch: BatchCreateRequest): Promise<ProductionBatch> {
|
||||
return apiClient.post(`${this.basePath}/batches`, batch);
|
||||
}
|
||||
|
||||
async updateBatch(batchId: string, updates: BatchUpdateRequest): Promise<ProductionBatch> {
|
||||
return apiClient.put(`${this.basePath}/batches/${batchId}`, updates);
|
||||
}
|
||||
|
||||
async deleteBatch(batchId: string): Promise<void> {
|
||||
return apiClient.delete(`${this.basePath}/batches/${batchId}`);
|
||||
}
|
||||
|
||||
async startBatch(batchId: string): Promise<ProductionBatch> {
|
||||
return apiClient.post(`${this.basePath}/batches/${batchId}/start`);
|
||||
}
|
||||
|
||||
async completeBatch(batchId: string, qualityScore?: number, notes?: string): Promise<ProductionBatch> {
|
||||
return apiClient.post(`${this.basePath}/batches/${batchId}/complete`, {
|
||||
quality_score: qualityScore,
|
||||
notes
|
||||
});
|
||||
}
|
||||
|
||||
async getBatchStatus(batchId: string): Promise<{
|
||||
status: string;
|
||||
progress: number;
|
||||
current_phase: string;
|
||||
temperature: number;
|
||||
humidity: number;
|
||||
estimated_completion: string;
|
||||
}> {
|
||||
return apiClient.get(`${this.basePath}/batches/${batchId}/status`);
|
||||
}
|
||||
|
||||
// Production Plans
|
||||
async getPlans(params?: {
|
||||
date_from?: string;
|
||||
date_to?: string;
|
||||
status?: string;
|
||||
limit?: number;
|
||||
offset?: number;
|
||||
}): Promise<ProductionPlan[]> {
|
||||
return apiClient.get(`${this.basePath}/plans`, { params });
|
||||
}
|
||||
|
||||
async getPlan(planId: string): Promise<ProductionPlan> {
|
||||
return apiClient.get(`${this.basePath}/plans/${planId}`);
|
||||
}
|
||||
|
||||
async createPlan(plan: PlanCreateRequest): Promise<ProductionPlan> {
|
||||
return apiClient.post(`${this.basePath}/plans`, plan);
|
||||
}
|
||||
|
||||
async updatePlan(planId: string, updates: Partial<PlanCreateRequest>): Promise<ProductionPlan> {
|
||||
return apiClient.put(`${this.basePath}/plans/${planId}`, updates);
|
||||
}
|
||||
|
||||
async deletePlan(planId: string): Promise<void> {
|
||||
return apiClient.delete(`${this.basePath}/plans/${planId}`);
|
||||
}
|
||||
|
||||
async approvePlan(planId: string): Promise<ProductionPlan> {
|
||||
return apiClient.post(`${this.basePath}/plans/${planId}/approve`);
|
||||
}
|
||||
|
||||
async optimizePlan(planId: string): Promise<ProductionPlan> {
|
||||
return apiClient.post(`${this.basePath}/plans/${planId}/optimize`);
|
||||
}
|
||||
|
||||
// Equipment
|
||||
async getEquipment(params?: {
|
||||
status?: string;
|
||||
type?: string;
|
||||
location?: string;
|
||||
limit?: number;
|
||||
offset?: number;
|
||||
}): Promise<Equipment[]> {
|
||||
return apiClient.get(`${this.basePath}/equipment`, { params });
|
||||
}
|
||||
|
||||
async getEquipmentById(equipmentId: string): Promise<Equipment> {
|
||||
return apiClient.get(`${this.basePath}/equipment/${equipmentId}`);
|
||||
}
|
||||
|
||||
async updateEquipment(equipmentId: string, updates: {
|
||||
status?: 'active' | 'idle' | 'maintenance' | 'error';
|
||||
temperature?: number;
|
||||
notes?: string;
|
||||
}): Promise<Equipment> {
|
||||
return apiClient.put(`${this.basePath}/equipment/${equipmentId}`, updates);
|
||||
}
|
||||
|
||||
async getEquipmentMetrics(equipmentId: string, params?: {
|
||||
date_from?: string;
|
||||
date_to?: string;
|
||||
}): Promise<{
|
||||
utilization: number[];
|
||||
temperature: number[];
|
||||
maintenance_events: any[];
|
||||
performance_score: number;
|
||||
}> {
|
||||
return apiClient.get(`${this.basePath}/equipment/${equipmentId}/metrics`, { params });
|
||||
}
|
||||
|
||||
async scheduleMaintenanceForEquipment(equipmentId: string, scheduledDate: string, notes?: string): Promise<void> {
|
||||
return apiClient.post(`${this.basePath}/equipment/${equipmentId}/maintenance`, {
|
||||
scheduled_date: scheduledDate,
|
||||
notes
|
||||
});
|
||||
}
|
||||
|
||||
// Analytics
|
||||
async getEfficiencyTrends(params?: {
|
||||
date_from?: string;
|
||||
date_to?: string;
|
||||
equipment_id?: string;
|
||||
}): Promise<{
|
||||
dates: string[];
|
||||
efficiency: number[];
|
||||
quality: number[];
|
||||
volume: number[];
|
||||
}> {
|
||||
return apiClient.get(`${this.basePath}/analytics/efficiency`, { params });
|
||||
}
|
||||
|
||||
async getProductionForecast(params?: {
|
||||
days?: number;
|
||||
include_weather?: boolean;
|
||||
}): Promise<{
|
||||
dates: string[];
|
||||
predicted_volume: number[];
|
||||
confidence_intervals: number[][];
|
||||
factors: string[];
|
||||
}> {
|
||||
return apiClient.get(`${this.basePath}/analytics/forecast`, { params });
|
||||
}
|
||||
|
||||
async getQualityAnalysis(params?: {
|
||||
date_from?: string;
|
||||
date_to?: string;
|
||||
recipe_id?: string;
|
||||
}): Promise<{
|
||||
average_quality: number;
|
||||
quality_trend: number[];
|
||||
quality_factors: { factor: string; impact: number }[];
|
||||
recommendations: string[];
|
||||
}> {
|
||||
return apiClient.get(`${this.basePath}/analytics/quality`, { params });
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
@@ -1,551 +0,0 @@
|
||||
// frontend/src/api/services/recipes.service.ts
|
||||
/**
|
||||
* Recipe Service API Client
|
||||
* Handles all recipe and production management API calls
|
||||
*/
|
||||
|
||||
import { apiClient } from '../client';
|
||||
import type {
|
||||
PaginatedResponse,
|
||||
ApiResponse,
|
||||
CreateResponse,
|
||||
UpdateResponse
|
||||
} from '../types';
|
||||
|
||||
// Recipe Types
|
||||
export interface Recipe {
|
||||
id: string;
|
||||
tenant_id: string;
|
||||
name: string;
|
||||
recipe_code?: string;
|
||||
version: string;
|
||||
finished_product_id: string;
|
||||
description?: string;
|
||||
category?: string;
|
||||
cuisine_type?: string;
|
||||
difficulty_level: number;
|
||||
yield_quantity: number;
|
||||
yield_unit: string;
|
||||
prep_time_minutes?: number;
|
||||
cook_time_minutes?: number;
|
||||
total_time_minutes?: number;
|
||||
rest_time_minutes?: number;
|
||||
estimated_cost_per_unit?: number;
|
||||
last_calculated_cost?: number;
|
||||
cost_calculation_date?: string;
|
||||
target_margin_percentage?: number;
|
||||
suggested_selling_price?: number;
|
||||
instructions?: Record<string, any>;
|
||||
preparation_notes?: string;
|
||||
storage_instructions?: string;
|
||||
quality_standards?: string;
|
||||
serves_count?: number;
|
||||
nutritional_info?: Record<string, any>;
|
||||
allergen_info?: Record<string, any>;
|
||||
dietary_tags?: Record<string, any>;
|
||||
batch_size_multiplier: number;
|
||||
minimum_batch_size?: number;
|
||||
maximum_batch_size?: number;
|
||||
optimal_production_temperature?: number;
|
||||
optimal_humidity?: number;
|
||||
quality_check_points?: Record<string, any>;
|
||||
common_issues?: Record<string, any>;
|
||||
status: 'draft' | 'active' | 'testing' | 'archived' | 'discontinued';
|
||||
is_seasonal: boolean;
|
||||
season_start_month?: number;
|
||||
season_end_month?: number;
|
||||
is_signature_item: boolean;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
created_by?: string;
|
||||
updated_by?: string;
|
||||
ingredients?: RecipeIngredient[];
|
||||
}
|
||||
|
||||
export interface RecipeIngredient {
|
||||
id: string;
|
||||
tenant_id: string;
|
||||
recipe_id: string;
|
||||
ingredient_id: string;
|
||||
quantity: number;
|
||||
unit: string;
|
||||
quantity_in_base_unit?: number;
|
||||
alternative_quantity?: number;
|
||||
alternative_unit?: string;
|
||||
preparation_method?: string;
|
||||
ingredient_notes?: string;
|
||||
is_optional: boolean;
|
||||
ingredient_order: number;
|
||||
ingredient_group?: string;
|
||||
substitution_options?: Record<string, any>;
|
||||
substitution_ratio?: number;
|
||||
unit_cost?: number;
|
||||
total_cost?: number;
|
||||
cost_updated_at?: string;
|
||||
}
|
||||
|
||||
export interface CreateRecipeRequest {
|
||||
name: string;
|
||||
recipe_code?: string;
|
||||
version?: string;
|
||||
finished_product_id: string;
|
||||
description?: string;
|
||||
category?: string;
|
||||
cuisine_type?: string;
|
||||
difficulty_level?: number;
|
||||
yield_quantity: number;
|
||||
yield_unit: string;
|
||||
prep_time_minutes?: number;
|
||||
cook_time_minutes?: number;
|
||||
total_time_minutes?: number;
|
||||
rest_time_minutes?: number;
|
||||
instructions?: Record<string, any>;
|
||||
preparation_notes?: string;
|
||||
storage_instructions?: string;
|
||||
quality_standards?: string;
|
||||
serves_count?: number;
|
||||
nutritional_info?: Record<string, any>;
|
||||
allergen_info?: Record<string, any>;
|
||||
dietary_tags?: Record<string, any>;
|
||||
batch_size_multiplier?: number;
|
||||
minimum_batch_size?: number;
|
||||
maximum_batch_size?: number;
|
||||
optimal_production_temperature?: number;
|
||||
optimal_humidity?: number;
|
||||
quality_check_points?: Record<string, any>;
|
||||
common_issues?: Record<string, any>;
|
||||
is_seasonal?: boolean;
|
||||
season_start_month?: number;
|
||||
season_end_month?: number;
|
||||
is_signature_item?: boolean;
|
||||
target_margin_percentage?: number;
|
||||
ingredients: CreateRecipeIngredientRequest[];
|
||||
}
|
||||
|
||||
export interface CreateRecipeIngredientRequest {
|
||||
ingredient_id: string;
|
||||
quantity: number;
|
||||
unit: string;
|
||||
alternative_quantity?: number;
|
||||
alternative_unit?: string;
|
||||
preparation_method?: string;
|
||||
ingredient_notes?: string;
|
||||
is_optional?: boolean;
|
||||
ingredient_order: number;
|
||||
ingredient_group?: string;
|
||||
substitution_options?: Record<string, any>;
|
||||
substitution_ratio?: number;
|
||||
}
|
||||
|
||||
export interface UpdateRecipeRequest {
|
||||
name?: string;
|
||||
recipe_code?: string;
|
||||
version?: string;
|
||||
description?: string;
|
||||
category?: string;
|
||||
cuisine_type?: string;
|
||||
difficulty_level?: number;
|
||||
yield_quantity?: number;
|
||||
yield_unit?: string;
|
||||
prep_time_minutes?: number;
|
||||
cook_time_minutes?: number;
|
||||
total_time_minutes?: number;
|
||||
rest_time_minutes?: number;
|
||||
instructions?: Record<string, any>;
|
||||
preparation_notes?: string;
|
||||
storage_instructions?: string;
|
||||
quality_standards?: string;
|
||||
serves_count?: number;
|
||||
nutritional_info?: Record<string, any>;
|
||||
allergen_info?: Record<string, any>;
|
||||
dietary_tags?: Record<string, any>;
|
||||
batch_size_multiplier?: number;
|
||||
minimum_batch_size?: number;
|
||||
maximum_batch_size?: number;
|
||||
optimal_production_temperature?: number;
|
||||
optimal_humidity?: number;
|
||||
quality_check_points?: Record<string, any>;
|
||||
common_issues?: Record<string, any>;
|
||||
status?: 'draft' | 'active' | 'testing' | 'archived' | 'discontinued';
|
||||
is_seasonal?: boolean;
|
||||
season_start_month?: number;
|
||||
season_end_month?: number;
|
||||
is_signature_item?: boolean;
|
||||
target_margin_percentage?: number;
|
||||
ingredients?: CreateRecipeIngredientRequest[];
|
||||
}
|
||||
|
||||
export interface RecipeSearchParams {
|
||||
search_term?: string;
|
||||
status?: string;
|
||||
category?: string;
|
||||
is_seasonal?: boolean;
|
||||
is_signature?: boolean;
|
||||
difficulty_level?: number;
|
||||
limit?: number;
|
||||
offset?: number;
|
||||
}
|
||||
|
||||
export interface RecipeFeasibility {
|
||||
recipe_id: string;
|
||||
recipe_name: string;
|
||||
batch_multiplier: number;
|
||||
feasible: boolean;
|
||||
missing_ingredients: Array<{
|
||||
ingredient_id: string;
|
||||
ingredient_name: string;
|
||||
required_quantity: number;
|
||||
unit: string;
|
||||
}>;
|
||||
insufficient_ingredients: Array<{
|
||||
ingredient_id: string;
|
||||
ingredient_name: string;
|
||||
required_quantity: number;
|
||||
available_quantity: number;
|
||||
unit: string;
|
||||
}>;
|
||||
}
|
||||
|
||||
export interface RecipeStatistics {
|
||||
total_recipes: number;
|
||||
active_recipes: number;
|
||||
signature_recipes: number;
|
||||
seasonal_recipes: number;
|
||||
category_breakdown: Array<{
|
||||
category: string;
|
||||
count: number;
|
||||
}>;
|
||||
}
|
||||
|
||||
// Production Types
|
||||
export interface ProductionBatch {
|
||||
id: string;
|
||||
tenant_id: string;
|
||||
recipe_id: string;
|
||||
batch_number: string;
|
||||
production_date: string;
|
||||
planned_start_time?: string;
|
||||
actual_start_time?: string;
|
||||
planned_end_time?: string;
|
||||
actual_end_time?: string;
|
||||
planned_quantity: number;
|
||||
actual_quantity?: number;
|
||||
yield_percentage?: number;
|
||||
batch_size_multiplier: number;
|
||||
status: 'planned' | 'in_progress' | 'completed' | 'failed' | 'cancelled';
|
||||
priority: 'low' | 'normal' | 'high' | 'urgent';
|
||||
assigned_staff?: string[];
|
||||
production_notes?: string;
|
||||
quality_score?: number;
|
||||
quality_notes?: string;
|
||||
defect_rate?: number;
|
||||
rework_required: boolean;
|
||||
planned_material_cost?: number;
|
||||
actual_material_cost?: number;
|
||||
labor_cost?: number;
|
||||
overhead_cost?: number;
|
||||
total_production_cost?: number;
|
||||
cost_per_unit?: number;
|
||||
production_temperature?: number;
|
||||
production_humidity?: number;
|
||||
oven_temperature?: number;
|
||||
baking_time_minutes?: number;
|
||||
waste_quantity: number;
|
||||
waste_reason?: string;
|
||||
efficiency_percentage?: number;
|
||||
customer_order_reference?: string;
|
||||
pre_order_quantity?: number;
|
||||
shelf_quantity?: number;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
created_by?: string;
|
||||
completed_by?: string;
|
||||
ingredient_consumptions?: ProductionIngredientConsumption[];
|
||||
}
|
||||
|
||||
export interface ProductionIngredientConsumption {
|
||||
id: string;
|
||||
tenant_id: string;
|
||||
production_batch_id: string;
|
||||
recipe_ingredient_id: string;
|
||||
ingredient_id: string;
|
||||
stock_id?: string;
|
||||
planned_quantity: number;
|
||||
actual_quantity: number;
|
||||
unit: string;
|
||||
variance_quantity?: number;
|
||||
variance_percentage?: number;
|
||||
unit_cost?: number;
|
||||
total_cost?: number;
|
||||
consumption_time: string;
|
||||
consumption_notes?: string;
|
||||
staff_member?: string;
|
||||
ingredient_condition?: string;
|
||||
quality_impact?: string;
|
||||
substitution_used: boolean;
|
||||
substitution_details?: string;
|
||||
}
|
||||
|
||||
export interface CreateProductionBatchRequest {
|
||||
recipe_id: string;
|
||||
batch_number?: string;
|
||||
production_date: string;
|
||||
planned_start_time?: string;
|
||||
planned_end_time?: string;
|
||||
planned_quantity: number;
|
||||
batch_size_multiplier?: number;
|
||||
priority?: 'low' | 'normal' | 'high' | 'urgent';
|
||||
assigned_staff?: string[];
|
||||
production_notes?: string;
|
||||
customer_order_reference?: string;
|
||||
pre_order_quantity?: number;
|
||||
shelf_quantity?: number;
|
||||
}
|
||||
|
||||
export interface UpdateProductionBatchRequest {
|
||||
batch_number?: string;
|
||||
production_date?: string;
|
||||
planned_start_time?: string;
|
||||
actual_start_time?: string;
|
||||
planned_end_time?: string;
|
||||
actual_end_time?: string;
|
||||
planned_quantity?: number;
|
||||
actual_quantity?: number;
|
||||
batch_size_multiplier?: number;
|
||||
status?: 'planned' | 'in_progress' | 'completed' | 'failed' | 'cancelled';
|
||||
priority?: 'low' | 'normal' | 'high' | 'urgent';
|
||||
assigned_staff?: string[];
|
||||
production_notes?: string;
|
||||
quality_score?: number;
|
||||
quality_notes?: string;
|
||||
defect_rate?: number;
|
||||
rework_required?: boolean;
|
||||
labor_cost?: number;
|
||||
overhead_cost?: number;
|
||||
production_temperature?: number;
|
||||
production_humidity?: number;
|
||||
oven_temperature?: number;
|
||||
baking_time_minutes?: number;
|
||||
waste_quantity?: number;
|
||||
waste_reason?: string;
|
||||
customer_order_reference?: string;
|
||||
pre_order_quantity?: number;
|
||||
shelf_quantity?: number;
|
||||
}
|
||||
|
||||
export interface ProductionBatchSearchParams {
|
||||
search_term?: string;
|
||||
status?: string;
|
||||
priority?: string;
|
||||
start_date?: string;
|
||||
end_date?: string;
|
||||
recipe_id?: string;
|
||||
limit?: number;
|
||||
offset?: number;
|
||||
}
|
||||
|
||||
export interface ProductionStatistics {
|
||||
total_batches: number;
|
||||
completed_batches: number;
|
||||
failed_batches: number;
|
||||
success_rate: number;
|
||||
average_yield_percentage: number;
|
||||
average_quality_score: number;
|
||||
total_production_cost: number;
|
||||
status_breakdown: Array<{
|
||||
status: string;
|
||||
count: number;
|
||||
}>;
|
||||
}
|
||||
|
||||
export class RecipesService {
|
||||
private baseUrl = '/api/recipes/v1';
|
||||
|
||||
// Recipe Management
|
||||
async getRecipes(tenantId: string, params?: RecipeSearchParams): Promise<Recipe[]> {
|
||||
const response = await apiClient.get<Recipe[]>(`${this.baseUrl}/recipes`, {
|
||||
headers: { 'X-Tenant-ID': tenantId },
|
||||
params
|
||||
});
|
||||
return response;
|
||||
}
|
||||
|
||||
async getRecipe(tenantId: string, recipeId: string): Promise<Recipe> {
|
||||
const response = await apiClient.get<Recipe>(`${this.baseUrl}/recipes/${recipeId}`, {
|
||||
headers: { 'X-Tenant-ID': tenantId }
|
||||
});
|
||||
return response;
|
||||
}
|
||||
|
||||
async createRecipe(tenantId: string, userId: string, data: CreateRecipeRequest): Promise<Recipe> {
|
||||
const response = await apiClient.post<Recipe>(`${this.baseUrl}/recipes`, data, {
|
||||
headers: {
|
||||
'X-Tenant-ID': tenantId,
|
||||
'X-User-ID': userId
|
||||
}
|
||||
});
|
||||
return response;
|
||||
}
|
||||
|
||||
async updateRecipe(tenantId: string, userId: string, recipeId: string, data: UpdateRecipeRequest): Promise<Recipe> {
|
||||
const response = await apiClient.put<Recipe>(`${this.baseUrl}/recipes/${recipeId}`, data, {
|
||||
headers: {
|
||||
'X-Tenant-ID': tenantId,
|
||||
'X-User-ID': userId
|
||||
}
|
||||
});
|
||||
return response;
|
||||
}
|
||||
|
||||
async deleteRecipe(tenantId: string, recipeId: string): Promise<void> {
|
||||
await apiClient.delete(`${this.baseUrl}/recipes/${recipeId}`, {
|
||||
headers: { 'X-Tenant-ID': tenantId }
|
||||
});
|
||||
}
|
||||
|
||||
async duplicateRecipe(tenantId: string, userId: string, recipeId: string, newName: string): Promise<Recipe> {
|
||||
const response = await apiClient.post<Recipe>(`${this.baseUrl}/recipes/${recipeId}/duplicate`,
|
||||
{ new_name: newName },
|
||||
{
|
||||
headers: {
|
||||
'X-Tenant-ID': tenantId,
|
||||
'X-User-ID': userId
|
||||
}
|
||||
}
|
||||
);
|
||||
return response;
|
||||
}
|
||||
|
||||
async activateRecipe(tenantId: string, userId: string, recipeId: string): Promise<Recipe> {
|
||||
const response = await apiClient.post<Recipe>(`${this.baseUrl}/recipes/${recipeId}/activate`, {}, {
|
||||
headers: {
|
||||
'X-Tenant-ID': tenantId,
|
||||
'X-User-ID': userId
|
||||
}
|
||||
});
|
||||
return response;
|
||||
}
|
||||
|
||||
async checkRecipeFeasibility(tenantId: string, recipeId: string, batchMultiplier: number = 1.0): Promise<RecipeFeasibility> {
|
||||
const response = await apiClient.get<RecipeFeasibility>(`${this.baseUrl}/recipes/${recipeId}/feasibility`, {
|
||||
headers: { 'X-Tenant-ID': tenantId },
|
||||
params: { batch_multiplier: batchMultiplier }
|
||||
});
|
||||
return response;
|
||||
}
|
||||
|
||||
async getRecipeStatistics(tenantId: string): Promise<RecipeStatistics> {
|
||||
const response = await apiClient.get<RecipeStatistics>(`${this.baseUrl}/recipes/statistics/dashboard`, {
|
||||
headers: { 'X-Tenant-ID': tenantId }
|
||||
});
|
||||
return response;
|
||||
}
|
||||
|
||||
async getRecipeCategories(tenantId: string): Promise<string[]> {
|
||||
const response = await apiClient.get<{ categories: string[] }>(`${this.baseUrl}/recipes/categories/list`, {
|
||||
headers: { 'X-Tenant-ID': tenantId }
|
||||
});
|
||||
return response.categories;
|
||||
}
|
||||
|
||||
// Production Management
|
||||
async getProductionBatches(tenantId: string, params?: ProductionBatchSearchParams): Promise<ProductionBatch[]> {
|
||||
const response = await apiClient.get<ProductionBatch[]>(`${this.baseUrl}/production/batches`, {
|
||||
headers: { 'X-Tenant-ID': tenantId },
|
||||
params
|
||||
});
|
||||
return response;
|
||||
}
|
||||
|
||||
async getProductionBatch(tenantId: string, batchId: string): Promise<ProductionBatch> {
|
||||
const response = await apiClient.get<ProductionBatch>(`${this.baseUrl}/production/batches/${batchId}`, {
|
||||
headers: { 'X-Tenant-ID': tenantId }
|
||||
});
|
||||
return response;
|
||||
}
|
||||
|
||||
async createProductionBatch(tenantId: string, userId: string, data: CreateProductionBatchRequest): Promise<ProductionBatch> {
|
||||
const response = await apiClient.post<ProductionBatch>(`${this.baseUrl}/production/batches`, data, {
|
||||
headers: {
|
||||
'X-Tenant-ID': tenantId,
|
||||
'X-User-ID': userId
|
||||
}
|
||||
});
|
||||
return response;
|
||||
}
|
||||
|
||||
async updateProductionBatch(tenantId: string, userId: string, batchId: string, data: UpdateProductionBatchRequest): Promise<ProductionBatch> {
|
||||
const response = await apiClient.put<ProductionBatch>(`${this.baseUrl}/production/batches/${batchId}`, data, {
|
||||
headers: {
|
||||
'X-Tenant-ID': tenantId,
|
||||
'X-User-ID': userId
|
||||
}
|
||||
});
|
||||
return response;
|
||||
}
|
||||
|
||||
async deleteProductionBatch(tenantId: string, batchId: string): Promise<void> {
|
||||
await apiClient.delete(`${this.baseUrl}/production/batches/${batchId}`, {
|
||||
headers: { 'X-Tenant-ID': tenantId }
|
||||
});
|
||||
}
|
||||
|
||||
async getActiveProductionBatches(tenantId: string): Promise<ProductionBatch[]> {
|
||||
const response = await apiClient.get<ProductionBatch[]>(`${this.baseUrl}/production/batches/active/list`, {
|
||||
headers: { 'X-Tenant-ID': tenantId }
|
||||
});
|
||||
return response;
|
||||
}
|
||||
|
||||
async startProductionBatch(tenantId: string, userId: string, batchId: string, data: {
|
||||
staff_member?: string;
|
||||
production_notes?: string;
|
||||
ingredient_consumptions: Array<{
|
||||
recipe_ingredient_id: string;
|
||||
ingredient_id: string;
|
||||
stock_id?: string;
|
||||
planned_quantity: number;
|
||||
actual_quantity: number;
|
||||
unit: string;
|
||||
consumption_notes?: string;
|
||||
ingredient_condition?: string;
|
||||
substitution_used?: boolean;
|
||||
substitution_details?: string;
|
||||
}>;
|
||||
}): Promise<ProductionBatch> {
|
||||
const response = await apiClient.post<ProductionBatch>(`${this.baseUrl}/production/batches/${batchId}/start`, data, {
|
||||
headers: {
|
||||
'X-Tenant-ID': tenantId,
|
||||
'X-User-ID': userId
|
||||
}
|
||||
});
|
||||
return response;
|
||||
}
|
||||
|
||||
async completeProductionBatch(tenantId: string, userId: string, batchId: string, data: {
|
||||
actual_quantity: number;
|
||||
quality_score?: number;
|
||||
quality_notes?: string;
|
||||
defect_rate?: number;
|
||||
waste_quantity?: number;
|
||||
waste_reason?: string;
|
||||
production_notes?: string;
|
||||
staff_member?: string;
|
||||
}): Promise<ProductionBatch> {
|
||||
const response = await apiClient.post<ProductionBatch>(`${this.baseUrl}/production/batches/${batchId}/complete`, data, {
|
||||
headers: {
|
||||
'X-Tenant-ID': tenantId,
|
||||
'X-User-ID': userId
|
||||
}
|
||||
});
|
||||
return response;
|
||||
}
|
||||
|
||||
async getProductionStatistics(tenantId: string, startDate?: string, endDate?: string): Promise<ProductionStatistics> {
|
||||
const response = await apiClient.get<ProductionStatistics>(`${this.baseUrl}/production/statistics/dashboard`, {
|
||||
headers: { 'X-Tenant-ID': tenantId },
|
||||
params: { start_date: startDate, end_date: endDate }
|
||||
});
|
||||
return response;
|
||||
}
|
||||
}
|
||||
@@ -1,219 +0,0 @@
|
||||
// frontend/src/api/services/sales.service.ts
|
||||
/**
|
||||
* Sales Data Service
|
||||
* Handles sales data operations for the sales microservice
|
||||
*/
|
||||
|
||||
import { apiClient } from '../client';
|
||||
import { RequestTimeouts } from '../client/config';
|
||||
import type {
|
||||
SalesData,
|
||||
SalesValidationResult,
|
||||
SalesDataQuery,
|
||||
SalesDataImport,
|
||||
SalesImportResult,
|
||||
DashboardStats,
|
||||
PaginatedResponse,
|
||||
ActivityItem,
|
||||
} from '../types';
|
||||
|
||||
export class SalesService {
|
||||
/**
|
||||
* Upload Sales History File
|
||||
*/
|
||||
async uploadSalesHistory(
|
||||
tenantId: string,
|
||||
file: File,
|
||||
additionalData?: Record<string, any>
|
||||
): Promise<SalesImportResult> {
|
||||
// Determine file format
|
||||
const fileName = file.name.toLowerCase();
|
||||
let fileFormat: string;
|
||||
|
||||
if (fileName.endsWith('.csv')) {
|
||||
fileFormat = 'csv';
|
||||
} else if (fileName.endsWith('.json')) {
|
||||
fileFormat = 'json';
|
||||
} else if (fileName.endsWith('.xlsx') || fileName.endsWith('.xls')) {
|
||||
fileFormat = 'excel';
|
||||
} else {
|
||||
fileFormat = 'csv'; // Default fallback
|
||||
}
|
||||
|
||||
const uploadData = {
|
||||
file_format: fileFormat,
|
||||
...additionalData,
|
||||
};
|
||||
|
||||
return apiClient.upload(
|
||||
`/tenants/${tenantId}/sales/import`,
|
||||
file,
|
||||
uploadData,
|
||||
{
|
||||
timeout: RequestTimeouts.LONG,
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate Sales Data
|
||||
*/
|
||||
async validateSalesData(
|
||||
tenantId: string,
|
||||
file: File
|
||||
): Promise<SalesValidationResult> {
|
||||
const fileName = file.name.toLowerCase();
|
||||
let fileFormat: string;
|
||||
|
||||
if (fileName.endsWith('.csv')) {
|
||||
fileFormat = 'csv';
|
||||
} else if (fileName.endsWith('.json')) {
|
||||
fileFormat = 'json';
|
||||
} else if (fileName.endsWith('.xlsx') || fileName.endsWith('.xls')) {
|
||||
fileFormat = 'excel';
|
||||
} else {
|
||||
fileFormat = 'csv';
|
||||
}
|
||||
|
||||
return apiClient.upload(
|
||||
`/tenants/${tenantId}/sales/import/validate`,
|
||||
file,
|
||||
{
|
||||
file_format: fileFormat,
|
||||
validate_only: true,
|
||||
source: 'onboarding_upload',
|
||||
},
|
||||
{
|
||||
timeout: RequestTimeouts.MEDIUM,
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get Sales Data
|
||||
*/
|
||||
async getSalesData(
|
||||
tenantId: string,
|
||||
query?: SalesDataQuery
|
||||
): Promise<PaginatedResponse<SalesData>> {
|
||||
return apiClient.get(`/tenants/${tenantId}/sales`, { params: query });
|
||||
}
|
||||
|
||||
/**
|
||||
* Get Single Sales Record
|
||||
*/
|
||||
async getSalesRecord(tenantId: string, recordId: string): Promise<SalesData> {
|
||||
return apiClient.get(`/tenants/${tenantId}/sales/${recordId}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Update Sales Record
|
||||
*/
|
||||
async updateSalesRecord(
|
||||
tenantId: string,
|
||||
recordId: string,
|
||||
data: Partial<SalesData>
|
||||
): Promise<SalesData> {
|
||||
return apiClient.put(`/tenants/${tenantId}/sales/${recordId}`, data);
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete Sales Record
|
||||
*/
|
||||
async deleteSalesRecord(tenantId: string, recordId: string): Promise<{ message: string }> {
|
||||
return apiClient.delete(`/tenants/${tenantId}/sales/${recordId}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get Dashboard Statistics
|
||||
*/
|
||||
async getDashboardStats(tenantId: string): Promise<DashboardStats> {
|
||||
return apiClient.get(`/tenants/${tenantId}/sales/stats`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get Analytics Data
|
||||
*/
|
||||
async getAnalytics(
|
||||
tenantId: string,
|
||||
params?: {
|
||||
start_date?: string;
|
||||
end_date?: string;
|
||||
inventory_product_ids?: string[]; // Primary way to filter by products
|
||||
product_names?: string[]; // For backward compatibility - will need inventory service lookup
|
||||
metrics?: string[];
|
||||
}
|
||||
): Promise<any> {
|
||||
return apiClient.get(`/tenants/${tenantId}/sales/analytics`, { params });
|
||||
}
|
||||
|
||||
/**
|
||||
* Export Sales Data
|
||||
*/
|
||||
async exportSalesData(
|
||||
tenantId: string,
|
||||
format: 'csv' | 'excel' | 'json',
|
||||
query?: SalesDataQuery
|
||||
): Promise<Blob> {
|
||||
const response = await apiClient.request(`/tenants/${tenantId}/sales/export`, {
|
||||
method: 'GET',
|
||||
params: { ...query, format },
|
||||
headers: {
|
||||
'Accept': format === 'csv' ? 'text/csv' :
|
||||
format === 'excel' ? 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' :
|
||||
'application/json',
|
||||
},
|
||||
});
|
||||
|
||||
return new Blob([response], {
|
||||
type: format === 'csv' ? 'text/csv' :
|
||||
format === 'excel' ? 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' :
|
||||
'application/json',
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Get Recent Activity
|
||||
*/
|
||||
async getRecentActivity(tenantId: string, limit?: number): Promise<ActivityItem[]> {
|
||||
return apiClient.get(`/tenants/${tenantId}/sales/activity`, {
|
||||
params: { limit },
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Get Sales Summary by Period
|
||||
*/
|
||||
async getSalesSummary(
|
||||
tenantId: string,
|
||||
period: 'daily' | 'weekly' | 'monthly' = 'daily'
|
||||
): Promise<any> {
|
||||
return apiClient.get(`/tenants/${tenantId}/sales/summary`, {
|
||||
params: { period }
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Get Sales Analytics
|
||||
*/
|
||||
async getSalesAnalytics(
|
||||
tenantId: string,
|
||||
startDate?: string,
|
||||
endDate?: string
|
||||
): Promise<{
|
||||
total_revenue: number;
|
||||
waste_reduction_percentage?: number;
|
||||
forecast_accuracy?: number;
|
||||
stockout_events?: number;
|
||||
}> {
|
||||
return apiClient.get(`/tenants/${tenantId}/sales/analytics/summary`, {
|
||||
params: {
|
||||
start_date: startDate,
|
||||
end_date: endDate
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export const salesService = new SalesService();
|
||||
@@ -1,475 +0,0 @@
|
||||
// ================================================================
|
||||
// frontend/src/api/services/suppliers.service.ts
|
||||
// ================================================================
|
||||
/**
|
||||
* Suppliers Service - API client for Suppliers Service endpoints
|
||||
*/
|
||||
|
||||
import { apiClient } from '../client';
|
||||
|
||||
// Supplier Types
|
||||
export interface Supplier {
|
||||
id: string;
|
||||
tenant_id: string;
|
||||
name: string;
|
||||
contact_person?: string;
|
||||
email?: string;
|
||||
phone?: string;
|
||||
address?: string;
|
||||
supplier_type: 'ingredients' | 'packaging' | 'equipment' | 'services';
|
||||
status: 'active' | 'inactive' | 'pending_approval';
|
||||
payment_terms?: string;
|
||||
lead_time_days: number;
|
||||
minimum_order_value?: number;
|
||||
delivery_areas: string[];
|
||||
certifications: string[];
|
||||
rating?: number;
|
||||
notes?: string;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
export interface SupplierPerformance {
|
||||
supplier_id: string;
|
||||
supplier_name: string;
|
||||
period_start: string;
|
||||
period_end: string;
|
||||
metrics: {
|
||||
delivery_performance: {
|
||||
on_time_delivery_rate: number;
|
||||
average_delay_days: number;
|
||||
total_deliveries: number;
|
||||
};
|
||||
quality_performance: {
|
||||
quality_score: number;
|
||||
defect_rate: number;
|
||||
complaints_count: number;
|
||||
returns_count: number;
|
||||
};
|
||||
cost_performance: {
|
||||
price_competitiveness: number;
|
||||
cost_savings: number;
|
||||
invoice_accuracy: number;
|
||||
};
|
||||
service_performance: {
|
||||
responsiveness_score: number;
|
||||
communication_score: number;
|
||||
flexibility_score: number;
|
||||
};
|
||||
};
|
||||
overall_score: number;
|
||||
performance_trend: 'improving' | 'stable' | 'declining';
|
||||
risk_level: 'low' | 'medium' | 'high' | 'critical';
|
||||
recommendations: string[];
|
||||
}
|
||||
|
||||
// Additional types for hooks
|
||||
export interface SupplierSummary {
|
||||
id: string;
|
||||
name: string;
|
||||
supplier_type: string;
|
||||
status: string;
|
||||
rating?: number;
|
||||
total_orders: number;
|
||||
total_spent: number;
|
||||
last_delivery_date?: string;
|
||||
}
|
||||
|
||||
export interface CreateSupplierRequest {
|
||||
name: string;
|
||||
contact_person?: string;
|
||||
email?: string;
|
||||
phone?: string;
|
||||
address?: string;
|
||||
supplier_type: 'ingredients' | 'packaging' | 'equipment' | 'services';
|
||||
payment_terms?: string;
|
||||
lead_time_days: number;
|
||||
minimum_order_value?: number;
|
||||
delivery_areas: string[];
|
||||
certifications?: string[];
|
||||
notes?: string;
|
||||
}
|
||||
|
||||
export interface UpdateSupplierRequest extends Partial<CreateSupplierRequest> {}
|
||||
|
||||
export interface SupplierSearchParams {
|
||||
supplier_type?: string;
|
||||
status?: string;
|
||||
search?: string;
|
||||
delivery_area?: string;
|
||||
limit?: number;
|
||||
offset?: number;
|
||||
}
|
||||
|
||||
export interface SupplierStatistics {
|
||||
total_suppliers: number;
|
||||
active_suppliers: number;
|
||||
average_rating: number;
|
||||
top_performing_suppliers: SupplierSummary[];
|
||||
}
|
||||
|
||||
export interface PurchaseOrder {
|
||||
id: string;
|
||||
supplier_id: string;
|
||||
supplier_name: string;
|
||||
order_number: string;
|
||||
status: 'pending' | 'confirmed' | 'shipped' | 'delivered' | 'cancelled';
|
||||
total_amount: number;
|
||||
created_at: string;
|
||||
expected_delivery: string;
|
||||
}
|
||||
|
||||
export interface CreatePurchaseOrderRequest {
|
||||
supplier_id: string;
|
||||
items: Array<{
|
||||
product_id: string;
|
||||
quantity: number;
|
||||
unit_price: number;
|
||||
}>;
|
||||
delivery_date: string;
|
||||
notes?: string;
|
||||
}
|
||||
|
||||
export interface PurchaseOrderSearchParams {
|
||||
supplier_id?: string;
|
||||
status?: string;
|
||||
date_from?: string;
|
||||
date_to?: string;
|
||||
limit?: number;
|
||||
offset?: number;
|
||||
}
|
||||
|
||||
export interface PurchaseOrderStatistics {
|
||||
total_orders: number;
|
||||
total_value: number;
|
||||
pending_orders: number;
|
||||
overdue_orders: number;
|
||||
}
|
||||
|
||||
export interface Delivery {
|
||||
id: string;
|
||||
purchase_order_id: string;
|
||||
supplier_name: string;
|
||||
delivered_at: string;
|
||||
status: 'on_time' | 'late' | 'early';
|
||||
quality_rating?: number;
|
||||
}
|
||||
|
||||
export interface DeliverySearchParams {
|
||||
supplier_id?: string;
|
||||
status?: string;
|
||||
date_from?: string;
|
||||
date_to?: string;
|
||||
limit?: number;
|
||||
offset?: number;
|
||||
}
|
||||
|
||||
export interface DeliveryPerformanceStats {
|
||||
on_time_delivery_rate: number;
|
||||
average_delay_days: number;
|
||||
quality_average: number;
|
||||
total_deliveries: number;
|
||||
}
|
||||
|
||||
export interface SupplierDashboardData {
|
||||
summary: {
|
||||
total_suppliers: number;
|
||||
active_suppliers: number;
|
||||
pending_orders: number;
|
||||
overdue_deliveries: number;
|
||||
average_performance_score: number;
|
||||
total_monthly_spend: number;
|
||||
};
|
||||
top_performers: SupplierPerformance[];
|
||||
recent_orders: any[];
|
||||
performance_trends: {
|
||||
dates: string[];
|
||||
delivery_rates: number[];
|
||||
quality_scores: number[];
|
||||
cost_savings: number[];
|
||||
};
|
||||
contract_expirations: { supplier_name: string; days_until_expiry: number }[];
|
||||
}
|
||||
|
||||
export class SuppliersService {
|
||||
private readonly basePath = '/suppliers';
|
||||
|
||||
// Dashboard
|
||||
async getDashboardData(params?: {
|
||||
date_from?: string;
|
||||
date_to?: string;
|
||||
}): Promise<SupplierDashboardData> {
|
||||
return apiClient.get(`${this.basePath}/dashboard`, { params });
|
||||
}
|
||||
|
||||
async getDashboardMetrics(params?: {
|
||||
date_from?: string;
|
||||
date_to?: string;
|
||||
supplier_id?: string;
|
||||
}): Promise<{
|
||||
dates: string[];
|
||||
delivery_performance: number[];
|
||||
quality_scores: number[];
|
||||
cost_savings: number[];
|
||||
order_volumes: number[];
|
||||
}> {
|
||||
return apiClient.get(`${this.basePath}/dashboard/metrics`, { params });
|
||||
}
|
||||
|
||||
// Suppliers
|
||||
async getSuppliers(params?: {
|
||||
supplier_type?: string;
|
||||
status?: string;
|
||||
search?: string;
|
||||
delivery_area?: string;
|
||||
limit?: number;
|
||||
offset?: number;
|
||||
}): Promise<Supplier[]> {
|
||||
return apiClient.get(`${this.basePath}`, { params });
|
||||
}
|
||||
|
||||
async getSupplier(supplierId: string): Promise<Supplier> {
|
||||
return apiClient.get(`${this.basePath}/${supplierId}`);
|
||||
}
|
||||
|
||||
async createSupplier(supplier: {
|
||||
name: string;
|
||||
contact_person?: string;
|
||||
email?: string;
|
||||
phone?: string;
|
||||
address?: string;
|
||||
supplier_type: 'ingredients' | 'packaging' | 'equipment' | 'services';
|
||||
payment_terms?: string;
|
||||
lead_time_days: number;
|
||||
minimum_order_value?: number;
|
||||
delivery_areas: string[];
|
||||
certifications?: string[];
|
||||
notes?: string;
|
||||
}): Promise<Supplier> {
|
||||
return apiClient.post(`${this.basePath}`, supplier);
|
||||
}
|
||||
|
||||
async updateSupplier(supplierId: string, updates: Partial<Supplier>): Promise<Supplier> {
|
||||
return apiClient.put(`${this.basePath}/${supplierId}`, updates);
|
||||
}
|
||||
|
||||
async deleteSupplier(supplierId: string): Promise<void> {
|
||||
return apiClient.delete(`${this.basePath}/${supplierId}`);
|
||||
}
|
||||
|
||||
// Performance Management
|
||||
async getSupplierPerformance(supplierId: string, params?: {
|
||||
period_start?: string;
|
||||
period_end?: string;
|
||||
}): Promise<SupplierPerformance> {
|
||||
return apiClient.get(`${this.basePath}/${supplierId}/performance`, { params });
|
||||
}
|
||||
|
||||
async getAllSupplierPerformance(params?: {
|
||||
period_start?: string;
|
||||
period_end?: string;
|
||||
min_score?: number;
|
||||
risk_level?: string;
|
||||
limit?: number;
|
||||
offset?: number;
|
||||
}): Promise<SupplierPerformance[]> {
|
||||
return apiClient.get(`${this.basePath}/performance`, { params });
|
||||
}
|
||||
|
||||
async updateSupplierRating(supplierId: string, rating: {
|
||||
overall_rating: number;
|
||||
delivery_rating: number;
|
||||
quality_rating: number;
|
||||
service_rating: number;
|
||||
comments?: string;
|
||||
}): Promise<void> {
|
||||
return apiClient.post(`${this.basePath}/${supplierId}/rating`, rating);
|
||||
}
|
||||
|
||||
// Analytics
|
||||
async getCostAnalysis(params?: {
|
||||
date_from?: string;
|
||||
date_to?: string;
|
||||
supplier_id?: string;
|
||||
category?: string;
|
||||
}): Promise<{
|
||||
total_spend: number;
|
||||
cost_by_supplier: { supplier_name: string; amount: number }[];
|
||||
cost_by_category: { category: string; amount: number }[];
|
||||
cost_trends: { date: string; amount: number }[];
|
||||
cost_savings_opportunities: string[];
|
||||
}> {
|
||||
return apiClient.get(`${this.basePath}/analytics/costs`, { params });
|
||||
}
|
||||
|
||||
async getSupplyChainRiskAnalysis(): Promise<{
|
||||
high_risk_suppliers: {
|
||||
supplier_id: string;
|
||||
supplier_name: string;
|
||||
risk_factors: string[];
|
||||
risk_score: number;
|
||||
}[];
|
||||
diversification_analysis: {
|
||||
category: string;
|
||||
supplier_count: number;
|
||||
concentration_risk: number;
|
||||
}[];
|
||||
recommendations: string[];
|
||||
}> {
|
||||
return apiClient.get(`${this.basePath}/analytics/risk-analysis`);
|
||||
}
|
||||
|
||||
|
||||
|
||||
// Additional methods for hooks compatibility
|
||||
async getSupplierStatistics(): Promise<SupplierStatistics> {
|
||||
const suppliers = await this.getSuppliers();
|
||||
const activeSuppliers = suppliers.filter(s => s.status === 'active');
|
||||
const averageRating = suppliers.reduce((sum, s) => sum + (s.rating || 0), 0) / suppliers.length;
|
||||
|
||||
return {
|
||||
total_suppliers: suppliers.length,
|
||||
active_suppliers: activeSuppliers.length,
|
||||
average_rating: averageRating,
|
||||
top_performing_suppliers: suppliers.slice(0, 5).map(s => ({
|
||||
id: s.id,
|
||||
name: s.name,
|
||||
supplier_type: s.supplier_type,
|
||||
status: s.status,
|
||||
rating: s.rating,
|
||||
total_orders: 0, // Would come from backend
|
||||
total_spent: 0, // Would come from backend
|
||||
last_delivery_date: undefined
|
||||
}))
|
||||
};
|
||||
}
|
||||
|
||||
async getActiveSuppliers(): Promise<SupplierSummary[]> {
|
||||
const suppliers = await this.getSuppliers({ status: 'active' });
|
||||
return suppliers.map(s => ({
|
||||
id: s.id,
|
||||
name: s.name,
|
||||
supplier_type: s.supplier_type,
|
||||
status: s.status,
|
||||
rating: s.rating,
|
||||
total_orders: 0, // Would come from backend
|
||||
total_spent: 0, // Would come from backend
|
||||
last_delivery_date: undefined
|
||||
}));
|
||||
}
|
||||
|
||||
async getTopSuppliers(): Promise<SupplierSummary[]> {
|
||||
const suppliers = await this.getSuppliers();
|
||||
return suppliers
|
||||
.sort((a, b) => (b.rating || 0) - (a.rating || 0))
|
||||
.slice(0, 10)
|
||||
.map(s => ({
|
||||
id: s.id,
|
||||
name: s.name,
|
||||
supplier_type: s.supplier_type,
|
||||
status: s.status,
|
||||
rating: s.rating,
|
||||
total_orders: 0, // Would come from backend
|
||||
total_spent: 0, // Would come from backend
|
||||
last_delivery_date: undefined
|
||||
}));
|
||||
}
|
||||
|
||||
async getSuppliersNeedingReview(): Promise<SupplierSummary[]> {
|
||||
const suppliers = await this.getSuppliers();
|
||||
return suppliers
|
||||
.filter(s => !s.rating || s.rating < 3)
|
||||
.map(s => ({
|
||||
id: s.id,
|
||||
name: s.name,
|
||||
supplier_type: s.supplier_type,
|
||||
status: s.status,
|
||||
rating: s.rating,
|
||||
total_orders: 0, // Would come from backend
|
||||
total_spent: 0, // Would come from backend
|
||||
last_delivery_date: undefined
|
||||
}));
|
||||
}
|
||||
|
||||
// Purchase Order Management Methods
|
||||
async getPurchaseOrders(params?: PurchaseOrderSearchParams): Promise<PurchaseOrder[]> {
|
||||
return apiClient.get(`${this.basePath}/purchase-orders`, { params });
|
||||
}
|
||||
|
||||
async getPurchaseOrder(orderId: string): Promise<PurchaseOrder> {
|
||||
return apiClient.get(`${this.basePath}/purchase-orders/${orderId}`);
|
||||
}
|
||||
|
||||
async createPurchaseOrder(orderData: CreatePurchaseOrderRequest): Promise<PurchaseOrder> {
|
||||
return apiClient.post(`${this.basePath}/purchase-orders`, orderData);
|
||||
}
|
||||
|
||||
async updatePurchaseOrderStatus(orderId: string, status: string): Promise<PurchaseOrder> {
|
||||
return apiClient.put(`${this.basePath}/purchase-orders/${orderId}/status`, { status });
|
||||
}
|
||||
|
||||
async approvePurchaseOrder(orderId: string, approval: any): Promise<void> {
|
||||
return apiClient.post(`${this.basePath}/purchase-orders/${orderId}/approve`, approval);
|
||||
}
|
||||
|
||||
async sendToSupplier(orderId: string): Promise<void> {
|
||||
return apiClient.post(`${this.basePath}/purchase-orders/${orderId}/send`);
|
||||
}
|
||||
|
||||
async cancelPurchaseOrder(orderId: string, reason?: string): Promise<void> {
|
||||
return apiClient.post(`${this.basePath}/purchase-orders/${orderId}/cancel`, { reason });
|
||||
}
|
||||
|
||||
async getPurchaseOrderStatistics(): Promise<PurchaseOrderStatistics> {
|
||||
const orders = await this.getPurchaseOrders();
|
||||
return {
|
||||
total_orders: orders.length,
|
||||
total_value: orders.reduce((sum, o) => sum + o.total_amount, 0),
|
||||
pending_orders: orders.filter(o => o.status === 'pending').length,
|
||||
overdue_orders: 0, // Would calculate based on expected delivery dates
|
||||
};
|
||||
}
|
||||
|
||||
async getOrdersRequiringApproval(): Promise<PurchaseOrder[]> {
|
||||
return this.getPurchaseOrders({ status: 'pending' });
|
||||
}
|
||||
|
||||
async getOverdueOrders(): Promise<PurchaseOrder[]> {
|
||||
const today = new Date();
|
||||
const orders = await this.getPurchaseOrders();
|
||||
return orders.filter(o => new Date(o.expected_delivery) < today && o.status !== 'delivered');
|
||||
}
|
||||
|
||||
// Delivery Management Methods
|
||||
async getDeliveries(params?: DeliverySearchParams): Promise<Delivery[]> {
|
||||
return apiClient.get(`${this.basePath}/deliveries`, { params });
|
||||
}
|
||||
|
||||
async getDelivery(deliveryId: string): Promise<Delivery> {
|
||||
return apiClient.get(`${this.basePath}/deliveries/${deliveryId}`);
|
||||
}
|
||||
|
||||
async getTodaysDeliveries(): Promise<Delivery[]> {
|
||||
const today = new Date().toISOString().split('T')[0];
|
||||
return this.getDeliveries({ date_from: today, date_to: today });
|
||||
}
|
||||
|
||||
async getDeliveryPerformanceStats(): Promise<DeliveryPerformanceStats> {
|
||||
const deliveries = await this.getDeliveries();
|
||||
const onTimeCount = deliveries.filter(d => d.status === 'on_time').length;
|
||||
const totalCount = deliveries.length;
|
||||
const qualitySum = deliveries.reduce((sum, d) => sum + (d.quality_rating || 0), 0);
|
||||
|
||||
return {
|
||||
on_time_delivery_rate: totalCount > 0 ? (onTimeCount / totalCount) * 100 : 0,
|
||||
average_delay_days: 0, // Would calculate based on actual vs expected delivery
|
||||
quality_average: totalCount > 0 ? qualitySum / totalCount : 0,
|
||||
total_deliveries: totalCount,
|
||||
};
|
||||
}
|
||||
|
||||
// Additional utility methods
|
||||
async approveSupplier(supplierId: string): Promise<void> {
|
||||
await this.updateSupplier(supplierId, { status: 'active' });
|
||||
}
|
||||
}
|
||||
@@ -1,150 +0,0 @@
|
||||
// frontend/src/api/services/tenant.service.ts
|
||||
/**
|
||||
* Tenant Management Service
|
||||
* Handles all tenant-related operations
|
||||
*/
|
||||
|
||||
import { apiClient } from '../client';
|
||||
import { serviceEndpoints } from '../client/config';
|
||||
import type {
|
||||
TenantInfo,
|
||||
TenantCreate,
|
||||
TenantUpdate,
|
||||
TenantMember,
|
||||
InviteUser,
|
||||
TenantStats,
|
||||
PaginatedResponse,
|
||||
BaseQueryParams,
|
||||
} from '../types';
|
||||
|
||||
export class TenantService {
|
||||
private baseEndpoint = serviceEndpoints.tenant;
|
||||
|
||||
/**
|
||||
* Create New Tenant
|
||||
*/
|
||||
async createTenant(data: TenantCreate): Promise<TenantInfo> {
|
||||
return apiClient.post(`${this.baseEndpoint}/register`, data);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get Tenant Details
|
||||
*/
|
||||
async getTenant(tenantId: string): Promise<TenantInfo> {
|
||||
return apiClient.get(`${this.baseEndpoint}/${tenantId}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Update Tenant
|
||||
*/
|
||||
async updateTenant(tenantId: string, data: TenantUpdate): Promise<TenantInfo> {
|
||||
return apiClient.put(`${this.baseEndpoint}/${tenantId}`, data);
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete Tenant
|
||||
*/
|
||||
async deleteTenant(tenantId: string): Promise<{ message: string }> {
|
||||
return apiClient.delete(`${this.baseEndpoint}/${tenantId}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get Tenant Members
|
||||
*/
|
||||
async getTenantMembers(
|
||||
tenantId: string,
|
||||
params?: BaseQueryParams
|
||||
): Promise<PaginatedResponse<TenantMember>> {
|
||||
return apiClient.get(`${this.baseEndpoint}/${tenantId}/members`, { params });
|
||||
}
|
||||
|
||||
/**
|
||||
* Invite User to Tenant
|
||||
*/
|
||||
async inviteUser(tenantId: string, invitation: InviteUser): Promise<{ message: string }> {
|
||||
return apiClient.post(`${this.baseEndpoint}/${tenantId}/invite`, invitation);
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove Member from Tenant
|
||||
*/
|
||||
async removeMember(tenantId: string, userId: string): Promise<{ message: string }> {
|
||||
return apiClient.delete(`${this.baseEndpoint}/${tenantId}/members/${userId}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Update Member Role
|
||||
*/
|
||||
async updateMemberRole(
|
||||
tenantId: string,
|
||||
userId: string,
|
||||
role: string
|
||||
): Promise<TenantMember> {
|
||||
return apiClient.patch(`${this.baseEndpoint}/${tenantId}/members/${userId}`, { role });
|
||||
}
|
||||
|
||||
/**
|
||||
* Get Tenant Statistics
|
||||
*/
|
||||
async getTenantStats(tenantId: string): Promise<TenantStats> {
|
||||
return apiClient.get(`${this.baseEndpoint}/${tenantId}/stats`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get User's Tenants - Get tenants where user is owner
|
||||
*/
|
||||
async getUserTenants(): Promise<TenantInfo[]> {
|
||||
try {
|
||||
// Extract user ID from the JWT token in localStorage
|
||||
const token = localStorage.getItem('auth_token');
|
||||
console.log('🔑 TenantService: Auth token present:', !!token);
|
||||
|
||||
if (!token) {
|
||||
throw new Error('No auth token found');
|
||||
}
|
||||
|
||||
// Decode JWT to get user ID (simple base64 decode)
|
||||
const payload = JSON.parse(atob(token.split('.')[1]));
|
||||
const userId = payload.user_id || payload.sub;
|
||||
console.log('👤 TenantService: Extracted user ID:', userId);
|
||||
|
||||
if (!userId) {
|
||||
throw new Error('No user ID found in token');
|
||||
}
|
||||
|
||||
// Get tenants owned by this user
|
||||
const url = `${this.baseEndpoint}/user/${userId}/owned`;
|
||||
console.log('🌐 TenantService: Making request to:', url);
|
||||
|
||||
const result = await apiClient.get(url);
|
||||
console.log('📦 TenantService: API response:', result);
|
||||
console.log('📏 TenantService: Response length:', Array.isArray(result) ? result.length : 'Not an array');
|
||||
|
||||
// Ensure we always return an array
|
||||
if (!Array.isArray(result)) {
|
||||
console.warn('⚠️ TenantService: Response is not an array, converting...');
|
||||
// If it's an object with numeric keys, convert to array
|
||||
if (result && typeof result === 'object') {
|
||||
const converted = Object.values(result);
|
||||
console.log('🔄 TenantService: Converted to array:', converted);
|
||||
return converted as TenantInfo[];
|
||||
}
|
||||
console.log('🔄 TenantService: Returning empty array');
|
||||
return [];
|
||||
}
|
||||
|
||||
if (result.length > 0) {
|
||||
console.log('✅ TenantService: First tenant:', result[0]);
|
||||
console.log('🆔 TenantService: First tenant ID:', result[0]?.id);
|
||||
}
|
||||
|
||||
return result;
|
||||
} catch (error) {
|
||||
console.error('❌ TenantService: Failed to get user tenants:', error);
|
||||
// Return empty array if API call fails
|
||||
return [];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const tenantService = new TenantService();
|
||||
@@ -1,161 +0,0 @@
|
||||
// frontend/src/api/services/training.service.ts
|
||||
/**
|
||||
* Training Service
|
||||
* Handles ML model training operations
|
||||
*/
|
||||
|
||||
import { apiClient } from '../client';
|
||||
import { RequestTimeouts } from '../client/config';
|
||||
import type {
|
||||
TrainingJobRequest,
|
||||
TrainingJobResponse,
|
||||
SingleProductTrainingRequest,
|
||||
ModelInfo,
|
||||
ModelTrainingStats,
|
||||
PaginatedResponse,
|
||||
BaseQueryParams,
|
||||
} from '../types';
|
||||
|
||||
export class TrainingService {
|
||||
/**
|
||||
* Start Training Job for All Products
|
||||
*/
|
||||
async startTrainingJob(
|
||||
tenantId: string,
|
||||
request: TrainingJobRequest
|
||||
): Promise<TrainingJobResponse> {
|
||||
return apiClient.post(
|
||||
`/tenants/${tenantId}/training/jobs`,
|
||||
request,
|
||||
{
|
||||
timeout: RequestTimeouts.EXTENDED,
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Start Training for Single Product
|
||||
*/
|
||||
async startSingleProductTraining(
|
||||
tenantId: string,
|
||||
request: SingleProductTrainingRequest
|
||||
): Promise<TrainingJobResponse> {
|
||||
return apiClient.post(
|
||||
`/tenants/${tenantId}/training/single`,
|
||||
request,
|
||||
{
|
||||
timeout: RequestTimeouts.EXTENDED,
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get Training Job Status
|
||||
*/
|
||||
async getTrainingJobStatus(tenantId: string, jobId: string): Promise<TrainingJobResponse> {
|
||||
return apiClient.get(`/tenants/${tenantId}/training/jobs/${jobId}/status`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get Training Job Logs
|
||||
*/
|
||||
async getTrainingJobLogs(tenantId: string, jobId: string): Promise<{ logs: string[] }> {
|
||||
return apiClient.get(`/tenants/${tenantId}/training/jobs/${jobId}/logs`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Cancel Training Job
|
||||
*/
|
||||
async cancelTrainingJob(tenantId: string, jobId: string): Promise<{ message: string }> {
|
||||
return apiClient.post(`/tenants/${tenantId}/training/jobs/${jobId}/cancel`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get Training Jobs
|
||||
*/
|
||||
async getTrainingJobs(
|
||||
tenantId: string,
|
||||
params?: BaseQueryParams & {
|
||||
status?: string;
|
||||
start_date?: string;
|
||||
end_date?: string;
|
||||
}
|
||||
): Promise<PaginatedResponse<TrainingJobResponse>> {
|
||||
return apiClient.get(`/tenants/${tenantId}/training/jobs`, { params });
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate Data for Training
|
||||
*/
|
||||
async validateTrainingData(tenantId: string): Promise<{
|
||||
is_valid: boolean;
|
||||
message: string;
|
||||
details?: any;
|
||||
}> {
|
||||
return apiClient.post(`/tenants/${tenantId}/training/validate`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get Trained Models
|
||||
*/
|
||||
async getModels(
|
||||
tenantId: string,
|
||||
params?: BaseQueryParams & {
|
||||
inventory_product_id?: string; // Primary way to filter by product
|
||||
product_name?: string; // For backward compatibility - will need inventory service lookup
|
||||
is_active?: boolean;
|
||||
}
|
||||
): Promise<PaginatedResponse<ModelInfo>> {
|
||||
return apiClient.get(`/tenants/${tenantId}/models`, { params });
|
||||
}
|
||||
|
||||
/**
|
||||
* Get Model Details
|
||||
*/
|
||||
async getModel(tenantId: string, modelId: string): Promise<ModelInfo> {
|
||||
return apiClient.get(`/tenants/${tenantId}/models/${modelId}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Update Model Status
|
||||
*/
|
||||
async updateModelStatus(
|
||||
tenantId: string,
|
||||
modelId: string,
|
||||
isActive: boolean
|
||||
): Promise<ModelInfo> {
|
||||
return apiClient.patch(`/tenants/${tenantId}/models/${modelId}`, {
|
||||
is_active: isActive,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete Model
|
||||
*/
|
||||
async deleteModel(tenantId: string, modelId: string): Promise<{ message: string }> {
|
||||
return apiClient.delete(`/tenants/${tenantId}/models/${modelId}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get Training Statistics
|
||||
*/
|
||||
async getTrainingStats(tenantId: string): Promise<ModelTrainingStats> {
|
||||
return apiClient.get(`/tenants/${tenantId}/training/stats`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Download Model File
|
||||
*/
|
||||
async downloadModel(tenantId: string, modelId: string): Promise<Blob> {
|
||||
const response = await apiClient.request(`/tenants/${tenantId}/models/${modelId}/download`, {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
'Accept': 'application/octet-stream',
|
||||
},
|
||||
});
|
||||
|
||||
return new Blob([response]);
|
||||
}
|
||||
}
|
||||
|
||||
export const trainingService = new TrainingService();
|
||||
@@ -1,94 +0,0 @@
|
||||
// frontend/src/api/types/auth.ts
|
||||
/**
|
||||
* Authentication Types
|
||||
*/
|
||||
|
||||
export interface UserData {
|
||||
id: string;
|
||||
email: string;
|
||||
full_name: string;
|
||||
is_active: boolean;
|
||||
is_verified: boolean;
|
||||
created_at: string;
|
||||
last_login?: string;
|
||||
phone?: string;
|
||||
language?: string;
|
||||
timezone?: string;
|
||||
tenant_id?: string;
|
||||
role: string;
|
||||
}
|
||||
|
||||
export interface User {
|
||||
id: string;
|
||||
email: string;
|
||||
fullName: string;
|
||||
role: "owner" | "admin" | "manager" | "worker";
|
||||
isOnboardingComplete: boolean;
|
||||
tenant_id: string;
|
||||
created_at?: string;
|
||||
last_login?: string;
|
||||
}
|
||||
|
||||
export interface LoginRequest {
|
||||
email: string;
|
||||
password: string;
|
||||
}
|
||||
|
||||
export interface RegisterRequest {
|
||||
email: string;
|
||||
password: string;
|
||||
full_name: string;
|
||||
role?: string;
|
||||
phone?: string;
|
||||
language?: string;
|
||||
}
|
||||
|
||||
export interface LoginResponse {
|
||||
access_token: string;
|
||||
refresh_token?: string;
|
||||
token_type: string;
|
||||
expires_in: number;
|
||||
user?: UserData;
|
||||
}
|
||||
|
||||
export interface UserResponse {
|
||||
id: string;
|
||||
email: string;
|
||||
full_name: string;
|
||||
is_active: boolean;
|
||||
is_verified: boolean;
|
||||
created_at: string;
|
||||
last_login?: string;
|
||||
phone?: string;
|
||||
language?: string;
|
||||
timezone?: string;
|
||||
tenant_id?: string;
|
||||
role?: string;
|
||||
}
|
||||
|
||||
export interface TokenVerification {
|
||||
valid: boolean;
|
||||
user_id?: string;
|
||||
email?: string;
|
||||
exp?: number;
|
||||
message?: string;
|
||||
}
|
||||
|
||||
export interface PasswordResetRequest {
|
||||
email: string;
|
||||
}
|
||||
|
||||
export interface PasswordResetResponse {
|
||||
message: string;
|
||||
reset_token?: string;
|
||||
}
|
||||
|
||||
export interface PasswordResetConfirmRequest {
|
||||
token: string;
|
||||
new_password: string;
|
||||
}
|
||||
|
||||
export interface LogoutResponse {
|
||||
message: string;
|
||||
success: boolean;
|
||||
}
|
||||
@@ -1,54 +0,0 @@
|
||||
// frontend/src/api/types/common.ts
|
||||
/**
|
||||
* Common API Types
|
||||
*/
|
||||
|
||||
export interface ApiResponse<T = any> {
|
||||
data?: T;
|
||||
message?: string;
|
||||
status: string;
|
||||
timestamp?: string;
|
||||
meta?: PaginationMeta;
|
||||
}
|
||||
|
||||
export interface ApiError {
|
||||
detail: string;
|
||||
service?: string;
|
||||
error_code?: string;
|
||||
timestamp?: string;
|
||||
field?: string;
|
||||
}
|
||||
|
||||
export interface PaginationMeta {
|
||||
page: number;
|
||||
limit: number;
|
||||
total: number;
|
||||
totalPages: number;
|
||||
hasNext: boolean;
|
||||
hasPrev: boolean;
|
||||
}
|
||||
|
||||
export interface PaginatedResponse<T> {
|
||||
data: T[];
|
||||
pagination: PaginationMeta;
|
||||
}
|
||||
|
||||
export interface BaseQueryParams {
|
||||
page?: number;
|
||||
limit?: number;
|
||||
search?: string;
|
||||
sort?: string;
|
||||
order?: 'asc' | 'desc';
|
||||
}
|
||||
|
||||
export interface CreateResponse<T = any> {
|
||||
data: T;
|
||||
message?: string;
|
||||
status: string;
|
||||
}
|
||||
|
||||
export interface UpdateResponse<T = any> {
|
||||
data: T;
|
||||
message?: string;
|
||||
status: string;
|
||||
}
|
||||
@@ -1,148 +0,0 @@
|
||||
// frontend/src/api/types/data.ts
|
||||
/**
|
||||
* Data Management Types
|
||||
*/
|
||||
|
||||
import { BaseQueryParams } from './common';
|
||||
|
||||
export interface ProductInfo {
|
||||
inventory_product_id: string;
|
||||
name: string;
|
||||
category?: string;
|
||||
sales_count?: number;
|
||||
total_quantity?: number;
|
||||
last_sale_date?: string;
|
||||
// Additional inventory fields
|
||||
current_stock?: number;
|
||||
unit?: string;
|
||||
cost_per_unit?: number;
|
||||
}
|
||||
|
||||
export interface SalesData {
|
||||
id: string;
|
||||
tenant_id: string;
|
||||
date: string;
|
||||
inventory_product_id: string; // Reference to inventory service product
|
||||
// Note: product_name now needs to be fetched from inventory service using inventory_product_id
|
||||
product_name?: string; // Optional - for backward compatibility, populated by frontend logic
|
||||
category?: string; // Optional - fetched from inventory service
|
||||
quantity: number;
|
||||
unit_price: number;
|
||||
total_revenue: number;
|
||||
location_id?: string;
|
||||
source: string;
|
||||
created_at: string;
|
||||
external_factors?: ExternalFactors;
|
||||
// Additional properties used by components
|
||||
sales_channel?: string;
|
||||
is_validated?: boolean;
|
||||
cost_of_goods?: number;
|
||||
revenue?: number;
|
||||
quantity_sold?: number;
|
||||
discount_applied?: number;
|
||||
weather_condition?: string;
|
||||
}
|
||||
|
||||
export interface SalesValidationResult {
|
||||
is_valid: boolean;
|
||||
total_records: number;
|
||||
valid_records: number;
|
||||
invalid_records: number;
|
||||
errors: ValidationError[];
|
||||
warnings: ValidationError[];
|
||||
summary: Record<string, any>;
|
||||
message?: string;
|
||||
details?: Record<string, any>;
|
||||
}
|
||||
|
||||
export interface ExternalFactors {
|
||||
weather_temperature?: number;
|
||||
weather_precipitation?: number;
|
||||
weather_description?: string;
|
||||
traffic_volume?: number;
|
||||
is_holiday?: boolean;
|
||||
is_weekend?: boolean;
|
||||
day_of_week?: number;
|
||||
}
|
||||
|
||||
export interface SalesDataQuery extends BaseQueryParams {
|
||||
tenant_id: string;
|
||||
start_date?: string;
|
||||
end_date?: string;
|
||||
// Note: product_names filtering now requires inventory service integration or use inventory_product_ids
|
||||
product_names?: string[]; // For backward compatibility - will need inventory service lookup
|
||||
inventory_product_ids?: string[]; // Primary way to filter by products
|
||||
location_ids?: string[];
|
||||
sources?: string[];
|
||||
min_quantity?: number;
|
||||
max_quantity?: number;
|
||||
min_revenue?: number;
|
||||
max_revenue?: number;
|
||||
search_term?: string;
|
||||
sales_channel?: string;
|
||||
inventory_product_id?: string; // Single product filter
|
||||
is_validated?: boolean;
|
||||
}
|
||||
|
||||
export interface SalesDataImport {
|
||||
tenant_id?: string;
|
||||
data: string;
|
||||
data_format: 'csv' | 'json' | 'excel';
|
||||
source?: string;
|
||||
validate_only?: boolean;
|
||||
}
|
||||
|
||||
export interface SalesImportResult {
|
||||
success: boolean;
|
||||
message: string;
|
||||
imported_count: number;
|
||||
records_imported: number;
|
||||
skipped_count: number;
|
||||
error_count: number;
|
||||
validation_errors?: ValidationError[];
|
||||
preview_data?: SalesData[];
|
||||
file_info?: FileInfo;
|
||||
}
|
||||
|
||||
export interface ValidationError {
|
||||
row: number;
|
||||
field: string;
|
||||
value: any;
|
||||
message: string;
|
||||
}
|
||||
|
||||
export interface FileInfo {
|
||||
filename: string;
|
||||
size: number;
|
||||
rows: number;
|
||||
format: string;
|
||||
}
|
||||
|
||||
export interface DashboardStats {
|
||||
total_sales: number;
|
||||
total_revenue: number;
|
||||
total_products: number;
|
||||
date_range: {
|
||||
start: string;
|
||||
end: string;
|
||||
};
|
||||
top_products: ProductStats[];
|
||||
recent_activity: ActivityItem[];
|
||||
}
|
||||
|
||||
export interface ProductStats {
|
||||
inventory_product_id: string; // Reference to inventory service product
|
||||
product_name?: string; // Optional - for display, populated by frontend from inventory service
|
||||
total_quantity: number;
|
||||
total_revenue: number;
|
||||
avg_price: number;
|
||||
sales_trend: number;
|
||||
}
|
||||
|
||||
export interface ActivityItem {
|
||||
id: string;
|
||||
type: string;
|
||||
description: string;
|
||||
timestamp: string;
|
||||
metadata?: Record<string, any>;
|
||||
}
|
||||
@@ -1,89 +0,0 @@
|
||||
// frontend/src/api/types/forecasting.ts
|
||||
/**
|
||||
* Forecasting Service Types
|
||||
*/
|
||||
|
||||
import { ExternalFactors } from './data';
|
||||
|
||||
export interface SingleForecastRequest {
|
||||
inventory_product_id: string;
|
||||
forecast_date: string;
|
||||
forecast_days: number;
|
||||
location: string;
|
||||
include_external_factors?: boolean;
|
||||
confidence_intervals?: boolean;
|
||||
// Note: confidence_level is handled internally by backend (0.8 default)
|
||||
}
|
||||
|
||||
export interface BatchForecastRequest {
|
||||
inventory_product_ids?: string[]; // Primary way to specify products
|
||||
product_names?: string[]; // For backward compatibility - will need inventory service lookup
|
||||
forecast_date: string;
|
||||
forecast_days: number;
|
||||
location: string;
|
||||
include_external_factors?: boolean;
|
||||
confidence_intervals?: boolean;
|
||||
batch_name?: string;
|
||||
}
|
||||
|
||||
export interface ForecastResponse {
|
||||
id: string;
|
||||
tenant_id: string;
|
||||
inventory_product_id: string;
|
||||
product_name?: string; // Optional - for display, populated by frontend from inventory service
|
||||
forecast_date: string;
|
||||
predicted_demand: number;
|
||||
confidence_lower?: number;
|
||||
confidence_upper?: number;
|
||||
model_id: string;
|
||||
confidence_level?: number;
|
||||
external_factors?: ExternalFactors;
|
||||
created_at: string;
|
||||
processing_time_ms?: number;
|
||||
features_used?: Record<string, any>;
|
||||
}
|
||||
|
||||
export interface BatchForecastResponse {
|
||||
id: string;
|
||||
tenant_id: string;
|
||||
batch_name: string;
|
||||
status: BatchForecastStatus;
|
||||
total_products: number;
|
||||
completed_products: number;
|
||||
failed_products: number;
|
||||
requested_at: string;
|
||||
completed_at?: string;
|
||||
processing_time_ms?: number;
|
||||
forecasts?: ForecastResponse[];
|
||||
error_message?: string;
|
||||
}
|
||||
|
||||
export type BatchForecastStatus =
|
||||
| 'pending'
|
||||
| 'processing'
|
||||
| 'completed'
|
||||
| 'failed'
|
||||
| 'cancelled';
|
||||
|
||||
export interface ForecastAlert {
|
||||
id: string;
|
||||
tenant_id: string;
|
||||
forecast_id: string;
|
||||
alert_type: string;
|
||||
severity: 'low' | 'medium' | 'high' | 'critical';
|
||||
message: string;
|
||||
is_active: boolean;
|
||||
created_at: string;
|
||||
acknowledged_at?: string;
|
||||
notification_sent: boolean;
|
||||
}
|
||||
|
||||
export interface QuickForecast {
|
||||
inventory_product_id: string;
|
||||
product_name?: string; // Optional - for display, populated by frontend from inventory service
|
||||
next_day_prediction: number;
|
||||
next_week_avg: number;
|
||||
trend_direction: 'up' | 'down' | 'stable';
|
||||
confidence_score: number;
|
||||
last_updated: string;
|
||||
}
|
||||
@@ -1,14 +0,0 @@
|
||||
// frontend/src/api/types/index.ts
|
||||
/**
|
||||
* Main Types Export
|
||||
*/
|
||||
|
||||
// Re-export all types
|
||||
export * from './common';
|
||||
export * from './auth';
|
||||
export * from './tenant';
|
||||
export * from './data';
|
||||
export * from './training';
|
||||
export * from './forecasting';
|
||||
export * from './notification';
|
||||
export * from './procurement';
|
||||
@@ -1,108 +0,0 @@
|
||||
// frontend/src/api/types/notification.ts
|
||||
/**
|
||||
* Notification Service Types
|
||||
*/
|
||||
|
||||
export interface NotificationCreate {
|
||||
recipient_id?: string;
|
||||
recipient_email?: string;
|
||||
recipient_phone?: string;
|
||||
channel: NotificationChannel;
|
||||
template_id?: string;
|
||||
subject?: string;
|
||||
message: string;
|
||||
data?: Record<string, any>;
|
||||
scheduled_for?: string;
|
||||
priority?: NotificationPriority;
|
||||
}
|
||||
|
||||
export interface NotificationResponse {
|
||||
id: string;
|
||||
tenant_id: string;
|
||||
recipient_id?: string;
|
||||
recipient_email?: string;
|
||||
recipient_phone?: string;
|
||||
channel: NotificationChannel;
|
||||
template_id?: string;
|
||||
subject?: string;
|
||||
message: string;
|
||||
status: NotificationStatus;
|
||||
priority: NotificationPriority;
|
||||
data?: Record<string, any>;
|
||||
scheduled_for?: string;
|
||||
sent_at?: string;
|
||||
delivered_at?: string;
|
||||
failed_at?: string;
|
||||
error_message?: string;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
export type NotificationChannel = 'email' | 'whatsapp' | 'push' | 'sms';
|
||||
export type NotificationStatus =
|
||||
| 'pending'
|
||||
| 'scheduled'
|
||||
| 'sent'
|
||||
| 'delivered'
|
||||
| 'failed'
|
||||
| 'cancelled';
|
||||
export type NotificationPriority = 'low' | 'normal' | 'high' | 'urgent';
|
||||
|
||||
export interface NotificationTemplate {
|
||||
id: string;
|
||||
tenant_id: string;
|
||||
name: string;
|
||||
channel: NotificationChannel;
|
||||
subject_template?: string;
|
||||
body_template: string;
|
||||
variables: string[];
|
||||
is_system: boolean;
|
||||
is_active: boolean;
|
||||
created_at: string;
|
||||
updated_at?: string;
|
||||
}
|
||||
|
||||
export interface NotificationHistory {
|
||||
id: string;
|
||||
notification_id: string;
|
||||
status: NotificationStatus;
|
||||
timestamp: string;
|
||||
details?: string;
|
||||
metadata?: Record<string, any>;
|
||||
}
|
||||
|
||||
export interface NotificationStats {
|
||||
total_sent: number;
|
||||
total_delivered: number;
|
||||
total_failed: number;
|
||||
delivery_rate: number;
|
||||
channels_breakdown: Record<NotificationChannel, number>;
|
||||
recent_activity: NotificationResponse[];
|
||||
}
|
||||
|
||||
export interface BulkNotificationRequest {
|
||||
recipients: BulkRecipient[];
|
||||
channel: NotificationChannel;
|
||||
template_id?: string;
|
||||
subject?: string;
|
||||
message: string;
|
||||
scheduled_for?: string;
|
||||
priority?: NotificationPriority;
|
||||
}
|
||||
|
||||
export interface BulkRecipient {
|
||||
recipient_id?: string;
|
||||
recipient_email?: string;
|
||||
recipient_phone?: string;
|
||||
data?: Record<string, any>;
|
||||
}
|
||||
|
||||
export interface BulkNotificationStatus {
|
||||
batch_id: string;
|
||||
total_recipients: number;
|
||||
sent_count: number;
|
||||
failed_count: number;
|
||||
pending_count: number;
|
||||
status: 'processing' | 'completed' | 'failed';
|
||||
created_at: string;
|
||||
completed_at?: string;
|
||||
}
|
||||
@@ -1,330 +0,0 @@
|
||||
// ================================================================
|
||||
// frontend/src/api/types/procurement.ts
|
||||
// ================================================================
|
||||
/**
|
||||
* TypeScript types for procurement planning API
|
||||
*/
|
||||
|
||||
// ================================================================
|
||||
// BASE TYPES
|
||||
// ================================================================
|
||||
|
||||
export interface ProcurementRequirement {
|
||||
id: string;
|
||||
plan_id: string;
|
||||
requirement_number: string;
|
||||
|
||||
// Product information
|
||||
product_id: string;
|
||||
product_name: string;
|
||||
product_sku?: string;
|
||||
product_category?: string;
|
||||
product_type: string;
|
||||
|
||||
// Quantity requirements
|
||||
required_quantity: number;
|
||||
unit_of_measure: string;
|
||||
safety_stock_quantity: number;
|
||||
total_quantity_needed: number;
|
||||
|
||||
// Current inventory situation
|
||||
current_stock_level: number;
|
||||
reserved_stock: number;
|
||||
available_stock: number;
|
||||
net_requirement: number;
|
||||
|
||||
// Demand breakdown
|
||||
order_demand: number;
|
||||
production_demand: number;
|
||||
forecast_demand: number;
|
||||
buffer_demand: number;
|
||||
|
||||
// Supplier information
|
||||
preferred_supplier_id?: string;
|
||||
backup_supplier_id?: string;
|
||||
supplier_name?: string;
|
||||
supplier_lead_time_days?: number;
|
||||
minimum_order_quantity?: number;
|
||||
|
||||
// Pricing
|
||||
estimated_unit_cost?: number;
|
||||
estimated_total_cost?: number;
|
||||
last_purchase_cost?: number;
|
||||
cost_variance: number;
|
||||
|
||||
// Timing
|
||||
required_by_date: string;
|
||||
lead_time_buffer_days: number;
|
||||
suggested_order_date: string;
|
||||
latest_order_date: string;
|
||||
|
||||
// Status and priority
|
||||
status: string;
|
||||
priority: string;
|
||||
risk_level: string;
|
||||
|
||||
// Purchase tracking
|
||||
purchase_order_id?: string;
|
||||
purchase_order_number?: string;
|
||||
ordered_quantity: number;
|
||||
ordered_at?: string;
|
||||
|
||||
// Delivery tracking
|
||||
expected_delivery_date?: string;
|
||||
actual_delivery_date?: string;
|
||||
received_quantity: number;
|
||||
delivery_status: string;
|
||||
|
||||
// Performance metrics
|
||||
fulfillment_rate?: number;
|
||||
on_time_delivery?: boolean;
|
||||
quality_rating?: number;
|
||||
|
||||
// Approval
|
||||
approved_quantity?: number;
|
||||
approved_cost?: number;
|
||||
approved_at?: string;
|
||||
approved_by?: string;
|
||||
|
||||
// Additional info
|
||||
special_requirements?: string;
|
||||
storage_requirements?: string;
|
||||
shelf_life_days?: number;
|
||||
quality_specifications?: Record<string, any>;
|
||||
procurement_notes?: string;
|
||||
}
|
||||
|
||||
export interface ProcurementPlan {
|
||||
id: string;
|
||||
tenant_id: string;
|
||||
plan_number: string;
|
||||
|
||||
// Plan scope and timing
|
||||
plan_date: string;
|
||||
plan_period_start: string;
|
||||
plan_period_end: string;
|
||||
planning_horizon_days: number;
|
||||
|
||||
// Plan status and lifecycle
|
||||
status: string;
|
||||
plan_type: string;
|
||||
priority: string;
|
||||
|
||||
// Business context
|
||||
business_model?: string;
|
||||
procurement_strategy: string;
|
||||
|
||||
// Plan totals and summary
|
||||
total_requirements: number;
|
||||
total_estimated_cost: number;
|
||||
total_approved_cost: number;
|
||||
cost_variance: number;
|
||||
|
||||
// Demand analysis
|
||||
total_demand_orders: number;
|
||||
total_demand_quantity: number;
|
||||
total_production_requirements: number;
|
||||
safety_stock_buffer: number;
|
||||
|
||||
// Supplier coordination
|
||||
primary_suppliers_count: number;
|
||||
backup_suppliers_count: number;
|
||||
supplier_diversification_score?: number;
|
||||
|
||||
// Risk assessment
|
||||
supply_risk_level: string;
|
||||
demand_forecast_confidence?: number;
|
||||
seasonality_adjustment: number;
|
||||
|
||||
// Execution tracking
|
||||
approved_at?: string;
|
||||
approved_by?: string;
|
||||
execution_started_at?: string;
|
||||
execution_completed_at?: string;
|
||||
|
||||
// Performance metrics
|
||||
fulfillment_rate?: number;
|
||||
on_time_delivery_rate?: number;
|
||||
cost_accuracy?: number;
|
||||
quality_score?: number;
|
||||
|
||||
// Metadata
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
created_by?: string;
|
||||
updated_by?: string;
|
||||
|
||||
// Additional info
|
||||
special_requirements?: string;
|
||||
|
||||
// Relationships
|
||||
requirements: ProcurementRequirement[];
|
||||
}
|
||||
|
||||
// ================================================================
|
||||
// REQUEST TYPES
|
||||
// ================================================================
|
||||
|
||||
export interface GeneratePlanRequest {
|
||||
plan_date?: string;
|
||||
force_regenerate?: boolean;
|
||||
planning_horizon_days?: number;
|
||||
include_safety_stock?: boolean;
|
||||
safety_stock_percentage?: number;
|
||||
}
|
||||
|
||||
export interface ForecastRequest {
|
||||
target_date: string;
|
||||
horizon_days?: number;
|
||||
include_confidence_intervals?: boolean;
|
||||
product_ids?: string[];
|
||||
}
|
||||
|
||||
// ================================================================
|
||||
// RESPONSE TYPES
|
||||
// ================================================================
|
||||
|
||||
export interface GeneratePlanResponse {
|
||||
success: boolean;
|
||||
message: string;
|
||||
plan?: ProcurementPlan;
|
||||
warnings?: string[];
|
||||
errors?: string[];
|
||||
}
|
||||
|
||||
export interface PaginatedProcurementPlans {
|
||||
plans: ProcurementPlan[];
|
||||
total: number;
|
||||
page: number;
|
||||
limit: number;
|
||||
has_more: boolean;
|
||||
}
|
||||
|
||||
// ================================================================
|
||||
// DASHBOARD TYPES
|
||||
// ================================================================
|
||||
|
||||
export interface ProcurementSummary {
|
||||
total_plans: number;
|
||||
active_plans: number;
|
||||
total_requirements: number;
|
||||
pending_requirements: number;
|
||||
critical_requirements: number;
|
||||
|
||||
total_estimated_cost: number;
|
||||
total_approved_cost: number;
|
||||
cost_variance: number;
|
||||
|
||||
average_fulfillment_rate?: number;
|
||||
average_on_time_delivery?: number;
|
||||
|
||||
top_suppliers: Array<Record<string, any>>;
|
||||
critical_items: Array<Record<string, any>>;
|
||||
}
|
||||
|
||||
export interface DashboardData {
|
||||
current_plan?: ProcurementPlan;
|
||||
summary: ProcurementSummary;
|
||||
|
||||
upcoming_deliveries: Array<Record<string, any>>;
|
||||
overdue_requirements: Array<Record<string, any>>;
|
||||
low_stock_alerts: Array<Record<string, any>>;
|
||||
|
||||
performance_metrics: Record<string, any>;
|
||||
}
|
||||
|
||||
// ================================================================
|
||||
// FILTER AND SEARCH TYPES
|
||||
// ================================================================
|
||||
|
||||
export interface ProcurementFilters {
|
||||
status?: string[];
|
||||
priority?: string[];
|
||||
risk_level?: string[];
|
||||
supplier_id?: string;
|
||||
product_category?: string;
|
||||
date_range?: {
|
||||
start: string;
|
||||
end: string;
|
||||
};
|
||||
}
|
||||
|
||||
export interface RequirementFilters {
|
||||
status?: string[];
|
||||
priority?: string[];
|
||||
product_type?: string[];
|
||||
overdue_only?: boolean;
|
||||
critical_only?: boolean;
|
||||
}
|
||||
|
||||
// ================================================================
|
||||
// UI COMPONENT TYPES
|
||||
// ================================================================
|
||||
|
||||
export interface ProcurementPlanCardProps {
|
||||
plan: ProcurementPlan;
|
||||
onViewDetails?: (planId: string) => void;
|
||||
onUpdateStatus?: (planId: string, status: string) => void;
|
||||
showActions?: boolean;
|
||||
}
|
||||
|
||||
export interface RequirementCardProps {
|
||||
requirement: ProcurementRequirement;
|
||||
onViewDetails?: (requirementId: string) => void;
|
||||
onUpdateStatus?: (requirementId: string, status: string) => void;
|
||||
showSupplierInfo?: boolean;
|
||||
}
|
||||
|
||||
export interface ProcurementDashboardProps {
|
||||
showFilters?: boolean;
|
||||
refreshInterval?: number;
|
||||
onPlanGenerated?: (plan: ProcurementPlan) => void;
|
||||
}
|
||||
|
||||
// ================================================================
|
||||
// ENUMS
|
||||
// ================================================================
|
||||
|
||||
export enum PlanStatus {
|
||||
DRAFT = 'draft',
|
||||
PENDING_APPROVAL = 'pending_approval',
|
||||
APPROVED = 'approved',
|
||||
IN_EXECUTION = 'in_execution',
|
||||
COMPLETED = 'completed',
|
||||
CANCELLED = 'cancelled'
|
||||
}
|
||||
|
||||
export enum RequirementStatus {
|
||||
PENDING = 'pending',
|
||||
APPROVED = 'approved',
|
||||
ORDERED = 'ordered',
|
||||
PARTIALLY_RECEIVED = 'partially_received',
|
||||
RECEIVED = 'received',
|
||||
CANCELLED = 'cancelled'
|
||||
}
|
||||
|
||||
export enum Priority {
|
||||
CRITICAL = 'critical',
|
||||
HIGH = 'high',
|
||||
NORMAL = 'normal',
|
||||
LOW = 'low'
|
||||
}
|
||||
|
||||
export enum RiskLevel {
|
||||
LOW = 'low',
|
||||
MEDIUM = 'medium',
|
||||
HIGH = 'high',
|
||||
CRITICAL = 'critical'
|
||||
}
|
||||
|
||||
export enum PlanType {
|
||||
REGULAR = 'regular',
|
||||
EMERGENCY = 'emergency',
|
||||
SEASONAL = 'seasonal'
|
||||
}
|
||||
|
||||
export enum ProductType {
|
||||
INGREDIENT = 'ingredient',
|
||||
PACKAGING = 'packaging',
|
||||
SUPPLIES = 'supplies'
|
||||
}
|
||||
@@ -1,143 +0,0 @@
|
||||
// frontend/src/api/types/tenant.ts
|
||||
/**
|
||||
* Tenant Management Types
|
||||
*/
|
||||
|
||||
export interface TenantInfo {
|
||||
id: string;
|
||||
name: string;
|
||||
description?: string;
|
||||
owner_id: string;
|
||||
is_active: boolean;
|
||||
created_at: string;
|
||||
updated_at?: string;
|
||||
settings?: TenantSettings;
|
||||
subscription?: TenantSubscription;
|
||||
location?: TenantLocation;
|
||||
business_type?: 'bakery' | 'coffee_shop' | 'pastry_shop' | 'restaurant';
|
||||
business_model?: 'individual_bakery' | 'central_baker_satellite' | 'retail_bakery' | 'hybrid_bakery';
|
||||
// Added properties for compatibility
|
||||
address?: string;
|
||||
products?: any[];
|
||||
}
|
||||
|
||||
export interface Tenant {
|
||||
id: string;
|
||||
name: string;
|
||||
business_type: string;
|
||||
address: string;
|
||||
products: any[];
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
owner_id: string;
|
||||
}
|
||||
|
||||
export interface TenantSettings {
|
||||
language: string;
|
||||
timezone: string;
|
||||
currency: string;
|
||||
date_format: string;
|
||||
notification_preferences: Record<string, boolean>;
|
||||
business_hours: BusinessHours;
|
||||
operating_hours?: BusinessHours;
|
||||
}
|
||||
|
||||
export interface BusinessHours {
|
||||
monday: DaySchedule;
|
||||
tuesday: DaySchedule;
|
||||
wednesday: DaySchedule;
|
||||
thursday: DaySchedule;
|
||||
friday: DaySchedule;
|
||||
saturday: DaySchedule;
|
||||
sunday: DaySchedule;
|
||||
}
|
||||
|
||||
export interface DaySchedule {
|
||||
open: string;
|
||||
close: string;
|
||||
closed: boolean;
|
||||
}
|
||||
|
||||
export interface TenantLocation {
|
||||
address: string;
|
||||
city: string;
|
||||
country: string;
|
||||
postal_code: string;
|
||||
latitude?: number;
|
||||
longitude?: number;
|
||||
}
|
||||
|
||||
export interface TenantSubscription {
|
||||
plan: string;
|
||||
status: string;
|
||||
billing_cycle: string;
|
||||
current_period_start: string;
|
||||
current_period_end: string;
|
||||
cancel_at_period_end: boolean;
|
||||
}
|
||||
|
||||
export interface TenantCreate {
|
||||
name: string;
|
||||
address?: string;
|
||||
business_type?: 'bakery' | 'coffee_shop' | 'pastry_shop' | 'restaurant';
|
||||
business_model?: 'individual_bakery' | 'central_baker_satellite' | 'retail_bakery' | 'hybrid_bakery';
|
||||
postal_code: string;
|
||||
phone: string;
|
||||
description?: string;
|
||||
settings?: Partial<TenantSettings>;
|
||||
location?: TenantLocation;
|
||||
coordinates?: { lat: number; lng: number };
|
||||
products?: string[];
|
||||
has_historical_data?: boolean;
|
||||
}
|
||||
|
||||
export interface TenantUpdate {
|
||||
name?: string;
|
||||
description?: string;
|
||||
settings?: Partial<TenantSettings>;
|
||||
location?: TenantLocation;
|
||||
}
|
||||
|
||||
export interface TenantMember {
|
||||
user_id: string;
|
||||
tenant_id: string;
|
||||
role: 'owner' | 'admin' | 'member' | 'viewer';
|
||||
is_active: boolean;
|
||||
joined_at: string;
|
||||
user: {
|
||||
id: string;
|
||||
email: string;
|
||||
full_name: string;
|
||||
};
|
||||
// Additional properties for compatibility
|
||||
id?: string;
|
||||
status?: 'active' | 'inactive' | 'pending';
|
||||
last_active?: string;
|
||||
}
|
||||
|
||||
export interface UserMember {
|
||||
id: string;
|
||||
email: string;
|
||||
full_name: string;
|
||||
role: 'owner' | 'admin' | 'member' | 'viewer';
|
||||
status: 'active' | 'inactive' | 'pending';
|
||||
joined_at: string;
|
||||
last_active?: string;
|
||||
}
|
||||
|
||||
export interface InviteUser {
|
||||
email: string;
|
||||
role: 'admin' | 'member' | 'viewer';
|
||||
message?: string;
|
||||
}
|
||||
|
||||
export interface TenantStats {
|
||||
tenant_id: string;
|
||||
total_members: number;
|
||||
active_members: number;
|
||||
total_predictions: number;
|
||||
models_trained: number;
|
||||
last_training_date?: string;
|
||||
subscription_plan: string;
|
||||
subscription_status: string;
|
||||
}
|
||||
@@ -1,132 +0,0 @@
|
||||
// frontend/src/api/types/training.ts
|
||||
/**
|
||||
* Training Service Types
|
||||
*/
|
||||
|
||||
export interface TrainingJobRequest {
|
||||
config?: TrainingJobConfig;
|
||||
priority?: number;
|
||||
schedule_time?: string;
|
||||
include_weather?: boolean;
|
||||
include_traffic?: boolean;
|
||||
min_data_points?: number;
|
||||
use_default_data?: boolean;
|
||||
}
|
||||
|
||||
export interface SingleProductTrainingRequest {
|
||||
inventory_product_id: string;
|
||||
config?: TrainingJobConfig;
|
||||
priority?: number;
|
||||
}
|
||||
|
||||
export interface TrainingJobConfig {
|
||||
external_data?: ExternalDataConfig;
|
||||
prophet_params?: Record<string, any>;
|
||||
data_filters?: Record<string, any>;
|
||||
validation_params?: Record<string, any>;
|
||||
}
|
||||
|
||||
export interface ExternalDataConfig {
|
||||
weather_enabled: boolean;
|
||||
traffic_enabled: boolean;
|
||||
weather_features: string[];
|
||||
traffic_features: string[];
|
||||
}
|
||||
|
||||
export interface TrainingJobResponse {
|
||||
job_id: string;
|
||||
tenant_id: string;
|
||||
status: TrainingJobStatus;
|
||||
config: TrainingJobConfig;
|
||||
priority: number;
|
||||
created_at: string;
|
||||
started_at?: string;
|
||||
completed_at?: string;
|
||||
error_message?: string;
|
||||
progress?: TrainingJobProgress;
|
||||
results?: TrainingJobResults;
|
||||
}
|
||||
|
||||
export type TrainingJobStatus =
|
||||
| 'pending'
|
||||
| 'running'
|
||||
| 'completed'
|
||||
| 'failed'
|
||||
| 'cancelled';
|
||||
|
||||
export interface TrainingJobProgress {
|
||||
current_step: string;
|
||||
total_steps: number;
|
||||
completed_steps: number;
|
||||
percentage: number;
|
||||
current_product?: string;
|
||||
total_products?: number;
|
||||
completed_products?: number;
|
||||
estimated_completion?: string;
|
||||
detailed_progress?: StepProgress[];
|
||||
}
|
||||
|
||||
export interface StepProgress {
|
||||
step_name: string;
|
||||
status: 'pending' | 'running' | 'completed' | 'failed';
|
||||
progress_percentage: number;
|
||||
start_time?: string;
|
||||
end_time?: string;
|
||||
duration_seconds?: number;
|
||||
}
|
||||
|
||||
export interface TrainingJobResults {
|
||||
models_trained: number;
|
||||
models_failed: number;
|
||||
total_training_time_seconds: number;
|
||||
average_model_accuracy?: number;
|
||||
trained_models: TrainedModelInfo[];
|
||||
failed_products?: string[]; // inventory_product_ids of failed products
|
||||
}
|
||||
|
||||
export interface TrainedModelInfo {
|
||||
inventory_product_id: string;
|
||||
product_name?: string; // Optional - for display, populated by frontend from inventory service
|
||||
model_id: string;
|
||||
model_type: string;
|
||||
accuracy_metrics: TrainingMetrics;
|
||||
training_time_seconds: number;
|
||||
data_points_used: number;
|
||||
model_path: string;
|
||||
}
|
||||
|
||||
export interface TrainingMetrics {
|
||||
mae: number;
|
||||
mse: number;
|
||||
rmse: number;
|
||||
mape: number;
|
||||
r2_score: number;
|
||||
mean_actual: number;
|
||||
mean_predicted: number;
|
||||
}
|
||||
|
||||
export interface ModelInfo {
|
||||
model_id: string;
|
||||
tenant_id: string;
|
||||
inventory_product_id: string;
|
||||
product_name?: string; // Optional - for display, populated by frontend from inventory service
|
||||
model_type: string;
|
||||
model_path: string;
|
||||
version: number;
|
||||
training_samples: number;
|
||||
features: string[];
|
||||
hyperparameters: Record<string, any>;
|
||||
training_metrics: Record<string, number>;
|
||||
is_active: boolean;
|
||||
created_at: string;
|
||||
data_period_start?: string;
|
||||
data_period_end?: string;
|
||||
}
|
||||
|
||||
export interface ModelTrainingStats {
|
||||
total_models: number;
|
||||
active_models: number;
|
||||
last_training_date?: string;
|
||||
avg_training_time_minutes: number;
|
||||
success_rate: number;
|
||||
}
|
||||
@@ -1,50 +0,0 @@
|
||||
// frontend/src/api/utils/error.ts
|
||||
/**
|
||||
* Error Handling Utilities
|
||||
*/
|
||||
|
||||
import type { ApiError } from '../client/types';
|
||||
|
||||
export class ApiErrorHandler {
|
||||
static formatError(error: any): string {
|
||||
if (error?.response?.data) {
|
||||
const errorData = error.response.data as ApiError;
|
||||
return errorData.detail || errorData.message || 'An error occurred';
|
||||
}
|
||||
|
||||
if (error?.message) {
|
||||
return error.message;
|
||||
}
|
||||
|
||||
return 'An unexpected error occurred';
|
||||
}
|
||||
|
||||
static getErrorCode(error: any): string | undefined {
|
||||
return error?.response?.data?.error_code;
|
||||
}
|
||||
|
||||
static isNetworkError(error: any): boolean {
|
||||
return !error?.response && error?.message?.includes('Network');
|
||||
}
|
||||
|
||||
static isAuthError(error: any): boolean {
|
||||
const status = error?.response?.status;
|
||||
return status === 401 || status === 403;
|
||||
}
|
||||
|
||||
static isValidationError(error: any): boolean {
|
||||
return error?.response?.status === 422;
|
||||
}
|
||||
|
||||
static isServerError(error: any): boolean {
|
||||
const status = error?.response?.status;
|
||||
return status >= 500;
|
||||
}
|
||||
|
||||
static shouldRetry(error: any): boolean {
|
||||
if (this.isNetworkError(error)) return true;
|
||||
if (this.isServerError(error)) return true;
|
||||
const status = error?.response?.status;
|
||||
return status === 408 || status === 429; // Timeout or Rate limited
|
||||
}
|
||||
}
|
||||
@@ -1,9 +0,0 @@
|
||||
// frontend/src/api/utils/index.ts
|
||||
/**
|
||||
* Main Utils Export
|
||||
*/
|
||||
|
||||
export { ApiErrorHandler } from './error';
|
||||
export { ResponseProcessor } from './response';
|
||||
export { RequestValidator } from './validation';
|
||||
export { DataTransformer } from './transform';
|
||||
@@ -1,34 +0,0 @@
|
||||
// frontend/src/api/utils/response.ts
|
||||
/**
|
||||
* Response Processing Utilities
|
||||
*/
|
||||
|
||||
import type { ApiResponse, PaginatedResponse } from '../types';
|
||||
|
||||
export class ResponseProcessor {
|
||||
static extractData<T>(response: ApiResponse<T>): T {
|
||||
return response.data;
|
||||
}
|
||||
|
||||
static extractPaginatedData<T>(response: PaginatedResponse<T>): {
|
||||
data: T[];
|
||||
pagination: PaginatedResponse<T>['pagination'];
|
||||
} {
|
||||
return {
|
||||
data: response.data,
|
||||
pagination: response.pagination,
|
||||
};
|
||||
}
|
||||
|
||||
static isSuccessResponse(response: ApiResponse): boolean {
|
||||
return response.status === 'success' || response.status === 'ok';
|
||||
}
|
||||
|
||||
static extractMessage(response: ApiResponse): string | undefined {
|
||||
return response.message;
|
||||
}
|
||||
|
||||
static extractMeta(response: ApiResponse): any {
|
||||
return response.meta;
|
||||
}
|
||||
}
|
||||
@@ -1,70 +0,0 @@
|
||||
// frontend/src/api/utils/transform.ts
|
||||
/**
|
||||
* Data Transformation Utilities
|
||||
*/
|
||||
|
||||
export class DataTransformer {
|
||||
static formatDate(date: string | Date): string {
|
||||
const d = new Date(date);
|
||||
return d.toLocaleDateString();
|
||||
}
|
||||
|
||||
static formatDateTime(date: string | Date): string {
|
||||
const d = new Date(date);
|
||||
return d.toLocaleString();
|
||||
}
|
||||
|
||||
static formatCurrency(amount: number, currency = 'EUR'): string {
|
||||
return new Intl.NumberFormat('es-ES', {
|
||||
style: 'currency',
|
||||
currency,
|
||||
}).format(amount);
|
||||
}
|
||||
|
||||
static formatPercentage(value: number, decimals = 1): string {
|
||||
return `${(value * 100).toFixed(decimals)}%`;
|
||||
}
|
||||
|
||||
static formatFileSize(bytes: number): string {
|
||||
const sizes = ['Bytes', 'KB', 'MB', 'GB'];
|
||||
if (bytes === 0) return '0 Bytes';
|
||||
const i = Math.floor(Math.log(bytes) / Math.log(1024));
|
||||
return `${Math.round(bytes / Math.pow(1024, i) * 100) / 100} ${sizes[i]}`;
|
||||
}
|
||||
|
||||
static slugify(text: string): string {
|
||||
return text
|
||||
.toLowerCase()
|
||||
.replace(/[^\w ]+/g, '')
|
||||
.replace(/ +/g, '-');
|
||||
}
|
||||
|
||||
static truncate(text: string, length: number): string {
|
||||
if (text.length <= length) return text;
|
||||
return `${text.substring(0, length)}...`;
|
||||
}
|
||||
|
||||
static camelToKebab(str: string): string {
|
||||
return str.replace(/([a-z0-9]|(?=[A-Z]))([A-Z])/g, '$1-$2').toLowerCase();
|
||||
}
|
||||
|
||||
static kebabToCamel(str: string): string {
|
||||
return str.replace(/-([a-z])/g, (g) => g[1].toUpperCase());
|
||||
}
|
||||
|
||||
static deepClone<T>(obj: T): T {
|
||||
return JSON.parse(JSON.stringify(obj));
|
||||
}
|
||||
|
||||
static removeEmpty(obj: Record<string, any>): Record<string, any> {
|
||||
const cleaned: Record<string, any> = {};
|
||||
|
||||
Object.keys(obj).forEach(key => {
|
||||
if (obj[key] !== null && obj[key] !== undefined && obj[key] !== '') {
|
||||
cleaned[key] = obj[key];
|
||||
}
|
||||
});
|
||||
|
||||
return cleaned;
|
||||
}
|
||||
}
|
||||
@@ -1,72 +0,0 @@
|
||||
// frontend/src/api/utils/validation.ts
|
||||
/**
|
||||
* Request Validation Utilities
|
||||
*/
|
||||
|
||||
export class RequestValidator {
|
||||
static validateEmail(email: string): boolean {
|
||||
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
||||
return emailRegex.test(email);
|
||||
}
|
||||
|
||||
static validatePassword(password: string): {
|
||||
valid: boolean;
|
||||
errors: string[];
|
||||
} {
|
||||
const errors: string[] = [];
|
||||
|
||||
if (password.length < 8) {
|
||||
errors.push('Password must be at least 8 characters long');
|
||||
}
|
||||
|
||||
if (!/(?=.*[a-z])/.test(password)) {
|
||||
errors.push('Password must contain at least one lowercase letter');
|
||||
}
|
||||
|
||||
if (!/(?=.*[A-Z])/.test(password)) {
|
||||
errors.push('Password must contain at least one uppercase letter');
|
||||
}
|
||||
|
||||
if (!/(?=.*\d)/.test(password)) {
|
||||
errors.push('Password must contain at least one number');
|
||||
}
|
||||
|
||||
return {
|
||||
valid: errors.length === 0,
|
||||
errors,
|
||||
};
|
||||
}
|
||||
|
||||
static validateFile(file: File, allowedTypes: string[], maxSize: number): {
|
||||
valid: boolean;
|
||||
errors: string[];
|
||||
} {
|
||||
const errors: string[] = [];
|
||||
|
||||
if (!allowedTypes.includes(file.type)) {
|
||||
errors.push(`File type ${file.type} is not allowed`);
|
||||
}
|
||||
|
||||
if (file.size > maxSize) {
|
||||
errors.push(`File size exceeds maximum of ${maxSize} bytes`);
|
||||
}
|
||||
|
||||
return {
|
||||
valid: errors.length === 0,
|
||||
errors,
|
||||
};
|
||||
}
|
||||
|
||||
static validatePhoneNumber(phone: string): boolean {
|
||||
// Basic international phone number validation
|
||||
const phoneRegex = /^\+?[1-9]\d{1,14}$/;
|
||||
return phoneRegex.test(phone.replace(/\s/g, ''));
|
||||
}
|
||||
|
||||
static validateRequired(value: any, fieldName: string): string | null {
|
||||
if (value === null || value === undefined || value === '') {
|
||||
return `${fieldName} is required`;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -1,433 +0,0 @@
|
||||
// frontend/src/api/websocket/hooks.ts
|
||||
/**
|
||||
* WebSocket React Hooks
|
||||
*/
|
||||
|
||||
import { useState, useEffect, useCallback, useRef } from 'react';
|
||||
import { WebSocketManager } from './manager';
|
||||
import type {
|
||||
WebSocketConfig,
|
||||
WebSocketMessage,
|
||||
WebSocketHandlers,
|
||||
WebSocketStatus,
|
||||
} from './types';
|
||||
|
||||
export const useWebSocket = (config: WebSocketConfig) => {
|
||||
const [status, setStatus] = useState<WebSocketStatus>('disconnected');
|
||||
const [lastMessage, setLastMessage] = useState<WebSocketMessage | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const wsManagerRef = useRef<WebSocketManager | null>(null);
|
||||
|
||||
// Initialize WebSocket manager
|
||||
useEffect(() => {
|
||||
wsManagerRef.current = new WebSocketManager(config);
|
||||
|
||||
const handlers: WebSocketHandlers = {
|
||||
onOpen: () => {
|
||||
setStatus('connected');
|
||||
setError(null);
|
||||
},
|
||||
onMessage: (message) => {
|
||||
setLastMessage(message);
|
||||
},
|
||||
onError: (error) => {
|
||||
setError('WebSocket connection error');
|
||||
setStatus('failed');
|
||||
},
|
||||
onClose: () => {
|
||||
setStatus('disconnected');
|
||||
},
|
||||
onReconnect: () => {
|
||||
setStatus('reconnecting');
|
||||
setError(null);
|
||||
},
|
||||
onReconnectFailed: () => {
|
||||
setStatus('failed');
|
||||
setError('Failed to reconnect');
|
||||
},
|
||||
};
|
||||
|
||||
wsManagerRef.current.setHandlers(handlers);
|
||||
|
||||
return () => {
|
||||
wsManagerRef.current?.disconnect();
|
||||
};
|
||||
}, [config.url]);
|
||||
|
||||
const connect = useCallback(async () => {
|
||||
try {
|
||||
setError(null);
|
||||
await wsManagerRef.current?.connect();
|
||||
} catch (error) {
|
||||
setError(error instanceof Error ? error.message : 'Connection failed');
|
||||
}
|
||||
}, []);
|
||||
|
||||
const disconnect = useCallback(() => {
|
||||
wsManagerRef.current?.disconnect();
|
||||
}, []);
|
||||
|
||||
const sendMessage = useCallback((message: Omit<WebSocketMessage, 'timestamp' | 'id'>) => {
|
||||
return wsManagerRef.current?.send(message) ?? false;
|
||||
}, []);
|
||||
|
||||
const addMessageHandler = useCallback((handler: (message: WebSocketMessage) => void) => {
|
||||
const currentHandlers = wsManagerRef.current?.['handlers'] || {};
|
||||
wsManagerRef.current?.setHandlers({
|
||||
...currentHandlers,
|
||||
onMessage: (message) => {
|
||||
setLastMessage(message);
|
||||
handler(message);
|
||||
},
|
||||
});
|
||||
}, []);
|
||||
|
||||
return {
|
||||
status,
|
||||
lastMessage,
|
||||
error,
|
||||
connect,
|
||||
disconnect,
|
||||
sendMessage,
|
||||
addMessageHandler,
|
||||
isConnected: status === 'connected',
|
||||
};
|
||||
};
|
||||
|
||||
// Hook for training job updates
|
||||
export const useTrainingWebSocket = (jobId: string, tenantId?: string) => {
|
||||
const [jobUpdates, setJobUpdates] = useState<any[]>([]);
|
||||
const [connectionError, setConnectionError] = useState<string | null>(null);
|
||||
const [isAuthenticationError, setIsAuthenticationError] = useState(false);
|
||||
|
||||
// Get tenant ID reliably with enhanced error handling
|
||||
const actualTenantId = tenantId || (() => {
|
||||
try {
|
||||
// Try multiple sources for tenant ID
|
||||
const sources = [
|
||||
() => localStorage.getItem('current_tenant_id'),
|
||||
() => {
|
||||
const userData = localStorage.getItem('user_data');
|
||||
if (userData) {
|
||||
const parsed = JSON.parse(userData);
|
||||
return parsed.current_tenant_id || parsed.tenant_id;
|
||||
}
|
||||
return null;
|
||||
},
|
||||
() => {
|
||||
const authData = localStorage.getItem('auth_data');
|
||||
if (authData) {
|
||||
const parsed = JSON.parse(authData);
|
||||
return parsed.tenant_id;
|
||||
}
|
||||
return null;
|
||||
},
|
||||
() => {
|
||||
const tenantContext = localStorage.getItem('tenant_context');
|
||||
if (tenantContext) {
|
||||
const parsed = JSON.parse(tenantContext);
|
||||
return parsed.current_tenant_id;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
];
|
||||
|
||||
for (const source of sources) {
|
||||
try {
|
||||
const tenantId = source();
|
||||
if (tenantId) return tenantId;
|
||||
} catch (e) {
|
||||
console.warn('Failed to get tenant ID from source:', e);
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Failed to parse tenant ID from storage:', e);
|
||||
}
|
||||
return null;
|
||||
})();
|
||||
|
||||
const config = {
|
||||
url: actualTenantId
|
||||
? `ws://localhost:8000/api/v1/ws/tenants/${actualTenantId}/training/jobs/${jobId}/live`
|
||||
: `ws://localhost:8000/api/v1/ws/tenants/unknown/training/jobs/${jobId}/live`,
|
||||
reconnect: true,
|
||||
reconnectInterval: 3000, // Faster reconnection for training
|
||||
maxReconnectAttempts: 20, // More attempts for long training jobs
|
||||
heartbeatInterval: 15000, // Send heartbeat every 15 seconds for training jobs
|
||||
enableLogging: true // Enable logging for debugging
|
||||
};
|
||||
|
||||
const {
|
||||
status,
|
||||
connect,
|
||||
disconnect,
|
||||
addMessageHandler,
|
||||
isConnected,
|
||||
lastMessage,
|
||||
sendMessage
|
||||
} = useWebSocket(config);
|
||||
|
||||
// Enhanced message handler with error handling
|
||||
const handleWebSocketMessage = useCallback((message: any) => {
|
||||
try {
|
||||
// Clear connection error when receiving messages
|
||||
setConnectionError(null);
|
||||
setIsAuthenticationError(false);
|
||||
|
||||
// Handle different message structures
|
||||
let processedMessage = message;
|
||||
|
||||
// If message has nested data, flatten it for easier processing
|
||||
if (message.data && typeof message.data === 'object') {
|
||||
processedMessage = {
|
||||
...message,
|
||||
// Merge data properties to root level for backward compatibility
|
||||
...message.data,
|
||||
// Preserve original structure
|
||||
_originalData: message.data
|
||||
};
|
||||
}
|
||||
|
||||
// Handle special message types
|
||||
if (message.type === 'connection_established') {
|
||||
console.log('WebSocket training connection established:', message);
|
||||
setJobUpdates(prev => [{
|
||||
type: 'connection_established',
|
||||
message: 'Connected to training service',
|
||||
timestamp: Date.now()
|
||||
}, ...prev.slice(0, 49)]);
|
||||
return;
|
||||
}
|
||||
|
||||
// Handle keepalive messages (don't show to user, just for connection health)
|
||||
if (message.type === 'pong' || message.type === 'heartbeat') {
|
||||
console.debug('Training WebSocket keepalive received:', message.type);
|
||||
return; // Don't add to jobUpdates
|
||||
}
|
||||
|
||||
if (message.type === 'authentication_error' || message.type === 'authorization_error') {
|
||||
console.error('WebSocket auth/authorization error:', message);
|
||||
setIsAuthenticationError(true);
|
||||
setConnectionError(message.message || 'Authentication/authorization failed - please refresh and try again');
|
||||
return;
|
||||
}
|
||||
|
||||
if (message.type === 'connection_error') {
|
||||
console.error('WebSocket connection error:', message);
|
||||
setConnectionError(message.message || 'Connection error');
|
||||
return;
|
||||
}
|
||||
|
||||
if (message.type === 'connection_timeout') {
|
||||
console.warn('WebSocket connection timeout:', message);
|
||||
// Don't set as error, just log - connection will retry
|
||||
return;
|
||||
}
|
||||
|
||||
if (message.type === 'job_not_found') {
|
||||
console.error('Training job not found:', message);
|
||||
setConnectionError('Training job not found. Please restart the training process.');
|
||||
return;
|
||||
}
|
||||
|
||||
// Comprehensive message type handling
|
||||
const trainingMessageTypes = [
|
||||
'progress', 'training_progress',
|
||||
'completed', 'training_completed',
|
||||
'failed', 'training_failed',
|
||||
'error', 'training_error',
|
||||
'started', 'training_started',
|
||||
'heartbeat', 'initial_status',
|
||||
'status_update'
|
||||
];
|
||||
|
||||
if (trainingMessageTypes.includes(message.type)) {
|
||||
// Add to updates array with processed message
|
||||
setJobUpdates(prev => {
|
||||
const newUpdates = [processedMessage, ...prev.slice(0, 49)]; // Keep last 50 messages
|
||||
return newUpdates;
|
||||
});
|
||||
} else {
|
||||
// Still add to updates for debugging purposes
|
||||
console.log('Received unknown message type:', message.type, message);
|
||||
setJobUpdates(prev => [processedMessage, ...prev.slice(0, 49)]);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error processing WebSocket message:', error, message);
|
||||
}
|
||||
}, []);
|
||||
|
||||
// Set up message handler when hook initializes
|
||||
useEffect(() => {
|
||||
addMessageHandler(handleWebSocketMessage);
|
||||
}, [addMessageHandler, handleWebSocketMessage]);
|
||||
|
||||
// Enhanced dual ping system for training jobs - prevent disconnection during long training
|
||||
useEffect(() => {
|
||||
if (isConnected) {
|
||||
// Primary ping system using JSON messages with training info
|
||||
const keepaliveInterval = setInterval(() => {
|
||||
const success = sendMessage({
|
||||
type: 'training_keepalive',
|
||||
data: {
|
||||
timestamp: Date.now(),
|
||||
job_id: jobId,
|
||||
tenant_id: actualTenantId,
|
||||
status: 'active'
|
||||
}
|
||||
});
|
||||
|
||||
if (!success) {
|
||||
console.warn('Training keepalive failed - connection may be lost');
|
||||
}
|
||||
}, 10000); // Every 10 seconds for training jobs
|
||||
|
||||
// Secondary simple text ping system (more lightweight)
|
||||
const simplePingInterval = setInterval(() => {
|
||||
// Send a simple text ping to keep connection alive
|
||||
const success = sendMessage({
|
||||
type: 'ping',
|
||||
data: {
|
||||
timestamp: Date.now(),
|
||||
source: 'training_client'
|
||||
}
|
||||
});
|
||||
|
||||
if (!success) {
|
||||
console.warn('Simple training ping failed');
|
||||
}
|
||||
}, 15000); // Every 15 seconds
|
||||
|
||||
return () => {
|
||||
clearInterval(keepaliveInterval);
|
||||
clearInterval(simplePingInterval);
|
||||
};
|
||||
}
|
||||
}, [isConnected, sendMessage, jobId, actualTenantId]);
|
||||
|
||||
// Define refresh connection function
|
||||
const refreshConnection = useCallback(() => {
|
||||
setConnectionError(null);
|
||||
setIsAuthenticationError(false);
|
||||
disconnect();
|
||||
setTimeout(() => {
|
||||
connect();
|
||||
}, 1000);
|
||||
}, [connect, disconnect]);
|
||||
|
||||
// Enhanced connection monitoring and auto-recovery for training jobs
|
||||
useEffect(() => {
|
||||
if (actualTenantId && jobId !== 'pending') {
|
||||
const healthCheckInterval = setInterval(() => {
|
||||
// If we should be connected but aren't, try to reconnect
|
||||
if (status === 'disconnected' && !connectionError) {
|
||||
console.log('WebSocket health check: reconnecting disconnected training socket');
|
||||
connect();
|
||||
}
|
||||
|
||||
// More aggressive stale connection detection for training jobs
|
||||
const lastUpdate = jobUpdates.length > 0 ? jobUpdates[0] : null;
|
||||
if (lastUpdate && status === 'connected') {
|
||||
const timeSinceLastMessage = Date.now() - (lastUpdate.timestamp || 0);
|
||||
if (timeSinceLastMessage > 45000) { // 45 seconds without messages during training
|
||||
console.log('WebSocket health check: connection appears stale, refreshing');
|
||||
refreshConnection();
|
||||
}
|
||||
}
|
||||
|
||||
// If connection is in a failed state for too long, force reconnect
|
||||
if (status === 'failed' && !isAuthenticationError) {
|
||||
console.log('WebSocket health check: recovering from failed state');
|
||||
setTimeout(() => connect(), 2000);
|
||||
}
|
||||
}, 12000); // Check every 12 seconds for training jobs
|
||||
|
||||
return () => clearInterval(healthCheckInterval);
|
||||
}
|
||||
}, [actualTenantId, jobId, status, connectionError, connect, refreshConnection, jobUpdates, isAuthenticationError]);
|
||||
|
||||
// Enhanced connection setup - request current status when connecting
|
||||
useEffect(() => {
|
||||
if (isConnected && jobId !== 'pending') {
|
||||
// Wait a moment for connection to stabilize, then request current status
|
||||
const statusRequestTimer = setTimeout(() => {
|
||||
console.log('Requesting current training status after connection');
|
||||
sendMessage({
|
||||
type: 'get_status',
|
||||
data: {
|
||||
job_id: jobId,
|
||||
tenant_id: actualTenantId
|
||||
}
|
||||
});
|
||||
}, 2000);
|
||||
|
||||
return () => clearTimeout(statusRequestTimer);
|
||||
}
|
||||
}, [isConnected, jobId, actualTenantId, sendMessage]);
|
||||
|
||||
return {
|
||||
status,
|
||||
jobUpdates,
|
||||
connect,
|
||||
disconnect,
|
||||
isConnected,
|
||||
lastMessage,
|
||||
tenantId: actualTenantId,
|
||||
wsUrl: config.url,
|
||||
connectionError,
|
||||
isAuthenticationError,
|
||||
// Enhanced refresh function with status request
|
||||
refreshConnection,
|
||||
// Force retry with new authentication
|
||||
retryWithAuth: useCallback(() => {
|
||||
setConnectionError(null);
|
||||
setIsAuthenticationError(false);
|
||||
// Clear any cached auth data that might be stale
|
||||
disconnect();
|
||||
setTimeout(() => {
|
||||
connect();
|
||||
}, 2000);
|
||||
}, [connect, disconnect]),
|
||||
// Manual status request function
|
||||
requestStatus: useCallback(() => {
|
||||
if (isConnected && jobId !== 'pending') {
|
||||
return sendMessage({
|
||||
type: 'get_status',
|
||||
data: {
|
||||
job_id: jobId,
|
||||
tenant_id: actualTenantId
|
||||
}
|
||||
});
|
||||
}
|
||||
return false;
|
||||
}, [isConnected, jobId, actualTenantId, sendMessage])
|
||||
};
|
||||
};
|
||||
|
||||
// Hook for forecast alerts
|
||||
export const useForecastWebSocket = (tenantId: string) => {
|
||||
const config: WebSocketConfig = {
|
||||
url: `ws://localhost:8000/api/v1/ws/forecasts/${tenantId}`,
|
||||
reconnect: true,
|
||||
};
|
||||
|
||||
const [alerts, setAlerts] = useState<any[]>([]);
|
||||
|
||||
const { status, connect, disconnect, addMessageHandler, isConnected } = useWebSocket(config);
|
||||
|
||||
useEffect(() => {
|
||||
addMessageHandler((message) => {
|
||||
if (message.type === 'forecast_alert') {
|
||||
setAlerts(prev => [message.data, ...prev]);
|
||||
}
|
||||
});
|
||||
}, [addMessageHandler]);
|
||||
|
||||
return {
|
||||
status,
|
||||
alerts,
|
||||
connect,
|
||||
disconnect,
|
||||
isConnected,
|
||||
};
|
||||
};
|
||||
@@ -1,18 +0,0 @@
|
||||
// frontend/src/api/websocket/index.ts
|
||||
/**
|
||||
* Main WebSocket Export
|
||||
*/
|
||||
|
||||
export { WebSocketManager } from './manager';
|
||||
export {
|
||||
useWebSocket,
|
||||
useTrainingWebSocket,
|
||||
useForecastWebSocket,
|
||||
} from './hooks';
|
||||
export type {
|
||||
WebSocketConfig,
|
||||
WebSocketMessage,
|
||||
WebSocketHandlers,
|
||||
WebSocketStatus,
|
||||
WebSocketMetrics,
|
||||
} from './types';
|
||||
@@ -1,274 +0,0 @@
|
||||
// frontend/src/api/websocket/manager.ts
|
||||
/**
|
||||
* WebSocket Manager
|
||||
* Handles WebSocket connections with auto-reconnection and heartbeat
|
||||
*/
|
||||
|
||||
import { apiConfig } from '../client/config';
|
||||
import type {
|
||||
WebSocketConfig,
|
||||
WebSocketMessage,
|
||||
WebSocketHandlers,
|
||||
WebSocketStatus,
|
||||
WebSocketMetrics,
|
||||
} from './types';
|
||||
|
||||
export class WebSocketManager {
|
||||
private ws: WebSocket | null = null;
|
||||
private config: WebSocketConfig;
|
||||
private handlers: WebSocketHandlers = {};
|
||||
private status: WebSocketStatus = 'disconnected';
|
||||
private reconnectTimer: NodeJS.Timeout | null = null;
|
||||
private heartbeatTimer: NodeJS.Timeout | null = null;
|
||||
private reconnectAttempts = 0;
|
||||
private metrics: WebSocketMetrics = {
|
||||
reconnectAttempts: 0,
|
||||
messagesReceived: 0,
|
||||
messagesSent: 0,
|
||||
lastActivity: new Date(),
|
||||
};
|
||||
|
||||
constructor(config: WebSocketConfig) {
|
||||
this.config = {
|
||||
reconnect: true,
|
||||
reconnectInterval: 5000,
|
||||
maxReconnectAttempts: 10,
|
||||
heartbeatInterval: 30000,
|
||||
enableLogging: apiConfig.enableLogging,
|
||||
...config,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Connect to WebSocket
|
||||
*/
|
||||
connect(): Promise<void> {
|
||||
return new Promise((resolve, reject) => {
|
||||
try {
|
||||
this.status = 'connecting';
|
||||
this.log('Connecting to WebSocket:', this.config.url);
|
||||
|
||||
// Add authentication token to URL if available
|
||||
const token = localStorage.getItem('auth_token');
|
||||
const wsUrl = token
|
||||
? `${this.config.url}?token=${encodeURIComponent(token)}`
|
||||
: this.config.url;
|
||||
|
||||
this.ws = new WebSocket(wsUrl, this.config.protocols);
|
||||
|
||||
this.ws.onopen = (event) => {
|
||||
this.status = 'connected';
|
||||
this.reconnectAttempts = 0;
|
||||
this.metrics.connectionTime = Date.now();
|
||||
this.metrics.lastActivity = new Date();
|
||||
|
||||
this.log('WebSocket connected');
|
||||
this.startHeartbeat();
|
||||
|
||||
this.handlers.onOpen?.(event);
|
||||
resolve();
|
||||
};
|
||||
|
||||
this.ws.onmessage = (event) => {
|
||||
this.metrics.messagesReceived++;
|
||||
this.metrics.lastActivity = new Date();
|
||||
|
||||
try {
|
||||
const message: WebSocketMessage = JSON.parse(event.data);
|
||||
this.log('WebSocket message received:', message.type);
|
||||
this.handlers.onMessage?.(message);
|
||||
} catch (error) {
|
||||
this.log('Failed to parse WebSocket message:', error);
|
||||
}
|
||||
};
|
||||
|
||||
this.ws.onerror = (error) => {
|
||||
this.log('WebSocket error:', error);
|
||||
this.handlers.onError?.(error);
|
||||
|
||||
if (this.status === 'connecting') {
|
||||
reject(new Error('WebSocket connection failed'));
|
||||
}
|
||||
};
|
||||
|
||||
this.ws.onclose = (event) => {
|
||||
this.log('WebSocket closed:', event.code, event.reason);
|
||||
this.status = 'disconnected';
|
||||
this.stopHeartbeat();
|
||||
|
||||
this.handlers.onClose?.(event);
|
||||
|
||||
// Auto-reconnect if enabled and not manually closed
|
||||
// Don't reconnect on authorization failures or job not found (1008) with specific reasons
|
||||
const isAuthorizationError = event.code === 1008 &&
|
||||
(event.reason === 'Authentication failed' || event.reason === 'Authorization failed');
|
||||
const isJobNotFound = event.code === 1008 && event.reason === 'Job not found';
|
||||
|
||||
if (this.config.reconnect && event.code !== 1000 && !isAuthorizationError && !isJobNotFound) {
|
||||
this.scheduleReconnect();
|
||||
} else if (isAuthorizationError || isJobNotFound) {
|
||||
this.log('Connection failed - stopping reconnection attempts:', event.reason);
|
||||
this.status = 'failed';
|
||||
this.handlers.onReconnectFailed?.();
|
||||
}
|
||||
};
|
||||
|
||||
} catch (error) {
|
||||
this.status = 'failed';
|
||||
reject(error);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Disconnect from WebSocket
|
||||
*/
|
||||
disconnect(): void {
|
||||
this.config.reconnect = false; // Disable auto-reconnect
|
||||
this.clearReconnectTimer();
|
||||
this.stopHeartbeat();
|
||||
|
||||
if (this.ws && this.ws.readyState === WebSocket.OPEN) {
|
||||
this.ws.close(1000, 'Manual disconnect');
|
||||
}
|
||||
|
||||
this.ws = null;
|
||||
this.status = 'disconnected';
|
||||
}
|
||||
|
||||
/**
|
||||
* Send message through WebSocket
|
||||
*/
|
||||
send(message: Omit<WebSocketMessage, 'timestamp' | 'id'>): boolean {
|
||||
if (!this.isConnected()) {
|
||||
this.log('Cannot send message: WebSocket not connected');
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
const fullMessage: WebSocketMessage = {
|
||||
...message,
|
||||
timestamp: new Date().toISOString(),
|
||||
id: this.generateMessageId(),
|
||||
};
|
||||
|
||||
this.ws!.send(JSON.stringify(fullMessage));
|
||||
this.metrics.messagesSent++;
|
||||
this.metrics.lastActivity = new Date();
|
||||
|
||||
this.log('WebSocket message sent:', message.type);
|
||||
return true;
|
||||
} catch (error) {
|
||||
this.log('Failed to send WebSocket message:', error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Set event handlers
|
||||
*/
|
||||
setHandlers(handlers: WebSocketHandlers): void {
|
||||
this.handlers = { ...this.handlers, ...handlers };
|
||||
}
|
||||
|
||||
/**
|
||||
* Get connection status
|
||||
*/
|
||||
getStatus(): WebSocketStatus {
|
||||
return this.status;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if connected
|
||||
*/
|
||||
isConnected(): boolean {
|
||||
return this.ws?.readyState === WebSocket.OPEN;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get connection metrics
|
||||
*/
|
||||
getMetrics(): WebSocketMetrics {
|
||||
return { ...this.metrics };
|
||||
}
|
||||
|
||||
/**
|
||||
* Schedule reconnection attempt
|
||||
*/
|
||||
private scheduleReconnect(): void {
|
||||
if (this.reconnectAttempts >= this.config.maxReconnectAttempts!) {
|
||||
this.status = 'failed';
|
||||
this.log('Max reconnection attempts reached');
|
||||
this.handlers.onReconnectFailed?.();
|
||||
return;
|
||||
}
|
||||
|
||||
this.status = 'reconnecting';
|
||||
this.reconnectAttempts++;
|
||||
this.metrics.reconnectAttempts++;
|
||||
|
||||
this.log(`Scheduling reconnection attempt ${this.reconnectAttempts}/${this.config.maxReconnectAttempts}`);
|
||||
|
||||
this.reconnectTimer = setTimeout(async () => {
|
||||
try {
|
||||
this.handlers.onReconnect?.();
|
||||
await this.connect();
|
||||
} catch (error) {
|
||||
this.log('Reconnection failed:', error);
|
||||
this.scheduleReconnect();
|
||||
}
|
||||
}, this.config.reconnectInterval);
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear reconnection timer
|
||||
*/
|
||||
private clearReconnectTimer(): void {
|
||||
if (this.reconnectTimer) {
|
||||
clearTimeout(this.reconnectTimer);
|
||||
this.reconnectTimer = null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Start heartbeat mechanism
|
||||
*/
|
||||
private startHeartbeat(): void {
|
||||
if (!this.config.heartbeatInterval) return;
|
||||
|
||||
this.heartbeatTimer = setInterval(() => {
|
||||
if (this.isConnected()) {
|
||||
this.send({
|
||||
type: 'ping',
|
||||
data: { timestamp: Date.now() },
|
||||
});
|
||||
}
|
||||
}, this.config.heartbeatInterval);
|
||||
}
|
||||
|
||||
/**
|
||||
* Stop heartbeat mechanism
|
||||
*/
|
||||
private stopHeartbeat(): void {
|
||||
if (this.heartbeatTimer) {
|
||||
clearInterval(this.heartbeatTimer);
|
||||
this.heartbeatTimer = null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate unique message ID
|
||||
*/
|
||||
private generateMessageId(): string {
|
||||
return `${Date.now()}-${Math.random().toString(36).substr(2, 9)}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Log message if logging enabled
|
||||
*/
|
||||
private log(...args: any[]): void {
|
||||
if (this.config.enableLogging) {
|
||||
console.log('[WebSocket]', ...args);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,45 +0,0 @@
|
||||
// frontend/src/api/websocket/types.ts
|
||||
/**
|
||||
* WebSocket Types
|
||||
*/
|
||||
|
||||
export interface WebSocketConfig {
|
||||
url: string;
|
||||
protocols?: string[];
|
||||
reconnect?: boolean;
|
||||
reconnectInterval?: number;
|
||||
maxReconnectAttempts?: number;
|
||||
heartbeatInterval?: number;
|
||||
enableLogging?: boolean;
|
||||
}
|
||||
|
||||
export interface WebSocketMessage {
|
||||
type: string;
|
||||
data: any;
|
||||
timestamp: string;
|
||||
id?: string;
|
||||
}
|
||||
|
||||
export interface WebSocketHandlers {
|
||||
onOpen?: (event: Event) => void;
|
||||
onMessage?: (message: WebSocketMessage) => void;
|
||||
onError?: (error: Event) => void;
|
||||
onClose?: (event: CloseEvent) => void;
|
||||
onReconnect?: () => void;
|
||||
onReconnectFailed?: () => void;
|
||||
}
|
||||
|
||||
export type WebSocketStatus =
|
||||
| 'connecting'
|
||||
| 'connected'
|
||||
| 'disconnected'
|
||||
| 'reconnecting'
|
||||
| 'failed';
|
||||
|
||||
export interface WebSocketMetrics {
|
||||
connectionTime?: number;
|
||||
reconnectAttempts: number;
|
||||
messagesReceived: number;
|
||||
messagesSent: number;
|
||||
lastActivity: Date;
|
||||
}
|
||||
Reference in New Issue
Block a user