diff --git a/frontend/src/api/hooks/pos.ts b/frontend/src/api/hooks/pos.ts new file mode 100644 index 00000000..43e1f7b9 --- /dev/null +++ b/frontend/src/api/hooks/pos.ts @@ -0,0 +1,655 @@ +/** + * POS React Query hooks + * Provides data fetching and mutation hooks for POS operations + */ + +import { useMutation, useQuery, useQueryClient, UseQueryOptions, UseMutationOptions } from '@tanstack/react-query'; +import { posService } from '../services/pos'; +import type { + POSConfiguration, + POSTransaction, + POSSyncLog, + POSWebhookLog, + GetPOSConfigurationsRequest, + GetPOSConfigurationsResponse, + CreatePOSConfigurationRequest, + CreatePOSConfigurationResponse, + GetPOSConfigurationRequest, + GetPOSConfigurationResponse, + UpdatePOSConfigurationRequest, + UpdatePOSConfigurationResponse, + DeletePOSConfigurationRequest, + DeletePOSConfigurationResponse, + TestPOSConnectionRequest, + TestPOSConnectionResponse, + GetSupportedPOSSystemsResponse, + POSSystem, +} from '../types/pos'; +import { ApiError } from '../client'; + +// ============================================================================ +// QUERY KEYS +// ============================================================================ + +export const posKeys = { + all: ['pos'] as const, + + // Configurations + configurations: () => [...posKeys.all, 'configurations'] as const, + configurationsList: (tenantId: string, filters?: { pos_system?: POSSystem; is_active?: boolean }) => + [...posKeys.configurations(), 'list', tenantId, filters] as const, + configuration: (tenantId: string, configId: string) => + [...posKeys.configurations(), 'detail', tenantId, configId] as const, + + // Supported Systems + supportedSystems: () => [...posKeys.all, 'supported-systems'] as const, + + // Transactions + transactions: () => [...posKeys.all, 'transactions'] as const, + transactionsList: (tenantId: string, filters?: any) => + [...posKeys.transactions(), 'list', tenantId, filters] as const, + transaction: (tenantId: string, transactionId: string) => + [...posKeys.transactions(), 'detail', tenantId, transactionId] as const, + + // Sync Logs + syncLogs: () => [...posKeys.all, 'sync-logs'] as const, + syncLogsList: (tenantId: string, filters?: any) => + [...posKeys.syncLogs(), 'list', tenantId, filters] as const, + + // Webhook Logs + webhookLogs: () => [...posKeys.all, 'webhook-logs'] as const, + webhookLogsList: (tenantId: string, filters?: any) => + [...posKeys.webhookLogs(), 'list', tenantId, filters] as const, +} as const; + +// ============================================================================ +// CONFIGURATION QUERIES +// ============================================================================ + +/** + * Get POS configurations for a tenant + */ +export const usePOSConfigurations = ( + params: GetPOSConfigurationsRequest, + options?: Omit, 'queryKey' | 'queryFn'> +) => { + return useQuery({ + queryKey: posKeys.configurationsList(params.tenant_id, { + pos_system: params.pos_system, + is_active: params.is_active + }), + queryFn: () => posService.getPOSConfigurations(params), + enabled: !!params.tenant_id, + staleTime: 2 * 60 * 1000, // 2 minutes + ...options, + }); +}; + +/** + * Get a specific POS configuration + */ +export const usePOSConfiguration = ( + params: GetPOSConfigurationRequest, + options?: Omit, 'queryKey' | 'queryFn'> +) => { + return useQuery({ + queryKey: posKeys.configuration(params.tenant_id, params.config_id), + queryFn: () => posService.getPOSConfiguration(params), + enabled: !!params.tenant_id && !!params.config_id, + staleTime: 5 * 60 * 1000, // 5 minutes + ...options, + }); +}; + +/** + * Get supported POS systems + */ +export const useSupportedPOSSystems = ( + options?: Omit, 'queryKey' | 'queryFn'> +) => { + return useQuery({ + queryKey: posKeys.supportedSystems(), + queryFn: () => posService.getSupportedPOSSystems(), + staleTime: 30 * 60 * 1000, // 30 minutes - this data rarely changes + ...options, + }); +}; + +// ============================================================================ +// CONFIGURATION MUTATIONS +// ============================================================================ + +/** + * Create a new POS configuration + */ +export const useCreatePOSConfiguration = ( + options?: UseMutationOptions +) => { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: (params) => posService.createPOSConfiguration(params), + onSuccess: (data, variables) => { + // Invalidate and refetch configurations list + queryClient.invalidateQueries({ + queryKey: posKeys.configurationsList(variables.tenant_id) + }); + + // If we have the created configuration, add it to the cache + if (data.configuration) { + queryClient.setQueryData( + posKeys.configuration(variables.tenant_id, data.id), + { configuration: data.configuration } + ); + } + }, + ...options, + }); +}; + +/** + * Update a POS configuration + */ +export const useUpdatePOSConfiguration = ( + options?: UseMutationOptions +) => { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: (params) => posService.updatePOSConfiguration(params), + onSuccess: (data, variables) => { + // Invalidate and refetch configurations list + queryClient.invalidateQueries({ + queryKey: posKeys.configurationsList(variables.tenant_id) + }); + + // Update the specific configuration cache if we have the updated data + if (data.configuration) { + queryClient.setQueryData( + posKeys.configuration(variables.tenant_id, variables.config_id), + { configuration: data.configuration } + ); + } else { + // Invalidate the specific configuration to refetch + queryClient.invalidateQueries({ + queryKey: posKeys.configuration(variables.tenant_id, variables.config_id) + }); + } + }, + ...options, + }); +}; + +/** + * Delete a POS configuration + */ +export const useDeletePOSConfiguration = ( + options?: UseMutationOptions +) => { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: (params) => posService.deletePOSConfiguration(params), + onSuccess: (data, variables) => { + // Remove from configurations list cache + queryClient.invalidateQueries({ + queryKey: posKeys.configurationsList(variables.tenant_id) + }); + + // Remove the specific configuration from cache + queryClient.removeQueries({ + queryKey: posKeys.configuration(variables.tenant_id, variables.config_id) + }); + }, + ...options, + }); +}; + +/** + * Test POS connection + */ +export const useTestPOSConnection = ( + options?: UseMutationOptions +) => { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: (params) => posService.testPOSConnection(params), + onSuccess: (data, variables) => { + // Invalidate the configurations list to refresh connection status + queryClient.invalidateQueries({ + queryKey: posKeys.configurationsList(variables.tenant_id) + }); + + // Invalidate the specific configuration to refresh connection status + queryClient.invalidateQueries({ + queryKey: posKeys.configuration(variables.tenant_id, variables.config_id) + }); + }, + ...options, + }); +}; + +// ============================================================================ +// TRANSACTION QUERIES +// ============================================================================ + +/** + * Get POS transactions for a tenant (Updated to match backend) + */ +export const usePOSTransactions = ( + params: { + tenant_id: string; + pos_system?: string; + start_date?: string; + end_date?: string; + status?: string; + is_synced?: boolean; + limit?: number; + offset?: number; + }, + options?: Omit, 'queryKey' | 'queryFn'> +) => { + return useQuery({ + queryKey: posKeys.transactionsList(params.tenant_id, params), + queryFn: () => posService.getPOSTransactions(params), + enabled: !!params.tenant_id, + staleTime: 30 * 1000, // 30 seconds - transaction data should be fresh + ...options, + }); +}; + +/** + * Get a specific POS transaction + */ +export const usePOSTransaction = ( + params: { + tenant_id: string; + transaction_id: string; + }, + options?: Omit, 'queryKey' | 'queryFn'> +) => { + return useQuery({ + queryKey: posKeys.transaction(params.tenant_id, params.transaction_id), + queryFn: () => posService.getPOSTransaction(params), + enabled: !!params.tenant_id && !!params.transaction_id, + staleTime: 5 * 60 * 1000, // 5 minutes + ...options, + }); +}; + +// ============================================================================ +// SYNC OPERATIONS +// ============================================================================ + +/** + * Trigger manual sync + */ +export const useTriggerManualSync = ( + options?: UseMutationOptions< + { + sync_id: string; + message: string; + status: string; + sync_type: string; + data_types: string[]; + estimated_duration: string; + }, + ApiError, + { + tenant_id: string; + config_id: string; + sync_type?: 'full' | 'incremental'; + data_types?: string[]; + from_date?: string; + to_date?: string; + } + > +) => { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: (params) => posService.triggerManualSync(params), + onSuccess: (data, variables) => { + // Invalidate sync logs to show the new sync + queryClient.invalidateQueries({ + queryKey: posKeys.syncLogsList(variables.tenant_id) + }); + + // Invalidate configurations to update last sync info + queryClient.invalidateQueries({ + queryKey: posKeys.configurationsList(variables.tenant_id) + }); + }, + ...options, + }); +}; + +/** + * Get sync status for a configuration + */ +export const usePOSSyncStatus = ( + params: { + tenant_id: string; + config_id: string; + limit?: number; + }, + options?: Omit, 'queryKey' | 'queryFn'> +) => { + return useQuery({ + queryKey: [...posKeys.configurations(), 'sync-status', params.tenant_id, params.config_id], + queryFn: () => posService.getSyncStatus(params), + enabled: !!params.tenant_id && !!params.config_id, + staleTime: 30 * 1000, // 30 seconds - sync status should be fresh + ...options, + }); +}; + +/** + * Get detailed sync logs for a configuration + */ +export const useDetailedSyncLogs = ( + params: { + tenant_id: string; + config_id: string; + limit?: number; + offset?: number; + status?: string; + sync_type?: string; + data_type?: string; + }, + options?: Omit, 'queryKey' | 'queryFn'> +) => { + return useQuery({ + queryKey: [...posKeys.syncLogs(), 'detailed', params.tenant_id, params.config_id, params], + queryFn: () => posService.getDetailedSyncLogs(params), + enabled: !!params.tenant_id && !!params.config_id, + staleTime: 30 * 1000, // 30 seconds + ...options, + }); +}; + +/** + * Sync single transaction + */ +export const useSyncSingleTransaction = ( + options?: UseMutationOptions< + { message: string; transaction_id: string; sync_status: string; sales_record_id: string }, + ApiError, + { tenant_id: string; transaction_id: string; force?: boolean } + > +) => { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: (params) => posService.syncSingleTransaction(params), + onSuccess: (data, variables) => { + // Invalidate transactions list to update sync status + queryClient.invalidateQueries({ + queryKey: posKeys.transactionsList(variables.tenant_id) + }); + + // Invalidate specific transaction + queryClient.invalidateQueries({ + queryKey: posKeys.transaction(variables.tenant_id, variables.transaction_id) + }); + }, + ...options, + }); +}; + +/** + * Get sync performance analytics + */ +export const usePOSSyncAnalytics = ( + params: { + tenant_id: string; + days?: number; + }, + options?: Omit, 'queryKey' | 'queryFn'> +) => { + return useQuery({ + queryKey: [...posKeys.all, 'analytics', params.tenant_id, params.days], + queryFn: () => posService.getSyncAnalytics(params), + enabled: !!params.tenant_id, + staleTime: 5 * 60 * 1000, // 5 minutes - analytics don't change frequently + ...options, + }); +}; + +/** + * Resync failed transactions + */ +export const useResyncFailedTransactions = ( + options?: UseMutationOptions< + { message: string; job_id: string; scope: string; estimated_transactions: number }, + ApiError, + { tenant_id: string; days_back?: number } + > +) => { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: (params) => posService.resyncFailedTransactions(params), + onSuccess: (data, variables) => { + // Invalidate sync logs and analytics + queryClient.invalidateQueries({ + queryKey: posKeys.syncLogsList(variables.tenant_id) + }); + queryClient.invalidateQueries({ + queryKey: [...posKeys.all, 'analytics', variables.tenant_id] + }); + }, + ...options, + }); +}; + +/** + * Get sync logs + */ +export const usePOSSyncLogs = ( + params: { + tenant_id: string; + config_id?: string; + status?: string; + limit?: number; + offset?: number; + }, + options?: Omit, 'queryKey' | 'queryFn'> +) => { + return useQuery({ + queryKey: posKeys.syncLogsList(params.tenant_id, params), + queryFn: () => posService.getSyncLogs(params), + enabled: !!params.tenant_id, + staleTime: 30 * 1000, // 30 seconds + ...options, + }); +}; + +// ============================================================================ +// WEBHOOK LOGS +// ============================================================================ + +/** + * Get webhook logs + */ +export const usePOSWebhookLogs = ( + params: { + tenant_id: string; + pos_system?: POSSystem; + status?: string; + limit?: number; + offset?: number; + }, + options?: Omit, 'queryKey' | 'queryFn'> +) => { + return useQuery({ + queryKey: posKeys.webhookLogsList(params.tenant_id, params), + queryFn: () => posService.getWebhookLogs(params), + enabled: !!params.tenant_id, + staleTime: 30 * 1000, // 30 seconds + ...options, + }); +}; + +/** + * Get webhook status for a POS system + */ +export const useWebhookStatus = ( + pos_system: POSSystem, + options?: Omit, 'queryKey' | 'queryFn'> +) => { + return useQuery({ + queryKey: [...posKeys.webhookLogs(), 'status', pos_system], + queryFn: () => posService.getWebhookStatus(pos_system), + enabled: !!pos_system, + staleTime: 5 * 60 * 1000, // 5 minutes - webhook status doesn't change often + ...options, + }); +}; + +// ============================================================================ +// UTILITY HOOKS +// ============================================================================ + +/** + * Hook to get POS service utility functions + */ +export const usePOSUtils = () => { + return { + formatPrice: posService.formatPrice, + getPOSSystemDisplayName: posService.getPOSSystemDisplayName, + getConnectionStatusColor: posService.getConnectionStatusColor, + getSyncStatusColor: posService.getSyncStatusColor, + formatSyncInterval: posService.formatSyncInterval, + validateCredentials: posService.validateCredentials, + }; +}; + +// ============================================================================ +// COMPOSITE HOOKS (Convenience) +// ============================================================================ + +/** + * Hook that combines configurations and supported systems for the configuration UI + */ +export const usePOSConfigurationData = (tenantId: string) => { + const configurationsQuery = usePOSConfigurations( + { tenant_id: tenantId }, + { enabled: !!tenantId } + ); + + const supportedSystemsQuery = useSupportedPOSSystems(); + + return { + configurations: configurationsQuery.data?.configurations || [], + supportedSystems: supportedSystemsQuery.data?.systems || [], + isLoading: configurationsQuery.isLoading || supportedSystemsQuery.isLoading, + error: configurationsQuery.error || supportedSystemsQuery.error, + refetch: () => { + configurationsQuery.refetch(); + supportedSystemsQuery.refetch(); + }, + }; +}; + +/** + * Hook for POS configuration management with all CRUD operations + */ +export const usePOSConfigurationManager = (tenantId: string) => { + const utils = usePOSUtils(); + + const createMutation = useCreatePOSConfiguration(); + const updateMutation = useUpdatePOSConfiguration(); + const deleteMutation = useDeletePOSConfiguration(); + const testConnectionMutation = useTestPOSConnection(); + + return { + // Utility functions + ...utils, + + // Mutations + createConfiguration: createMutation.mutateAsync, + updateConfiguration: updateMutation.mutateAsync, + deleteConfiguration: deleteMutation.mutateAsync, + testConnection: testConnectionMutation.mutateAsync, + + // Mutation 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, + }; +}; \ No newline at end of file diff --git a/frontend/src/api/index.ts b/frontend/src/api/index.ts index fb837873..0582b5ea 100644 --- a/frontend/src/api/index.ts +++ b/frontend/src/api/index.ts @@ -27,6 +27,7 @@ export { suppliersService } from './services/suppliers'; export { OrdersService } from './services/orders'; export { forecastingService } from './services/forecasting'; export { productionService } from './services/production'; +export { posService } from './services/pos'; // Types - Auth export type { @@ -344,6 +345,33 @@ export { ProductionPriorityEnum, } from './types/production'; +// Types - POS +export type { + POSConfiguration, + POSTransaction, + POSTransactionItem, + POSWebhookLog, + POSSyncLog, + POSSystemInfo, + POSProviderConfig, + POSCredentialsField, + GetPOSConfigurationsRequest, + GetPOSConfigurationsResponse, + CreatePOSConfigurationRequest, + CreatePOSConfigurationResponse, + UpdatePOSConfigurationRequest, + UpdatePOSConfigurationResponse, + TestPOSConnectionRequest, + TestPOSConnectionResponse, + POSSyncSettings, + SyncHealth, + SyncAnalytics, + TransactionSummary, + WebhookStatus, + POSSystem, + POSEnvironment, +} from './types/pos'; + // Hooks - Auth export { useAuthProfile, @@ -632,6 +660,32 @@ export { productionKeys, } from './hooks/production'; +// Hooks - POS +export { + usePOSConfigurations, + usePOSConfiguration, + useSupportedPOSSystems, + useCreatePOSConfiguration, + useUpdatePOSConfiguration, + useDeletePOSConfiguration, + useTestPOSConnection, + usePOSTransactions, + usePOSTransaction, + useTriggerManualSync, + usePOSSyncStatus, + useDetailedSyncLogs, + useSyncSingleTransaction, + usePOSSyncAnalytics, + useResyncFailedTransactions, + usePOSSyncLogs, + usePOSWebhookLogs, + useWebhookStatus, + usePOSUtils, + usePOSConfigurationData, + usePOSConfigurationManager, + posKeys, +} from './hooks/pos'; + // Query Key Factories (for advanced usage) export { authKeys, @@ -650,4 +704,5 @@ export { dataImportKeys, forecastingKeys, productionKeys, + posKeys, }; \ No newline at end of file diff --git a/frontend/src/api/services/auth.ts b/frontend/src/api/services/auth.ts index de49e2d9..1efef2bf 100644 --- a/frontend/src/api/services/auth.ts +++ b/frontend/src/api/services/auth.ts @@ -62,11 +62,11 @@ export class AuthService { } async getProfile(): Promise { - return apiClient.get(`${this.baseUrl}/profile`); + return apiClient.get('/users/me'); } async updateProfile(updateData: UserUpdate): Promise { - return apiClient.put(`${this.baseUrl}/profile`, updateData); + return apiClient.put('/users/me', updateData); } async verifyEmail( diff --git a/frontend/src/api/services/pos.ts b/frontend/src/api/services/pos.ts new file mode 100644 index 00000000..cf84e4c2 --- /dev/null +++ b/frontend/src/api/services/pos.ts @@ -0,0 +1,557 @@ +/** + * POS Service + * Handles all POS configuration and management API calls + * Based on services/pos/app/api/pos_config.py backend implementation + */ + +import { apiClient } from '../client'; +import type { + POSConfiguration, + POSTransaction, + POSWebhookLog, + POSSyncLog, + POSSystemInfo, + GetPOSConfigurationsRequest, + GetPOSConfigurationsResponse, + CreatePOSConfigurationRequest, + CreatePOSConfigurationResponse, + GetPOSConfigurationRequest, + GetPOSConfigurationResponse, + UpdatePOSConfigurationRequest, + UpdatePOSConfigurationResponse, + DeletePOSConfigurationRequest, + DeletePOSConfigurationResponse, + TestPOSConnectionRequest, + TestPOSConnectionResponse, + GetSupportedPOSSystemsResponse, + POSSystem, +} from '../types/pos'; + +export class POSService { + private readonly basePath = '/pos'; + + // ============================================================================ + // POS CONFIGURATIONS + // ============================================================================ + + /** + * Get POS configurations for a tenant + */ + async getPOSConfigurations(params: GetPOSConfigurationsRequest): Promise { + const { tenant_id, pos_system, is_active } = params; + + const queryParams = new URLSearchParams(); + if (pos_system) queryParams.append('pos_system', pos_system); + if (is_active !== undefined) queryParams.append('is_active', is_active.toString()); + + const url = `/tenants/${tenant_id}${this.basePath}/configurations${queryParams.toString() ? `?${queryParams.toString()}` : ''}`; + + return apiClient.get(url); + } + + /** + * Create a new POS configuration + */ + async createPOSConfiguration(params: CreatePOSConfigurationRequest): Promise { + const { tenant_id, ...configData } = params; + const url = `/tenants/${tenant_id}${this.basePath}/configurations`; + + return apiClient.post(url, configData); + } + + /** + * Get a specific POS configuration + */ + async getPOSConfiguration(params: GetPOSConfigurationRequest): Promise { + const { tenant_id, config_id } = params; + const url = `/tenants/${tenant_id}${this.basePath}/configurations/${config_id}`; + + return apiClient.get(url); + } + + /** + * Update a POS configuration + */ + async updatePOSConfiguration(params: UpdatePOSConfigurationRequest): Promise { + const { tenant_id, config_id, ...updateData } = params; + const url = `/tenants/${tenant_id}${this.basePath}/configurations/${config_id}`; + + return apiClient.put(url, updateData); + } + + /** + * Delete a POS configuration + */ + async deletePOSConfiguration(params: DeletePOSConfigurationRequest): Promise { + const { tenant_id, config_id } = params; + const url = `/tenants/${tenant_id}${this.basePath}/configurations/${config_id}`; + + return apiClient.delete(url); + } + + /** + * Test connection to POS system + */ + async testPOSConnection(params: TestPOSConnectionRequest): Promise { + const { tenant_id, config_id } = params; + const url = `/tenants/${tenant_id}${this.basePath}/configurations/${config_id}/test-connection`; + + return apiClient.post(url); + } + + // ============================================================================ + // SUPPORTED SYSTEMS + // ============================================================================ + + /** + * Get list of supported POS systems + */ + async getSupportedPOSSystems(): Promise { + const url = `${this.basePath}/supported-systems`; + return apiClient.get(url); + } + + // ============================================================================ + // TRANSACTIONS (Future Implementation) + // ============================================================================ + + /** + * Get POS transactions for a tenant (Updated with backend structure) + */ + async getPOSTransactions(params: { + tenant_id: string; + 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 { tenant_id, ...queryParams } = params; + const searchParams = new URLSearchParams(); + + Object.entries(queryParams).forEach(([key, value]) => { + if (value !== undefined) { + searchParams.append(key, value.toString()); + } + }); + + const url = `/tenants/${tenant_id}${this.basePath}/transactions${searchParams.toString() ? `?${searchParams.toString()}` : ''}`; + + return apiClient.get(url); + } + + /** + * Sync a single transaction to sales service + */ + async syncSingleTransaction(params: { + tenant_id: string; + transaction_id: string; + force?: boolean; + }): Promise<{ + message: string; + transaction_id: string; + sync_status: string; + sales_record_id: string; + }> { + const { tenant_id, transaction_id, force } = params; + const queryParams = new URLSearchParams(); + if (force) queryParams.append('force', force.toString()); + + const url = `/tenants/${tenant_id}${this.basePath}/transactions/${transaction_id}/sync${queryParams.toString() ? `?${queryParams.toString()}` : ''}`; + + return apiClient.post(url); + } + + /** + * Get sync performance analytics + */ + async getSyncAnalytics(params: { + tenant_id: string; + days?: number; + }): Promise<{ + 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: any[]; + error_trends: any[]; + }; + }> { + const { tenant_id, days } = params; + const queryParams = new URLSearchParams(); + if (days) queryParams.append('days', days.toString()); + + const url = `/tenants/${tenant_id}${this.basePath}/analytics/sync-performance${queryParams.toString() ? `?${queryParams.toString()}` : ''}`; + + return apiClient.get(url); + } + + /** + * Resync failed transactions + */ + async resyncFailedTransactions(params: { + tenant_id: string; + days_back?: number; + }): Promise<{ + message: string; + job_id: string; + scope: string; + estimated_transactions: number; + }> { + const { tenant_id, days_back } = params; + const queryParams = new URLSearchParams(); + if (days_back) queryParams.append('days_back', days_back.toString()); + + const url = `/tenants/${tenant_id}${this.basePath}/data/resync${queryParams.toString() ? `?${queryParams.toString()}` : ''}`; + + return apiClient.post(url); + } + + /** + * Get a specific POS transaction + */ + async getPOSTransaction(params: { + tenant_id: string; + transaction_id: string; + }): Promise<{ + transaction: POSTransaction; + }> { + const { tenant_id, transaction_id } = params; + const url = `/tenants/${tenant_id}${this.basePath}/transactions/${transaction_id}`; + + return apiClient.get(url); + } + + // ============================================================================ + // SYNC OPERATIONS (Future Implementation) + // ============================================================================ + + /** + * Trigger manual sync for a POS configuration + */ + async triggerManualSync(params: { + tenant_id: string; + config_id: string; + sync_type?: 'full' | 'incremental'; + data_types?: string[]; + from_date?: string; + to_date?: string; + }): Promise<{ + sync_id: string; + message: string; + status: string; + sync_type: string; + data_types: string[]; + estimated_duration: string; + }> { + const { tenant_id, config_id, ...syncData } = params; + const url = `/tenants/${tenant_id}${this.basePath}/configurations/${config_id}/sync`; + + return apiClient.post(url, syncData); + } + + /** + * Get sync status for a POS configuration + */ + async getSyncStatus(params: { + tenant_id: string; + config_id: string; + limit?: number; + }): Promise<{ + current_sync: any; + last_successful_sync: any; + recent_syncs: any[]; + sync_health: { + status: string; + success_rate: number; + average_duration_minutes: number; + last_error?: string; + }; + }> { + const { tenant_id, config_id, limit } = params; + const queryParams = new URLSearchParams(); + if (limit) queryParams.append('limit', limit.toString()); + + const url = `/tenants/${tenant_id}${this.basePath}/configurations/${config_id}/sync/status${queryParams.toString() ? `?${queryParams.toString()}` : ''}`; + + return apiClient.get(url); + } + + /** + * Get detailed sync logs for a configuration + */ + async getDetailedSyncLogs(params: { + tenant_id: string; + config_id: string; + limit?: number; + offset?: number; + status?: string; + sync_type?: string; + data_type?: string; + }): Promise<{ + logs: any[]; + total: number; + has_more: boolean; + }> { + const { tenant_id, config_id, ...queryParams } = params; + const searchParams = new URLSearchParams(); + + Object.entries(queryParams).forEach(([key, value]) => { + if (value !== undefined) { + searchParams.append(key, value.toString()); + } + }); + + const url = `/tenants/${tenant_id}${this.basePath}/configurations/${config_id}/sync/logs${searchParams.toString() ? `?${searchParams.toString()}` : ''}`; + + return apiClient.get(url); + } + + /** + * Get sync logs for a POS configuration + */ + async getSyncLogs(params: { + tenant_id: string; + config_id?: string; + status?: string; + limit?: number; + offset?: number; + }): Promise<{ + sync_logs: POSSyncLog[]; + total: number; + has_more: boolean; + }> { + const { tenant_id, ...queryParams } = params; + const searchParams = new URLSearchParams(); + + Object.entries(queryParams).forEach(([key, value]) => { + if (value !== undefined) { + searchParams.append(key, value.toString()); + } + }); + + const url = `/tenants/${tenant_id}${this.basePath}/sync-logs${searchParams.toString() ? `?${searchParams.toString()}` : ''}`; + + return apiClient.get(url); + } + + // ============================================================================ + // WEBHOOKS + // ============================================================================ + + /** + * Get webhook logs + */ + async getWebhookLogs(params: { + tenant_id: string; + pos_system?: POSSystem; + status?: string; + limit?: number; + offset?: number; + }): Promise<{ + webhook_logs: POSWebhookLog[]; + total: number; + has_more: boolean; + }> { + const { tenant_id, ...queryParams } = params; + const searchParams = new URLSearchParams(); + + Object.entries(queryParams).forEach(([key, value]) => { + if (value !== undefined) { + searchParams.append(key, value.toString()); + } + }); + + const url = `/tenants/${tenant_id}${this.basePath}/webhook-logs${searchParams.toString() ? `?${searchParams.toString()}` : ''}`; + + return apiClient.get(url); + } + + /** + * Get webhook endpoint status for a POS system + */ + async getWebhookStatus(pos_system: POSSystem): Promise<{ + pos_system: string; + status: string; + endpoint: string; + supported_events: { + events: string[]; + format: string; + authentication: string; + }; + last_received?: string; + total_received: number; + }> { + const url = `/webhooks/${pos_system}/status`; + return apiClient.get(url); + } + + /** + * Process webhook (typically called by POS systems, but useful for testing) + */ + async processWebhook(params: { + pos_system: POSSystem; + payload: any; + signature?: string; + headers?: Record; + }): Promise<{ + status: string; + message?: string; + success?: boolean; + received?: boolean; + }> { + const { pos_system, payload, signature, headers = {} } = params; + + const requestHeaders: Record = { + 'Content-Type': 'application/json', + ...headers, + }; + + if (signature) { + requestHeaders['X-Webhook-Signature'] = signature; + } + + const url = `/webhooks/${pos_system}`; + + // Note: This would typically be called by the POS system, not the frontend + // This method is mainly for testing webhook processing + return apiClient.post(url, payload); + } + + // ============================================================================ + // UTILITY METHODS + // ============================================================================ + + /** + * Format price for display + */ + formatPrice(amount: number, currency: string = 'EUR'): string { + return new Intl.NumberFormat('es-ES', { + style: 'currency', + currency: currency, + }).format(amount); + } + + /** + * Get POS system display name + */ + getPOSSystemDisplayName(posSystem: POSSystem): string { + const systemNames: Record = { + square: 'Square POS', + toast: 'Toast POS', + lightspeed: 'Lightspeed POS', + }; + + return systemNames[posSystem] || posSystem; + } + + /** + * Get connection status color for UI + */ + getConnectionStatusColor(isConnected: boolean, healthStatus?: string): 'green' | 'yellow' | 'red' { + if (!isConnected) return 'red'; + if (healthStatus === 'healthy') return 'green'; + if (healthStatus === 'unhealthy') return 'red'; + return 'yellow'; // unknown status + } + + /** + * Get sync status color for UI + */ + getSyncStatusColor(status?: string): 'green' | 'yellow' | 'red' { + switch (status) { + case 'success': + return 'green'; + case 'failed': + return 'red'; + case 'partial': + return 'yellow'; + default: + return 'yellow'; + } + } + + /** + * Format sync interval for display + */ + formatSyncInterval(minutes: string): string { + const mins = parseInt(minutes); + if (mins < 60) { + return `${mins} minutos`; + } else if (mins < 1440) { + const hours = Math.floor(mins / 60); + return hours === 1 ? '1 hora' : `${hours} horas`; + } else { + const days = Math.floor(mins / 1440); + return days === 1 ? '1 día' : `${days} días`; + } + } + + /** + * Validate POS credentials based on system type + */ + validateCredentials(posSystem: POSSystem, credentials: Record): { + isValid: boolean; + errors: string[]; + } { + const errors: string[] = []; + + switch (posSystem) { + case 'square': + if (!credentials.application_id) errors.push('Application ID es requerido'); + if (!credentials.access_token) errors.push('Access Token es requerido'); + if (!credentials.location_id) errors.push('Location ID es requerido'); + break; + + case 'toast': + if (!credentials.api_key) errors.push('API Key es requerido'); + if (!credentials.restaurant_guid) errors.push('Restaurant GUID es requerido'); + if (!credentials.location_id) errors.push('Location ID es requerido'); + break; + + case 'lightspeed': + if (!credentials.api_key) errors.push('API Key es requerido'); + if (!credentials.api_secret) errors.push('API Secret es requerido'); + if (!credentials.account_id) errors.push('Account ID es requerido'); + if (!credentials.shop_id) errors.push('Shop ID es requerido'); + break; + + default: + errors.push('Sistema POS no soportado'); + } + + return { + isValid: errors.length === 0, + errors, + }; + } +} + +// Export singleton instance +export const posService = new POSService(); +export default posService; \ No newline at end of file diff --git a/frontend/src/api/types/pos.ts b/frontend/src/api/types/pos.ts new file mode 100644 index 00000000..3d45079c --- /dev/null +++ b/frontend/src/api/types/pos.ts @@ -0,0 +1,697 @@ +/** + * POS API Types + * Based on services/pos/app/models/ backend implementation + */ + +// ============================================================================ +// CORE POS TYPES +// ============================================================================ + +export type POSSystem = 'square' | 'toast' | 'lightspeed'; + +export type POSEnvironment = 'sandbox' | 'production'; + +export type POSTransactionType = 'sale' | 'refund' | 'void' | 'exchange'; + +export type POSTransactionStatus = 'completed' | 'pending' | 'failed' | 'refunded' | 'voided'; + +export type POSPaymentMethod = 'card' | 'cash' | 'digital_wallet' | 'other'; + +export type POSPaymentStatus = 'paid' | 'pending' | 'failed'; + +export type POSOrderType = 'dine_in' | 'takeout' | 'delivery' | 'pickup'; + +export type POSSyncStatus = 'success' | 'failed' | 'partial'; + +export type POSHealthStatus = 'healthy' | 'unhealthy' | 'unknown'; + +export type POSSyncType = 'full' | 'incremental' | 'manual' | 'webhook_triggered'; + +export type POSSyncDirection = 'inbound' | 'outbound' | 'bidirectional'; + +export type POSDataType = 'transactions' | 'products' | 'customers' | 'orders'; + +export type POSWebhookStatus = 'received' | 'processing' | 'processed' | 'failed'; + +// ============================================================================ +// POS CONFIGURATION +// ============================================================================ + +export interface POSConfiguration { + // Primary identifiers + id: string; + tenant_id: string; + + // POS Provider Information + pos_system: POSSystem; + provider_name: string; + + // Configuration Status + is_active: boolean; + is_connected: boolean; + + // Authentication & Credentials (encrypted) + encrypted_credentials?: string; + webhook_url?: string; + webhook_secret?: string; + + // Provider-specific Settings + environment: POSEnvironment; + location_id?: string; + merchant_id?: string; + + // Sync Configuration + sync_enabled: boolean; + sync_interval_minutes: string; + auto_sync_products: boolean; + auto_sync_transactions: boolean; + + // Last Sync Information + last_sync_at?: string; + last_successful_sync_at?: string; + last_sync_status?: POSSyncStatus; + last_sync_message?: string; + + // Provider-specific Configuration (JSON) + provider_settings?: Record; + + // Connection Health + last_health_check_at?: string; + health_status: POSHealthStatus; + health_message?: string; + + // Timestamps + created_at: string; + updated_at: string; + + // Metadata + created_by?: string; + notes?: string; +} + +// ============================================================================ +// POS TRANSACTIONS +// ============================================================================ + +export interface POSTransaction { + // Primary identifiers + id: string; + tenant_id: string; + pos_config_id: string; + + // POS Provider Information + pos_system: POSSystem; + external_transaction_id: string; + external_order_id?: string; + + // Transaction Details + transaction_type: POSTransactionType; + status: POSTransactionStatus; + + // Financial Information + subtotal: number; + tax_amount: number; + tip_amount: number; + discount_amount: number; + total_amount: number; + currency: string; + + // Payment Information + payment_method?: POSPaymentMethod; + payment_status?: POSPaymentStatus; + + // Transaction Timing + transaction_date: string; + pos_created_at: string; + pos_updated_at?: string; + + // Location & Staff + location_id?: string; + location_name?: string; + staff_id?: string; + staff_name?: string; + + // Customer Information + customer_id?: string; + customer_email?: string; + customer_phone?: string; + + // Order Context + order_type?: POSOrderType; + table_number?: string; + receipt_number?: string; + + // Sync Status + is_synced_to_sales: boolean; + sales_record_id?: string; + sync_attempted_at?: string; + sync_completed_at?: string; + sync_error?: string; + sync_retry_count: number; + + // Raw Data + raw_data?: Record; + + // Processing Status + is_processed: boolean; + processing_error?: string; + + // Duplicate Detection + is_duplicate: boolean; + duplicate_of?: string; + + // Timestamps + created_at: string; + updated_at: string; + + // Related Items + items?: POSTransactionItem[]; +} + +export interface POSTransactionItem { + // Primary identifiers + id: string; + transaction_id: string; + tenant_id: string; + + // POS Item Information + external_item_id?: string; + sku?: string; + + // Product Details + product_name: string; + product_category?: string; + product_subcategory?: string; + + // Quantity & Pricing + quantity: number; + unit_price: number; + total_price: number; + + // Discounts & Modifiers + discount_amount: number; + tax_amount: number; + + // Modifiers (e.g., extra shot, no foam for coffee) + modifiers?: Record; + + // Inventory Mapping + inventory_product_id?: string; + is_mapped_to_inventory: boolean; + + // Sync Status + is_synced_to_sales: boolean; + sync_error?: string; + + // Raw Data + raw_data?: Record; + + // Timestamps + created_at: string; + updated_at: string; +} + +// ============================================================================ +// POS WEBHOOK LOGS +// ============================================================================ + +export interface POSWebhookLog { + // Primary identifiers + id: string; + tenant_id?: string; + + // POS Provider Information + pos_system: POSSystem; + webhook_type: string; + + // Request Information + method: string; + url_path: string; + query_params?: Record; + headers?: Record; + + // Payload + raw_payload: string; + payload_size: number; + content_type?: string; + + // Security + signature?: string; + is_signature_valid?: boolean; + source_ip?: string; + + // Processing Status + status: POSWebhookStatus; + processing_started_at?: string; + processing_completed_at?: string; + processing_duration_ms?: number; + + // Error Handling + error_message?: string; + error_code?: string; + retry_count: number; + max_retries: number; + + // Response Information + response_status_code?: number; + response_body?: string; + response_sent_at?: string; + + // Event Metadata + event_id?: string; + event_timestamp?: string; + sequence_number?: number; + + // Business Data References + transaction_id?: string; + order_id?: string; + customer_id?: string; + + // Internal References + created_transaction_id?: string; + updated_transaction_id?: string; + + // Duplicate Detection + is_duplicate: boolean; + duplicate_of?: string; + + // Processing Priority + priority: string; + + // Debugging Information + user_agent?: string; + forwarded_for?: string; + request_id?: string; + + // Timestamps + received_at: string; + created_at: string; + updated_at: string; +} + +// ============================================================================ +// POS SYNC LOGS +// ============================================================================ + +export interface POSSyncLog { + // Primary identifiers + id: string; + tenant_id: string; + pos_config_id: string; + + // Sync Operation Details + sync_type: POSSyncType; + sync_direction: POSSyncDirection; + data_type: POSDataType; + + // POS Provider Information + pos_system: POSSystem; + + // Sync Status + status: string; // started, in_progress, completed, failed, cancelled + + // Timing Information + started_at: string; + completed_at?: string; + duration_seconds?: number; + + // Date Range for Sync + sync_from_date?: string; + sync_to_date?: string; + + // Statistics + records_requested: number; + records_processed: number; + records_created: number; + records_updated: number; + records_skipped: number; + records_failed: number; + + // API Usage Statistics + api_calls_made: number; + api_rate_limit_hits: number; + total_api_time_ms: number; + + // Error Information + error_message?: string; + error_code?: string; + error_details?: Record; + + // Retry Information + retry_attempt: number; + max_retries: number; + parent_sync_id?: string; + + // Configuration Snapshot + sync_configuration?: Record; + + // Progress Tracking + current_page?: number; + total_pages?: number; + current_batch?: number; + total_batches?: number; + progress_percentage?: number; + + // Data Quality + validation_errors?: Record[]; + data_quality_score?: number; + + // Performance Metrics + memory_usage_mb?: number; + cpu_usage_percentage?: number; + network_bytes_received?: number; + network_bytes_sent?: number; + + // Business Impact + revenue_synced?: number; + transactions_synced: number; + + // Trigger Information + triggered_by?: string; // system, user, webhook, schedule + triggered_by_user_id?: string; + trigger_details?: Record; + + // External References + external_batch_id?: string; + webhook_log_id?: string; + + // Timestamps + created_at: string; + updated_at: string; + + // Metadata + notes?: string; + tags?: string[]; +} + +// ============================================================================ +// SUPPORTED POS SYSTEMS +// ============================================================================ + +export interface POSSystemInfo { + id: POSSystem; + name: string; + description: string; + features: string[]; + supported_regions: string[]; +} + +export interface POSCredentialsField { + field: string; + label: string; + type: 'text' | 'password' | 'url' | 'select'; + placeholder?: string; + required: boolean; + help_text?: string; + options?: { value: string; label: string }[]; +} + +export interface POSProviderConfig { + id: POSSystem; + name: string; + logo: string; + description: string; + features: string[]; + required_fields: POSCredentialsField[]; +} + +// ============================================================================ +// SYNC ANALYTICS & PERFORMANCE TYPES +// ============================================================================ + +export interface SyncHealth { + status: string; + success_rate: number; + average_duration_minutes: number; + last_error?: string; +} + +export interface SyncFrequency { + daily_average: number; + peak_day?: string; + peak_count: number; +} + +export interface ErrorAnalysis { + common_errors: any[]; + error_trends: any[]; +} + +export interface SyncAnalytics { + 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: SyncFrequency; + error_analysis: ErrorAnalysis; +} + +export interface TransactionSummary { + total_amount: number; + transaction_count: number; + sync_status: { + synced: number; + pending: number; + failed: number; + }; +} + +// ============================================================================ +// WEBHOOK TYPES +// ============================================================================ + +export interface WebhookSupportedEvents { + events: string[]; + format: string; + authentication: string; +} + +export interface WebhookStatus { + pos_system: string; + status: string; + endpoint: string; + supported_events: WebhookSupportedEvents; + last_received?: string; + total_received: number; +} + +// ============================================================================ +// BASE POS CLIENT TYPES (matching backend integrations) +// ============================================================================ + +export interface POSCredentialsData { + pos_system: POSSystem; + environment: POSEnvironment; + api_key?: string; + api_secret?: string; + access_token?: string; + application_id?: string; + merchant_id?: string; + location_id?: string; + webhook_secret?: string; + additional_params?: Record; +} + +export interface ClientPOSTransaction { + external_id: string; + transaction_type: string; + status: string; + total_amount: number; + subtotal: number; + tax_amount: number; + tip_amount: number; + discount_amount: number; + currency: string; + transaction_date: string; + payment_method?: string; + payment_status?: string; + location_id?: string; + location_name?: string; + staff_id?: string; + staff_name?: string; + customer_id?: string; + customer_email?: string; + order_type?: string; + table_number?: string; + receipt_number?: string; + external_order_id?: string; + items: ClientPOSTransactionItem[]; + raw_data: Record; +} + +export interface ClientPOSTransactionItem { + external_id?: string; + sku?: string; + name: string; + category?: string; + quantity: number; + unit_price: number; + total_price: number; + discount_amount: number; + tax_amount: number; + modifiers?: Record; + raw_data?: Record; +} + +export interface ClientPOSProduct { + external_id: string; + name: string; + sku?: string; + category?: string; + subcategory?: string; + price: number; + description?: string; + is_active: boolean; + raw_data: Record; +} + +export interface SyncResult { + success: boolean; + records_processed: number; + records_created: number; + records_updated: number; + records_skipped: number; + records_failed: number; + errors: string[]; + warnings: string[]; + duration_seconds: number; + api_calls_made: number; +} + +// ============================================================================ +// API REQUEST/RESPONSE TYPES +// ============================================================================ + +// GET /tenants/{tenant_id}/pos/configurations +export interface GetPOSConfigurationsRequest { + tenant_id: string; + pos_system?: POSSystem; + is_active?: boolean; +} + +export interface GetPOSConfigurationsResponse { + configurations: POSConfiguration[]; + total: number; + supported_systems: POSSystem[]; +} + +// POST /tenants/{tenant_id}/pos/configurations +export interface CreatePOSConfigurationRequest { + tenant_id: string; + pos_system: POSSystem; + provider_name: string; + environment: POSEnvironment; + credentials: Record; + sync_settings?: { + auto_sync_enabled: boolean; + sync_interval_minutes: number; + sync_sales: boolean; + sync_inventory: boolean; + sync_customers: boolean; + }; + webhook_url?: string; + location_id?: string; + merchant_id?: string; + notes?: string; +} + +export interface CreatePOSConfigurationResponse { + message: string; + id: string; + configuration?: POSConfiguration; +} + +// GET /tenants/{tenant_id}/pos/configurations/{config_id} +export interface GetPOSConfigurationRequest { + tenant_id: string; + config_id: string; +} + +export interface GetPOSConfigurationResponse { + configuration: POSConfiguration; +} + +// PUT /tenants/{tenant_id}/pos/configurations/{config_id} +export interface UpdatePOSConfigurationRequest { + tenant_id: string; + config_id: string; + provider_name?: string; + credentials?: Record; + sync_settings?: { + auto_sync_enabled?: boolean; + sync_interval_minutes?: number; + sync_sales?: boolean; + sync_inventory?: boolean; + sync_customers?: boolean; + }; + webhook_url?: string; + location_id?: string; + merchant_id?: string; + notes?: string; + is_active?: boolean; +} + +export interface UpdatePOSConfigurationResponse { + message: string; + configuration?: POSConfiguration; +} + +// DELETE /tenants/{tenant_id}/pos/configurations/{config_id} +export interface DeletePOSConfigurationRequest { + tenant_id: string; + config_id: string; +} + +export interface DeletePOSConfigurationResponse { + message: string; + success: boolean; +} + +// POST /tenants/{tenant_id}/pos/configurations/{config_id}/test-connection +export interface TestPOSConnectionRequest { + tenant_id: string; + config_id: string; +} + +export interface TestPOSConnectionResponse { + success: boolean; + message: string; + tested_at: string; +} + +// GET /pos/supported-systems +export interface GetSupportedPOSSystemsResponse { + systems: POSSystemInfo[]; +} + +// ============================================================================ +// UTILITY TYPES +// ============================================================================ + +export interface POSApiError { + message: string; + status?: number; + code?: string; + details?: any; +} + +export interface POSSyncSettings { + auto_sync_enabled: boolean; + sync_interval_minutes: number; + sync_sales: boolean; + sync_inventory: boolean; + sync_customers: boolean; +} + +export interface POSConnectionTestResult { + success: boolean; + message: string; + tested_at: string; +} + +// For backward compatibility with existing BakeryConfigPage +export type { POSProviderConfig, POSSyncSettings as SyncSettings }; \ No newline at end of file diff --git a/frontend/src/components/domain/forecasting/AlertsPanel.tsx b/frontend/src/components/domain/forecasting/AlertsPanel.tsx index d6eb442f..8e622dc9 100644 --- a/frontend/src/components/domain/forecasting/AlertsPanel.tsx +++ b/frontend/src/components/domain/forecasting/AlertsPanel.tsx @@ -5,189 +5,64 @@ import { Button } from '../../ui'; import { Badge } from '../../ui'; import { Select } from '../../ui'; import { Input } from '../../ui'; -import { - ForecastAlert, - ForecastAlertType, - AlertSeverity, -} from '../../../types/forecasting.types'; +// Simple alert interface for component +interface SimpleAlert { + id: string; + type: string; + product: string; + message: string; + severity: 'high' | 'medium' | 'low' | 'info'; + recommendation?: string; +} export interface AlertsPanelProps { className?: string; title?: string; - alerts?: ForecastAlert[]; + alerts?: SimpleAlert[]; loading?: boolean; error?: string | null; - onAlertAction?: (alertId: string, action: 'acknowledge' | 'resolve' | 'snooze' | 'production_adjust' | 'inventory_check') => void; + onAlertAction?: (alertId: string, action: string) => void; onAlertDismiss?: (alertId: string) => void; - onBulkAction?: (alertIds: string[], action: string) => void; showFilters?: boolean; compact?: boolean; maxItems?: number; - autoRefresh?: boolean; - refreshInterval?: number; } interface AlertFilter { - severity: AlertSeverity | 'all'; - type: ForecastAlertType | 'all'; - status: 'active' | 'acknowledged' | 'resolved' | 'all'; + severity: 'high' | 'medium' | 'low' | 'info' | 'all'; + type: string; product: string; - dateRange: 'today' | 'week' | 'month' | 'all'; } -interface AlertActionGroup { - critical: AlertAction[]; - high: AlertAction[]; - medium: AlertAction[]; - low: AlertAction[]; -} - -interface AlertAction { - id: string; - label: string; - icon: string; - variant: 'primary' | 'secondary' | 'success' | 'warning' | 'danger'; - description: string; -} - -const SPANISH_ALERT_TYPES: Record = { - [ForecastAlertType.HIGH_DEMAND_PREDICTED]: 'Alta Demanda Predicha', - [ForecastAlertType.LOW_DEMAND_PREDICTED]: 'Baja Demanda Predicha', - [ForecastAlertType.ACCURACY_DROP]: 'Caída de Precisión', - [ForecastAlertType.MODEL_DRIFT]: 'Deriva del Modelo', - [ForecastAlertType.DATA_ANOMALY]: 'Anomalía de Datos', - [ForecastAlertType.MISSING_DATA]: 'Datos Faltantes', - [ForecastAlertType.SEASONAL_SHIFT]: 'Cambio Estacional', +const SPANISH_SEVERITIES: Record = { + 'high': 'Alta', + 'medium': 'Media', + 'low': 'Baja', + 'info': 'Info', }; -const SPANISH_SEVERITIES: Record = { - [AlertSeverity.CRITICAL]: 'Crítica', - [AlertSeverity.HIGH]: 'Alta', - [AlertSeverity.MEDIUM]: 'Media', - [AlertSeverity.LOW]: 'Baja', +const SEVERITY_COLORS: Record = { + 'high': 'text-[var(--color-error)] bg-red-50 border-red-200', + 'medium': 'text-yellow-600 bg-yellow-50 border-yellow-200', + 'low': 'text-[var(--color-info)] bg-[var(--color-info)]/5 border-[var(--color-info)]/20', + 'info': 'text-[var(--color-primary)] bg-blue-50 border-blue-200', }; -const SEVERITY_COLORS: Record = { - [AlertSeverity.CRITICAL]: 'text-[var(--color-error)] bg-red-50 border-red-200', - [AlertSeverity.HIGH]: 'text-[var(--color-primary)] bg-orange-50 border-orange-200', - [AlertSeverity.MEDIUM]: 'text-yellow-600 bg-yellow-50 border-yellow-200', - [AlertSeverity.LOW]: 'text-[var(--color-info)] bg-[var(--color-info)]/5 border-[var(--color-info)]/20', +const SEVERITY_BADGE_VARIANTS: Record = { + 'high': 'danger', + 'medium': 'warning', + 'low': 'info', + 'info': 'info', }; -const SEVERITY_BADGE_VARIANTS: Record = { - [AlertSeverity.CRITICAL]: 'danger', - [AlertSeverity.HIGH]: 'warning', - [AlertSeverity.MEDIUM]: 'warning', - [AlertSeverity.LOW]: 'info', +const ALERT_ICONS: Record = { + 'high-demand': '📈', + 'low-demand': '📉', + 'low-confidence': '🎯', + 'weather': '🌦️', + 'default': '⚠️', }; -const ALERT_TYPE_ICONS: Record = { - [ForecastAlertType.HIGH_DEMAND_PREDICTED]: '📈', - [ForecastAlertType.LOW_DEMAND_PREDICTED]: '📉', - [ForecastAlertType.ACCURACY_DROP]: '🎯', - [ForecastAlertType.MODEL_DRIFT]: '🔄', - [ForecastAlertType.DATA_ANOMALY]: '⚠️', - [ForecastAlertType.MISSING_DATA]: '📊', - [ForecastAlertType.SEASONAL_SHIFT]: '🍂', -}; - -const ALERT_ACTIONS: AlertActionGroup = { - critical: [ - { - id: 'production_adjust', - label: 'Ajustar Producción', - icon: '🏭', - variant: 'primary', - description: 'Modificar inmediatamente el plan de producción', - }, - { - id: 'inventory_check', - label: 'Verificar Inventario', - icon: '📦', - variant: 'warning', - description: 'Revisar niveles de stock y materias primas', - }, - { - id: 'emergency_order', - label: 'Pedido Urgente', - icon: '🚚', - variant: 'danger', - description: 'Realizar pedido urgente a proveedores', - }, - ], - high: [ - { - id: 'production_adjust', - label: 'Ajustar Producción', - icon: '🏭', - variant: 'primary', - description: 'Ajustar plan de producción para mañana', - }, - { - id: 'inventory_alert', - label: 'Alerta Inventario', - icon: '📋', - variant: 'warning', - description: 'Crear alerta de inventario preventiva', - }, - { - id: 'team_notify', - label: 'Notificar Equipo', - icon: '👥', - variant: 'secondary', - description: 'Informar al equipo de producción', - }, - ], - medium: [ - { - id: 'production_review', - label: 'Revisar Producción', - icon: '📊', - variant: 'secondary', - description: 'Revisar plan de producción esta semana', - }, - { - id: 'monitor', - label: 'Monitorear', - icon: '👁️', - variant: 'secondary', - description: 'Mantener bajo observación', - }, - ], - low: [ - { - id: 'monitor', - label: 'Monitorear', - icon: '👁️', - variant: 'secondary', - description: 'Mantener bajo observación', - }, - { - id: 'data_review', - label: 'Revisar Datos', - icon: '🔍', - variant: 'secondary', - description: 'Revisar calidad de los datos', - }, - ], -}; - -const RECOMMENDATION_MESSAGES: Record string> = { - [ForecastAlertType.HIGH_DEMAND_PREDICTED]: (product, value) => - `Se predice un aumento del ${value}% en la demanda de ${product}. Considera aumentar la producción.`, - [ForecastAlertType.LOW_DEMAND_PREDICTED]: (product, value) => - `Se predice una disminución del ${Math.abs(value)}% en la demanda de ${product}. Considera reducir la producción para evitar desperdicios.`, - [ForecastAlertType.ACCURACY_DROP]: (product, value) => - `La precisión del modelo para ${product} ha disminuido al ${value}%. Es recomendable reentrenar el modelo.`, - [ForecastAlertType.MODEL_DRIFT]: (product, value) => - `Detectada deriva en el modelo de ${product}. Los patrones de demanda han cambiado significativamente.`, - [ForecastAlertType.DATA_ANOMALY]: (product, value) => - `Anomalía detectada en los datos de ${product}. Verifica la calidad de los datos de entrada.`, - [ForecastAlertType.MISSING_DATA]: (product, value) => - `Faltan ${value} días de datos para ${product}. Esto puede afectar la precisión de las predicciones.`, - [ForecastAlertType.SEASONAL_SHIFT]: (product, value) => - `Detectado cambio en el patrón estacional de ${product}. El pico de demanda se ha adelantado/retrasado.`, -}; const AlertsPanel: React.FC = ({ className, @@ -197,90 +72,35 @@ const AlertsPanel: React.FC = ({ error = null, onAlertAction, onAlertDismiss, - onBulkAction, - showFilters = true, + showFilters = false, compact = false, maxItems, - autoRefresh = false, - refreshInterval = 30000, }) => { const [filters, setFilters] = useState({ severity: 'all', type: 'all', - status: 'active', product: '', - dateRange: 'all', }); - - const [selectedAlerts, setSelectedAlerts] = useState([]); - const [expandedAlerts, setExpandedAlerts] = useState([]); - // Filter alerts + // Filter and limit alerts const filteredAlerts = useMemo(() => { let filtered = [...alerts]; - // Filter by status - if (filters.status !== 'all') { - filtered = filtered.filter(alert => { - if (filters.status === 'active') return alert.is_active && !alert.acknowledged_at && !alert.resolved_at; - if (filters.status === 'acknowledged') return !!alert.acknowledged_at && !alert.resolved_at; - if (filters.status === 'resolved') return !!alert.resolved_at; - return true; - }); - } - // Filter by severity if (filters.severity !== 'all') { filtered = filtered.filter(alert => alert.severity === filters.severity); } - // Filter by type - if (filters.type !== 'all') { - filtered = filtered.filter(alert => alert.alert_type === filters.type); - } - // Filter by product if (filters.product) { filtered = filtered.filter(alert => - alert.product_name?.toLowerCase().includes(filters.product.toLowerCase()) + alert.product.toLowerCase().includes(filters.product.toLowerCase()) ); } - // Filter by date range - if (filters.dateRange !== 'all') { - const now = new Date(); - const filterDate = new Date(); - - switch (filters.dateRange) { - case 'today': - filterDate.setHours(0, 0, 0, 0); - break; - case 'week': - filterDate.setDate(now.getDate() - 7); - break; - case 'month': - filterDate.setMonth(now.getMonth() - 1); - break; - } - - filtered = filtered.filter(alert => new Date(alert.created_at) >= filterDate); - } - - // Sort by severity and creation date - filtered.sort((a, b) => { - const severityOrder: Record = { - [AlertSeverity.CRITICAL]: 4, - [AlertSeverity.HIGH]: 3, - [AlertSeverity.MEDIUM]: 2, - [AlertSeverity.LOW]: 1, - }; - - if (a.severity !== b.severity) { - return severityOrder[b.severity] - severityOrder[a.severity]; - } - - return new Date(b.created_at).getTime() - new Date(a.created_at).getTime(); - }); + // Sort by severity priority + const severityOrder = { 'high': 3, 'medium': 2, 'low': 1, 'info': 0 }; + filtered.sort((a, b) => severityOrder[b.severity] - severityOrder[a.severity]); // Limit items if specified if (maxItems) { @@ -290,73 +110,6 @@ const AlertsPanel: React.FC = ({ return filtered; }, [alerts, filters, maxItems]); - // Get alert statistics - const alertStats = useMemo(() => { - const stats = { - total: alerts.length, - active: alerts.filter(a => a.is_active && !a.acknowledged_at && !a.resolved_at).length, - critical: alerts.filter(a => a.severity === AlertSeverity.CRITICAL && a.is_active).length, - high: alerts.filter(a => a.severity === AlertSeverity.HIGH && a.is_active).length, - }; - return stats; - }, [alerts]); - - // Handle alert expansion - const toggleAlertExpansion = useCallback((alertId: string) => { - setExpandedAlerts(prev => - prev.includes(alertId) - ? prev.filter(id => id !== alertId) - : [...prev, alertId] - ); - }, []); - - // Handle alert selection - const toggleAlertSelection = useCallback((alertId: string) => { - setSelectedAlerts(prev => - prev.includes(alertId) - ? prev.filter(id => id !== alertId) - : [...prev, alertId] - ); - }, []); - - // Handle bulk selection - const handleSelectAll = useCallback(() => { - const activeAlerts = filteredAlerts.filter(a => a.is_active).map(a => a.id); - setSelectedAlerts(prev => - prev.length === activeAlerts.length ? [] : activeAlerts - ); - }, [filteredAlerts]); - - // Get available actions for alert - const getAlertActions = useCallback((alert: ForecastAlert): AlertAction[] => { - return ALERT_ACTIONS[alert.severity] || []; - }, []); - - // Get recommendation message - const getRecommendation = useCallback((alert: ForecastAlert): string => { - const generator = RECOMMENDATION_MESSAGES[alert.alert_type]; - if (!generator) return alert.message; - - const value = alert.predicted_value || alert.threshold_value || 0; - return generator(alert.product_name || 'Producto', value); - }, []); - - // Format time ago - const formatTimeAgo = useCallback((dateString: string): string => { - const date = new Date(dateString); - const now = new Date(); - const diffMs = now.getTime() - date.getTime(); - const diffMins = Math.floor(diffMs / (1000 * 60)); - const diffHours = Math.floor(diffMins / 60); - const diffDays = Math.floor(diffHours / 24); - - if (diffMins < 1) return 'Ahora mismo'; - if (diffMins < 60) return `Hace ${diffMins} min`; - if (diffHours < 24) return `Hace ${diffHours}h`; - if (diffDays < 7) return `Hace ${diffDays}d`; - return date.toLocaleDateString('es-ES'); - }, []); - // Loading state if (loading) { return ( @@ -403,110 +156,13 @@ const AlertsPanel: React.FC = ({
-
-

{title}

- {alertStats.critical > 0 && ( - - {alertStats.critical} críticas - - )} - {alertStats.high > 0 && ( - - {alertStats.high} altas - - )} - - {alertStats.active}/{alertStats.total} activas - -
- -
- {selectedAlerts.length > 0 && onBulkAction && ( -
- - -
- )} - - {autoRefresh && ( -
-
- Auto-actualización -
- )} -
+

{title}

+ + {filteredAlerts.length} alertas +
- {showFilters && ( -
-
- - - - - - - setFilters(prev => ({ ...prev, product: e.target.value }))} - className="text-sm" - /> - - -
-
- )} - {filteredAlerts.length === 0 ? (
@@ -521,200 +177,68 @@ const AlertsPanel: React.FC = ({
) : (
- {/* Bulk actions bar */} - {filteredAlerts.some(a => a.is_active) && ( -
- a.is_active).length} - onChange={handleSelectAll} - /> - - {selectedAlerts.length > 0 - ? `${selectedAlerts.length} alertas seleccionadas` - : 'Seleccionar todas' - } - -
- )} + {filteredAlerts.map((alert) => ( +
+
+
+ {ALERT_ICONS[alert.type] || ALERT_ICONS['default']} +
- {/* Alerts list */} - {filteredAlerts.map((alert) => { - const isExpanded = expandedAlerts.includes(alert.id); - const isSelected = selectedAlerts.includes(alert.id); - const availableActions = getAlertActions(alert); - const recommendation = getRecommendation(alert); - - return ( -
-
-
- {/* Selection checkbox */} - {alert.is_active && !alert.resolved_at && ( - toggleAlertSelection(alert.id)} - /> +
+
+ + {SPANISH_SEVERITIES[alert.severity]} + + {alert.product && ( + + {alert.product} + )} - - {/* Alert icon */} -
- {ALERT_TYPE_ICONS[alert.alert_type]} -
- - {/* Alert content */} -
-
-

- {alert.title} -

- - {SPANISH_SEVERITIES[alert.severity]} - - {alert.product_name && ( - - {alert.product_name} - - )} -
- -

- {compact ? alert.message.slice(0, 100) + '...' : alert.message} -

- - {!compact && ( -

- 💡 {recommendation} -

- )} - -
-
- {formatTimeAgo(alert.created_at)} - {alert.acknowledged_at && ( - • Confirmada - )} - {alert.resolved_at && ( - • Resuelta - )} -
- -
- {/* Quick actions */} - {alert.is_active && !alert.resolved_at && ( -
- {availableActions.slice(0, compact ? 1 : 2).map((action) => ( - - ))} - - {availableActions.length > (compact ? 1 : 2) && ( - - )} -
- )} - - {/* Dismiss button */} - {onAlertDismiss && alert.is_active && ( - - )} -
-
-
- {/* Expanded actions */} - {isExpanded && availableActions.length > (compact ? 1 : 2) && ( -
-
- {availableActions.slice(compact ? 1 : 2).map((action) => ( - - ))} -
-
+

+ {alert.message} +

+ + {alert.recommendation && ( +

+ 💡 {alert.recommendation} +

)} - {/* Alert details */} - {!compact && isExpanded && ( -
-
-
- Tipo: -
{SPANISH_ALERT_TYPES[alert.alert_type]}
-
- {alert.predicted_value && ( -
- Valor Predicho: -
{alert.predicted_value}
-
- )} - {alert.threshold_value && ( -
- Umbral: -
{alert.threshold_value}
-
- )} - {alert.model_accuracy && ( -
- Precisión Modelo: -
{(alert.model_accuracy * 100).toFixed(1)}%
-
- )} -
-
- )} +
+ {onAlertAction && ( + + )} + {onAlertDismiss && ( + + )} +
- ); - })} +
+ ))}
)} diff --git a/frontend/src/components/domain/forecasting/DemandChart.tsx b/frontend/src/components/domain/forecasting/DemandChart.tsx index 4c63984e..77c2c495 100644 --- a/frontend/src/components/domain/forecasting/DemandChart.tsx +++ b/frontend/src/components/domain/forecasting/DemandChart.tsx @@ -21,35 +21,17 @@ import { Select } from '../../ui'; import { Badge } from '../../ui'; import { ForecastResponse, - DemandTrend, - TrendDirection, - WeatherCondition, - EventType, -} from '../../../types/forecasting.types'; +} from '../../../api/types/forecasting'; export interface DemandChartProps { className?: string; title?: string; - data?: DemandTrend[]; - products?: string[]; - selectedProducts?: string[]; - onProductSelectionChange?: (products: string[]) => void; + data?: ForecastResponse[]; + product?: string; + period?: string; timeframe?: 'weekly' | 'monthly' | 'quarterly' | 'yearly'; onTimeframeChange?: (timeframe: 'weekly' | 'monthly' | 'quarterly' | 'yearly') => void; showConfidenceInterval?: boolean; - showEvents?: boolean; - showWeatherOverlay?: boolean; - events?: Array<{ - date: string; - type: EventType; - name: string; - impact: 'high' | 'medium' | 'low'; - }>; - weatherData?: Array<{ - date: string; - condition: WeatherCondition; - temperature: number; - }>; loading?: boolean; error?: string | null; height?: number; @@ -62,15 +44,6 @@ interface ChartDataPoint { predictedDemand?: number; confidenceLower?: number; confidenceUpper?: number; - accuracy?: number; - trendDirection?: TrendDirection; - seasonalFactor?: number; - anomalyScore?: number; - hasEvent?: boolean; - eventType?: EventType; - eventName?: string; - eventImpact?: 'high' | 'medium' | 'low'; - weather?: WeatherCondition; temperature?: number; } @@ -85,52 +58,25 @@ const PRODUCT_COLORS = [ '#87ceeb', // Mazapán ]; -const EVENT_ICONS: Record = { - [EventType.HOLIDAY]: '🎉', - [EventType.FESTIVAL]: '🎪', - [EventType.SPORTS_EVENT]: '⚽', - [EventType.WEATHER_EVENT]: '🌧️', - [EventType.SCHOOL_EVENT]: '🎒', - [EventType.CONCERT]: '🎵', - [EventType.CONFERENCE]: '📊', - [EventType.CONSTRUCTION]: '🚧', -}; - -const WEATHER_ICONS: Record = { - [WeatherCondition.SUNNY]: '☀️', - [WeatherCondition.CLOUDY]: '☁️', - [WeatherCondition.RAINY]: '🌧️', - [WeatherCondition.STORMY]: '⛈️', - [WeatherCondition.SNOWY]: '❄️', - [WeatherCondition.FOGGY]: '🌫️', - [WeatherCondition.WINDY]: '🌪️', -}; - -const SPANISH_EVENT_NAMES: Record = { - [EventType.HOLIDAY]: 'Festividad', - [EventType.FESTIVAL]: 'Festival', - [EventType.SPORTS_EVENT]: 'Evento Deportivo', - [EventType.WEATHER_EVENT]: 'Evento Climático', - [EventType.SCHOOL_EVENT]: 'Evento Escolar', - [EventType.CONCERT]: 'Concierto', - [EventType.CONFERENCE]: 'Conferencia', - [EventType.CONSTRUCTION]: 'Construcción', +const WEATHER_ICONS: Record = { + 'sunny': '☀️', + 'cloudy': '☁️', + 'rainy': '🌧️', + 'stormy': '⛈️', + 'snowy': '❄️', + 'foggy': '🌫️', + 'windy': '🌪️', }; const DemandChart: React.FC = ({ className, title = 'Predicción de Demanda', data = [], - products = [], - selectedProducts = [], - onProductSelectionChange, + product = '', + period = '7', timeframe = 'weekly', onTimeframeChange, showConfidenceInterval = true, - showEvents = true, - showWeatherOverlay = false, - events = [], - weatherData = [], loading = false, error = null, height = 400, @@ -140,34 +86,21 @@ const DemandChart: React.FC = ({ const [zoomedData, setZoomedData] = useState([]); const [hoveredPoint, setHoveredPoint] = useState(null); - // Process and merge data with events and weather + // Process forecast data for chart const chartData = useMemo(() => { - const processedData: ChartDataPoint[] = data.map(point => { - const dateStr = point.date; - const event = events.find(e => e.date === dateStr); - const weather = weatherData.find(w => w.date === dateStr); - + const processedData: ChartDataPoint[] = data.map(forecast => { return { - date: dateStr, - actualDemand: point.actual_demand, - predictedDemand: point.predicted_demand, - confidenceLower: point.confidence_lower, - confidenceUpper: point.confidence_upper, - accuracy: point.accuracy, - trendDirection: point.trend_direction, - seasonalFactor: point.seasonal_factor, - anomalyScore: point.anomaly_score, - hasEvent: !!event, - eventType: event?.type, - eventName: event?.name, - eventImpact: event?.impact, - weather: weather?.condition, - temperature: weather?.temperature, + date: forecast.forecast_date, + actualDemand: undefined, // Not available in current forecast response + predictedDemand: forecast.predicted_demand, + confidenceLower: forecast.confidence_lower, + confidenceUpper: forecast.confidence_upper, + temperature: forecast.weather_temperature, }; }); - return processedData; - }, [data, events, weatherData]); + return processedData.sort((a, b) => new Date(a.date).getTime() - new Date(b.date).getTime()); + }, [data]); // Filter data based on selected period const filteredData = useMemo(() => { @@ -218,13 +151,6 @@ const DemandChart: React.FC = ({

{formattedDate}

- {data.actualDemand !== undefined && ( -
- Demanda Real: - {data.actualDemand} -
- )} - {data.predictedDemand !== undefined && (
Demanda Predicha: @@ -241,88 +167,17 @@ const DemandChart: React.FC = ({
)} - {data.accuracy !== undefined && ( + {data.temperature && (
- Precisión: - 0.8 ? 'success' : data.accuracy > 0.6 ? 'warning' : 'danger'} - size="sm" - > - {(data.accuracy * 100).toFixed(1)}% - + Temperatura: + {data.temperature}°C
)}
- - {showEvents && data.hasEvent && ( -
-
- {EVENT_ICONS[data.eventType!]} -
-
{data.eventName}
-
- {SPANISH_EVENT_NAMES[data.eventType!]} -
-
- - {data.eventImpact === 'high' ? 'Alto' : data.eventImpact === 'medium' ? 'Medio' : 'Bajo'} - -
-
- )} - - {showWeatherOverlay && data.weather && ( -
-
- {WEATHER_ICONS[data.weather]} -
-
- {data.weather.replace('_', ' ')} -
- {data.temperature && ( -
- {data.temperature}°C -
- )} -
-
-
- )}
); }; - // Event reference dots - const renderEventDots = () => { - if (!showEvents) return null; - - return events - .filter(event => { - const eventData = zoomedData.find(d => d.date === event.date); - return eventData; - }) - .map((event, index) => { - const eventData = zoomedData.find(d => d.date === event.date); - if (!eventData) return null; - - const yValue = eventData.predictedDemand || eventData.actualDemand || 0; - - return ( - - ); - }); - }; // Loading state if (loading) { @@ -489,30 +344,17 @@ const DemandChart: React.FC = ({ /> )} - {/* Actual demand line */} - - {/* Predicted demand line */} - - {renderEventDots()}
@@ -547,11 +389,7 @@ const DemandChart: React.FC = ({ {/* Chart legend */}
-
- Demanda Real -
-
-
+
Demanda Predicha
{showConfidenceInterval && ( @@ -560,12 +398,6 @@ const DemandChart: React.FC = ({ Intervalo de Confianza
)} - {showEvents && events.length > 0 && ( -
-
- Eventos -
- )}
diff --git a/frontend/src/components/domain/forecasting/ForecastTable.tsx b/frontend/src/components/domain/forecasting/ForecastTable.tsx index 7c1eff35..c4700175 100644 --- a/frontend/src/components/domain/forecasting/ForecastTable.tsx +++ b/frontend/src/components/domain/forecasting/ForecastTable.tsx @@ -8,9 +8,7 @@ import { Input } from '../../ui'; import { Table, TableColumn } from '../../ui'; import { ForecastResponse, - TrendDirection, - ModelType, -} from '../../../types/forecasting.types'; +} from '../../../api/types/forecasting'; export interface ForecastTableProps { className?: string; @@ -31,7 +29,7 @@ interface FilterState { productName: string; category: string; accuracyRange: 'all' | 'high' | 'medium' | 'low'; - trendDirection: TrendDirection | 'all'; + trendDirection: 'increasing' | 'decreasing' | 'stable' | 'all'; confidenceLevel: number | 'all'; dateRange: 'today' | 'week' | 'month' | 'quarter' | 'all'; } @@ -41,28 +39,28 @@ interface SortState { order: 'asc' | 'desc'; } -const SPANISH_TRENDS: Record = { - [TrendDirection.INCREASING]: 'Creciente', - [TrendDirection.DECREASING]: 'Decreciente', - [TrendDirection.STABLE]: 'Estable', - [TrendDirection.VOLATILE]: 'Volátil', - [TrendDirection.SEASONAL]: 'Estacional', +const SPANISH_TRENDS: Record = { + 'increasing': 'Creciente', + 'decreasing': 'Decreciente', + 'stable': 'Estable', + 'volatile': 'Volátil', + 'seasonal': 'Estacional', }; -const TREND_COLORS: Record = { - [TrendDirection.INCREASING]: 'text-[var(--color-success)]', - [TrendDirection.DECREASING]: 'text-[var(--color-error)]', - [TrendDirection.STABLE]: 'text-[var(--color-info)]', - [TrendDirection.VOLATILE]: 'text-yellow-600', - [TrendDirection.SEASONAL]: 'text-purple-600', +const TREND_COLORS: Record = { + 'increasing': 'text-[var(--color-success)]', + 'decreasing': 'text-[var(--color-error)]', + 'stable': 'text-[var(--color-info)]', + 'volatile': 'text-yellow-600', + 'seasonal': 'text-purple-600', }; -const TREND_ICONS: Record = { - [TrendDirection.INCREASING]: '↗️', - [TrendDirection.DECREASING]: '↘️', - [TrendDirection.STABLE]: '➡️', - [TrendDirection.VOLATILE]: '📈', - [TrendDirection.SEASONAL]: '🔄', +const TREND_ICONS: Record = { + 'increasing': '↗️', + 'decreasing': '↘️', + 'stable': '➡️', + 'volatile': '📈', + 'seasonal': '🔄', }; const BAKERY_CATEGORIES = [ @@ -140,17 +138,17 @@ const ForecastTable: React.FC = ({ // Apply filters if (filters.productName) { filtered = filtered.filter(item => - item.product_name.toLowerCase().includes(filters.productName.toLowerCase()) + item.inventory_product_id.toLowerCase().includes(filters.productName.toLowerCase()) ); } if (filters.category !== 'all') { - filtered = filtered.filter(item => getProductCategory(item.product_name) === filters.category); + filtered = filtered.filter(item => getProductCategory(item.inventory_product_id) === filters.category); } if (filters.accuracyRange !== 'all') { filtered = filtered.filter(item => { - const accuracy = item.accuracy_score || 0; + const accuracy = item.confidence_level || 0; switch (filters.accuracyRange) { case 'high': return accuracy >= 0.8; case 'medium': return accuracy >= 0.6 && accuracy < 0.8; @@ -162,8 +160,8 @@ const ForecastTable: React.FC = ({ if (filters.trendDirection !== 'all') { filtered = filtered.filter(item => { - // Determine trend from predicted vs historical (simplified) - const trend = item.predicted_demand > (item.actual_demand || 0) ? TrendDirection.INCREASING : TrendDirection.DECREASING; + // Determine trend from predicted demand (simplified) + const trend = item.predicted_demand > 50 ? 'increasing' : item.predicted_demand < 20 ? 'decreasing' : 'stable'; return trend === filters.trendDirection; }); } @@ -200,21 +198,17 @@ const ForecastTable: React.FC = ({ let bValue: any; switch (sort.field) { - case 'product_name': - aValue = a.product_name; - bValue = b.product_name; + case 'inventory_product_id': + aValue = a.inventory_product_id; + bValue = b.inventory_product_id; break; case 'predicted_demand': aValue = a.predicted_demand; bValue = b.predicted_demand; break; - case 'accuracy_score': - aValue = a.accuracy_score || 0; - bValue = b.accuracy_score || 0; - break; case 'confidence_level': - aValue = a.confidence_level; - bValue = b.confidence_level; + aValue = a.confidence_level || 0; + bValue = b.confidence_level || 0; break; case 'forecast_date': aValue = new Date(a.forecast_date); @@ -263,7 +257,7 @@ const ForecastTable: React.FC = ({ // Calculate accuracy percentage and trend const getAccuracyInfo = useCallback((item: ForecastResponse) => { - const accuracy = item.accuracy_score || 0; + const accuracy = item.confidence_level || 0; const percentage = (accuracy * 100).toFixed(1); let variant: 'success' | 'warning' | 'danger' = 'success'; @@ -276,8 +270,7 @@ const ForecastTable: React.FC = ({ // Get trend info const getTrendInfo = useCallback((item: ForecastResponse) => { // Simplified trend calculation - const trend = item.predicted_demand > (item.actual_demand || 0) ? - TrendDirection.INCREASING : TrendDirection.DECREASING; + const trend = item.predicted_demand > 50 ? 'increasing' : item.predicted_demand < 20 ? 'decreasing' : 'stable'; return { direction: trend, @@ -291,9 +284,9 @@ const ForecastTable: React.FC = ({ const columns: TableColumn[] = useMemo(() => { const baseColumns: TableColumn[] = [ { - key: 'product_name', + key: 'inventory_product_id', title: 'Producto', - dataIndex: 'product_name', + dataIndex: 'inventory_product_id', sortable: true, width: compact ? 120 : 160, render: (value: string, record: ForecastResponse) => ( @@ -324,21 +317,9 @@ const ForecastTable: React.FC = ({ ), }, { - key: 'actual_demand', - title: 'Demanda Real', - dataIndex: 'actual_demand', - align: 'right' as const, - width: compact ? 80 : 100, - render: (value?: number) => ( -
- {value ? value.toFixed(0) : '-'} -
- ), - }, - { - key: 'accuracy_score', - title: 'Precisión', - dataIndex: 'accuracy_score', + key: 'confidence_level', + title: 'Confianza', + dataIndex: 'confidence_level', sortable: true, align: 'center' as const, width: 80, diff --git a/frontend/src/components/domain/forecasting/SeasonalityIndicator.tsx b/frontend/src/components/domain/forecasting/SeasonalityIndicator.tsx index 69e03786..64d01dc0 100644 --- a/frontend/src/components/domain/forecasting/SeasonalityIndicator.tsx +++ b/frontend/src/components/domain/forecasting/SeasonalityIndicator.tsx @@ -22,56 +22,25 @@ import { Card, CardHeader, CardBody } from '../../ui'; import { Button } from '../../ui'; import { Badge } from '../../ui'; import { Select } from '../../ui'; -import { - SeasonalPattern, - SeasonalComponent, - HolidayEffect, - WeeklyPattern, - YearlyTrend, - Season, - SeasonalPeriod, - DayOfWeek, -} from '../../../types/forecasting.types'; +// Simplified interfaces for seasonality data +interface SimpleSeasonalData { + month: string; + value: number; + season: string; +} + +interface SimpleWeeklyData { + day: string; + value: number; +} export interface SeasonalityIndicatorProps { className?: string; title?: string; - seasonalPatterns?: SeasonalPattern[]; - selectedProduct?: string; - onProductChange?: (product: string) => void; - viewMode?: 'circular' | 'calendar' | 'heatmap' | 'trends'; - onViewModeChange?: (mode: 'circular' | 'calendar' | 'heatmap' | 'trends') => void; - showComparison?: boolean; - comparisonYear?: number; loading?: boolean; error?: string | null; } -interface MonthlyData { - month: string; - value: number; - strength: number; - color: string; - season: Season; - holidays: string[]; -} - -interface WeeklyData { - day: string; - dayShort: string; - value: number; - variance: number; - peakHours?: number[]; -} - -interface HeatmapData { - month: number; - week: number; - intensity: number; - value: number; - holiday?: string; -} - const SPANISH_MONTHS = [ 'Enero', 'Febrero', 'Marzo', 'Abril', 'Mayo', 'Junio', 'Julio', 'Agosto', 'Septiembre', 'Octubre', 'Noviembre', 'Diciembre' @@ -85,18 +54,18 @@ const SPANISH_DAYS_SHORT = [ 'L', 'M', 'X', 'J', 'V', 'S', 'D' ]; -const SPANISH_SEASONS: Record = { - [Season.SPRING]: 'Primavera', - [Season.SUMMER]: 'Verano', - [Season.FALL]: 'Otoño', - [Season.WINTER]: 'Invierno', +const SPANISH_SEASONS: Record = { + 'spring': 'Primavera', + 'summer': 'Verano', + 'fall': 'Otoño', + 'winter': 'Invierno', }; -const SEASON_COLORS: Record = { - [Season.SPRING]: '#22c55e', // Green - [Season.SUMMER]: '#f59e0b', // Amber - [Season.FALL]: '#ea580c', // Orange - [Season.WINTER]: '#3b82f6', // Blue +const SEASON_COLORS: Record = { + 'spring': '#22c55e', // Green + 'summer': '#f59e0b', // Amber + 'fall': '#ea580c', // Orange + 'winter': '#3b82f6', // Blue }; const SPANISH_HOLIDAYS = [ @@ -125,308 +94,9 @@ const INTENSITY_COLORS = [ const SeasonalityIndicator: React.FC = ({ className, title = 'Patrones Estacionales', - seasonalPatterns = [], - selectedProduct = '', - onProductChange, - viewMode = 'circular', - onViewModeChange, - showComparison = false, - comparisonYear = new Date().getFullYear() - 1, loading = false, error = null, }) => { - const [hoveredElement, setHoveredElement] = useState(null); - - // Get current pattern data - const currentPattern = useMemo(() => { - if (!selectedProduct || seasonalPatterns.length === 0) { - return seasonalPatterns[0] || null; - } - return seasonalPatterns.find(p => p.product_name === selectedProduct) || seasonalPatterns[0]; - }, [seasonalPatterns, selectedProduct]); - - // Process monthly seasonal data - const monthlyData = useMemo((): MonthlyData[] => { - if (!currentPattern) return []; - - const monthlyComponent = currentPattern.seasonal_components.find( - c => c.period === SeasonalPeriod.MONTHLY - ); - - if (!monthlyComponent) return []; - - return SPANISH_MONTHS.map((month, index) => { - const value = monthlyComponent.pattern[index] || 0; - const strength = Math.abs(value); - - // Determine season - let season: Season; - if (index >= 2 && index <= 4) season = Season.SPRING; - else if (index >= 5 && index <= 7) season = Season.SUMMER; - else if (index >= 8 && index <= 10) season = Season.FALL; - else season = Season.WINTER; - - // Get holidays for this month - const holidays = SPANISH_HOLIDAYS - .filter(h => h.month === index) - .map(h => h.name); - - return { - month, - value: value * 100, // Convert to percentage - strength: strength * 100, - color: SEASON_COLORS[season], - season, - holidays, - }; - }); - }, [currentPattern]); - - // Process weekly data - const weeklyData = useMemo((): WeeklyData[] => { - if (!currentPattern) return []; - - return currentPattern.weekly_patterns.map((pattern, index) => ({ - day: SPANISH_DAYS[index] || `Día ${index + 1}`, - dayShort: SPANISH_DAYS_SHORT[index] || `D${index + 1}`, - value: pattern.average_multiplier * 100, - variance: pattern.variance * 100, - peakHours: pattern.peak_hours, - })); - }, [currentPattern]); - - // Process heatmap data - const heatmapData = useMemo((): HeatmapData[] => { - if (!currentPattern) return []; - - const data: HeatmapData[] = []; - const monthlyComponent = currentPattern.seasonal_components.find( - c => c.period === SeasonalPeriod.MONTHLY - ); - - if (monthlyComponent) { - for (let month = 0; month < 12; month++) { - for (let week = 0; week < 4; week++) { - const value = monthlyComponent.pattern[month] || 0; - const intensity = Math.min(Math.max(Math.abs(value) * 8, 0), 7); // 0-7 scale - - const holiday = SPANISH_HOLIDAYS.find(h => h.month === month); - - data.push({ - month, - week, - intensity: Math.floor(intensity), - value: value * 100, - holiday: holiday?.name, - }); - } - } - } - - return data; - }, [currentPattern]); - - // Custom tooltip for radial chart - const RadialTooltip = ({ active, payload }: any) => { - if (!active || !payload || !payload.length) return null; - - const data = payload[0].payload as MonthlyData; - - return ( -
-

{data.month}

-
-
- Estación: - - {SPANISH_SEASONS[data.season]} - -
-
- Variación: - {data.value.toFixed(1)}% -
-
- Intensidad: - {data.strength.toFixed(1)}% -
- {data.holidays.length > 0 && ( -
-
Festividades:
-
- {data.holidays.map(holiday => ( - - {holiday} - - ))} -
-
- )} -
-
- ); - }; - - // Circular view (Radial chart) - const renderCircularView = () => ( -
- - - entry.color} - /> - } /> - - -
- ); - - // Calendar view (Bar chart by month) - const renderCalendarView = () => ( -
- - - - - - { - if (!active || !payload || !payload.length) return null; - const data = payload[0].payload as MonthlyData; - return ; - }} - /> - monthlyData[index]?.color || '#8884d8'} - /> - - - - -
- ); - - // Heatmap view - const renderHeatmapView = () => ( -
- {/* Month labels */} -
- {SPANISH_MONTHS.map(month => ( -
- {month.slice(0, 3)} -
- ))} -
- - {/* Heatmap grid */} - {[0, 1, 2, 3].map(week => ( -
- {heatmapData - .filter(d => d.week === week) - .map((cell, monthIndex) => ( -
setHoveredElement(cell)} - onMouseLeave={() => setHoveredElement(null)} - title={`${SPANISH_MONTHS[cell.month]} S${cell.week + 1}: ${cell.value.toFixed(1)}%`} - > - {cell.holiday && ( -
🎉
- )} -
- ))} -
- ))} - - {/* Legend */} -
- Baja -
- {INTENSITY_COLORS.map((color, index) => ( -
- ))} -
- Alta -
-
- ); - - // Trends view (Weekly patterns) - const renderTrendsView = () => ( -
- - - - - - { - if (!active || !payload || !payload.length) return null; - const data = payload[0].payload as WeeklyData; - return ( -
-

{data.day}

-
-
- Multiplicador Promedio: - {data.value.toFixed(1)}% -
-
- Varianza: - {data.variance.toFixed(1)}% -
- {data.peakHours && data.peakHours.length > 0 && ( -
- Horas Pico: - - {data.peakHours.map(h => `${h}:00`).join(', ')} - -
- )} -
-
- ); - }} - /> - -
-
-
- ); - // Loading state if (loading) { return ( @@ -469,161 +139,24 @@ const SeasonalityIndicator: React.FC = ({ ); } - // Empty state - if (!currentPattern) { - return ( - - -

{title}

-
- -
-
-
- - - -
-

No hay datos de estacionalidad disponibles

-
-
-
-
- ); - } - + // Placeholder view - no complex seasonality data available yet return ( -
-
-

{title}

- {currentPattern && ( - - {currentPattern.product_name} - - )} - - {(currentPattern.confidence_score * 100).toFixed(0)}% confianza - -
- -
- {/* Product selector */} - {onProductChange && seasonalPatterns.length > 1 && ( - - )} - - {/* View mode selector */} -
- {(['circular', 'calendar', 'heatmap', 'trends'] as const).map((mode) => ( - - ))} -
-
-
+

{title}

- -
- {/* Main visualization */} -
- {viewMode === 'circular' && renderCircularView()} - {viewMode === 'calendar' && renderCalendarView()} - {viewMode === 'heatmap' && renderHeatmapView()} - {viewMode === 'trends' && renderTrendsView()} -
- - {/* Holiday effects summary */} - {currentPattern.holiday_effects && currentPattern.holiday_effects.length > 0 && ( -
-

Efectos de Festividades

-
- {currentPattern.holiday_effects.map((holiday, index) => ( -
-
- - {holiday.holiday_name} - - 1.2 ? 'success' : holiday.impact_factor < 0.8 ? 'danger' : 'warning'} - size="sm" - > - {((holiday.impact_factor - 1) * 100).toFixed(0)}% - -
-
-
Duración: {holiday.duration_days} días
-
Confianza: {(holiday.confidence * 100).toFixed(0)}%
-
-
- ))} -
-
- )} - - {/* Pattern strength indicators */} -
-

Intensidad de Patrones

-
- {currentPattern.seasonal_components.map((component, index) => { - const periodLabel = { - [SeasonalPeriod.WEEKLY]: 'Semanal', - [SeasonalPeriod.MONTHLY]: 'Mensual', - [SeasonalPeriod.QUARTERLY]: 'Trimestral', - [SeasonalPeriod.YEARLY]: 'Anual', - }[component.period] || component.period; - - return ( -
-
- {periodLabel} -
-
-
-
-
- - {(component.strength * 100).toFixed(0)}% - -
-
- ); - })} +
+
+
+ + +
+

Análisis de patrones estacionales

+

+ Los datos de estacionalidad estarán disponibles próximamente +

diff --git a/frontend/src/pages/app/analytics/forecasting/ForecastingPage.tsx b/frontend/src/pages/app/analytics/forecasting/ForecastingPage.tsx index 2fc3535c..7c3e5535 100644 --- a/frontend/src/pages/app/analytics/forecasting/ForecastingPage.tsx +++ b/frontend/src/pages/app/analytics/forecasting/ForecastingPage.tsx @@ -1,28 +1,68 @@ -import React, { useState } from 'react'; -import { Calendar, TrendingUp, AlertTriangle, BarChart3, Download, Settings } from 'lucide-react'; +import React, { useState, useMemo } from 'react'; +import { Calendar, TrendingUp, AlertTriangle, BarChart3, Download, Settings, Loader } from 'lucide-react'; import { Button, Card, Badge, Select, Table } from '../../../../components/ui'; import type { TableColumn } from '../../../../components/ui'; import { PageHeader } from '../../../../components/layout'; import { DemandChart, ForecastTable, SeasonalityIndicator, AlertsPanel } from '../../../../components/domain/forecasting'; +import { useTenantForecasts, useForecastStatistics } from '../../../../api/hooks/forecasting'; +import { useIngredients } from '../../../../api/hooks/inventory'; +import { useAuthUser } from '../../../../stores/auth.store'; +import { ForecastResponse } from '../../../../api/types/forecasting'; const ForecastingPage: React.FC = () => { const [selectedProduct, setSelectedProduct] = useState('all'); const [forecastPeriod, setForecastPeriod] = useState('7'); const [viewMode, setViewMode] = useState<'chart' | 'table'>('chart'); - const forecastData = { - accuracy: 92, - totalDemand: 1247, - growthTrend: 8.5, - seasonalityFactor: 1.15, - }; + // Get tenant ID from auth user + const user = useAuthUser(); + const tenantId = user?.tenant_id || ''; - const products = [ - { id: 'all', name: 'Todos los productos' }, - { id: 'bread', name: 'Panes' }, - { id: 'pastry', name: 'Bollería' }, - { id: 'cake', name: 'Tartas' }, - ]; + // Calculate date range based on selected period + const endDate = new Date(); + const startDate = new Date(); + startDate.setDate(startDate.getDate() - parseInt(forecastPeriod)); + + // API hooks + const { + data: forecastsData, + isLoading: forecastsLoading, + error: forecastsError + } = useTenantForecasts(tenantId, { + start_date: startDate.toISOString().split('T')[0], + end_date: endDate.toISOString().split('T')[0], + ...(selectedProduct !== 'all' && { inventory_product_id: selectedProduct }), + limit: 100 + }); + + const { + data: statisticsData, + isLoading: statisticsLoading, + error: statisticsError + } = useForecastStatistics(tenantId); + + // Fetch real inventory data + const { + data: ingredientsData, + isLoading: ingredientsLoading, + error: ingredientsError + } = useIngredients(tenantId); + + // Build products list from real inventory data + const products = useMemo(() => { + const productList = [{ id: 'all', name: 'Todos los productos' }]; + + if (ingredientsData && ingredientsData.length > 0) { + const inventoryProducts = ingredientsData.map(ingredient => ({ + id: ingredient.id, + name: ingredient.name, + category: ingredient.category, + })); + productList.push(...inventoryProducts); + } + + return productList; + }, [ingredientsData]); const periods = [ { value: '7', label: '7 días' }, @@ -31,78 +71,51 @@ const ForecastingPage: React.FC = () => { { value: '90', label: '3 meses' }, ]; - const mockForecasts = [ - { - id: '1', - product: 'Pan de Molde Integral', - currentStock: 25, - forecastDemand: 45, - recommendedProduction: 50, - confidence: 95, - trend: 'up', - stockoutRisk: 'low', - }, - { - id: '2', - product: 'Croissants de Mantequilla', - currentStock: 18, - forecastDemand: 32, - recommendedProduction: 35, - confidence: 88, - trend: 'stable', - stockoutRisk: 'medium', - }, - { - id: '3', - product: 'Baguettes Francesas', - currentStock: 12, - forecastDemand: 28, - recommendedProduction: 30, - confidence: 91, - trend: 'down', - stockoutRisk: 'high', - }, - ]; - - const alerts = [ - { - id: '1', - type: 'stockout', - product: 'Baguettes Francesas', - message: 'Alto riesgo de agotamiento en las próximas 24h', - severity: 'high', - recommendation: 'Incrementar producción en 15 unidades', - }, - { - id: '2', - type: 'overstock', - product: 'Magdalenas', - message: 'Probable exceso de stock para mañana', - severity: 'medium', - recommendation: 'Reducir producción en 20%', - }, - { - id: '3', - type: 'weather', - product: 'Todos', - message: 'Lluvia prevista - incremento esperado en demanda de bollería', - severity: 'info', - recommendation: 'Aumentar producción de productos de interior en 10%', - }, - ]; - - const weatherImpact = { - today: 'sunny', - temperature: 22, - demandFactor: 0.95, - affectedCategories: ['helados', 'bebidas frías'], + // Transform forecast data for table display + const transformForecastsForTable = (forecasts: ForecastResponse[]) => { + return forecasts.map(forecast => ({ + id: forecast.id, + product: forecast.inventory_product_id, // Will need to map to product name + currentStock: 'N/A', // Not available in forecast data + forecastDemand: forecast.predicted_demand, + recommendedProduction: Math.ceil(forecast.predicted_demand * 1.1), // Simple calculation + confidence: Math.round(forecast.confidence_level * 100), + trend: forecast.predicted_demand > 0 ? 'up' : 'stable', + stockoutRisk: forecast.confidence_level > 0.8 ? 'low' : forecast.confidence_level > 0.6 ? 'medium' : 'high', + })); }; - const seasonalInsights = [ - { period: 'Mañana', factor: 1.2, products: ['Pan', 'Bollería'] }, - { period: 'Tarde', factor: 0.8, products: ['Tartas', 'Dulces'] }, - { period: 'Fin de semana', factor: 1.4, products: ['Tartas especiales'] }, - ]; + // Generate alerts based on forecast data + const generateAlertsFromForecasts = (forecasts: ForecastResponse[]) => { + return forecasts + .filter(forecast => forecast.confidence_level < 0.7 || forecast.predicted_demand > 50) + .slice(0, 3) // Limit to 3 alerts + .map((forecast, index) => ({ + id: (index + 1).toString(), + type: forecast.confidence_level < 0.7 ? 'low-confidence' : 'high-demand', + product: forecast.inventory_product_id, + message: forecast.confidence_level < 0.7 + ? `Baja confianza en predicción (${Math.round(forecast.confidence_level * 100)}%)` + : `Alta demanda prevista: ${forecast.predicted_demand} unidades`, + severity: forecast.confidence_level < 0.5 ? 'high' : 'medium', + recommendation: forecast.confidence_level < 0.7 + ? 'Revisar datos históricos y factores externos' + : `Considerar aumentar producción a ${Math.ceil(forecast.predicted_demand * 1.2)} unidades` + })); + }; + + // Extract weather data from first forecast (if available) + const getWeatherImpact = (forecasts: ForecastResponse[]) => { + const firstForecast = forecasts?.[0]; + if (!firstForecast) return null; + + return { + today: firstForecast.weather_description || 'N/A', + temperature: firstForecast.weather_temperature || 0, + demandFactor: 1.0, // Could be calculated based on weather + affectedCategories: [], // Could be derived from business logic + }; + }; const getTrendIcon = (trend: string) => { switch (trend) { @@ -177,6 +190,20 @@ const ForecastingPage: React.FC = () => { }, ]; + // Derived data from API responses + const forecasts = forecastsData?.forecasts || []; + const transformedForecasts = transformForecastsForTable(forecasts); + const alerts = generateAlertsFromForecasts(forecasts); + const weatherImpact = getWeatherImpact(forecasts); + const isLoading = forecastsLoading || statisticsLoading || ingredientsLoading; + const hasError = forecastsError || statisticsError || ingredientsError; + + // Calculate metrics from real data + const totalDemand = forecasts.reduce((sum, f) => sum + f.predicted_demand, 0); + const averageConfidence = forecasts.length > 0 + ? Math.round((forecasts.reduce((sum, f) => sum + f.confidence_level, 0) / forecasts.length) * 100) + : 0; + return (
{ } /> - {/* Key Metrics */} -
- -
-
-

Precisión del Modelo

-

{forecastData.accuracy}%

-
-
- -
-
+ {isLoading && ( + + + Cargando predicciones... + )} - -
-
-

Demanda Prevista

-

{forecastData.totalDemand}

-

próximos {forecastPeriod} días

-
- + {hasError && ( + +
+ + Error al cargar las predicciones. Por favor, inténtalo de nuevo.
+ )} - -
-
-

Tendencia

-

+{forecastData.growthTrend}%

-

vs período anterior

-
- -
-
+ {!isLoading && !hasError && ( + <> + {/* Key Metrics */} +
+ +
+
+

Precisión del Modelo

+

+ {statisticsData?.accuracy_metrics?.average_accuracy + ? Math.round(statisticsData.accuracy_metrics.average_accuracy * 100) + : averageConfidence}% +

+
+
+ +
+
+
- -
-
-

Factor Estacional

-

{forecastData.seasonalityFactor}x

-

multiplicador actual

-
-
- - - -
+ +
+
+

Demanda Prevista

+

{Math.round(totalDemand)}

+

próximos {forecastPeriod} días

+
+ +
+
+ + +
+
+

Tendencia

+

+ +{statisticsData?.accuracy_metrics?.accuracy_trend + ? Math.round(statisticsData.accuracy_metrics.accuracy_trend * 100) + : 5}% +

+

vs período anterior

+
+ +
+
+ + +
+
+

Total Predicciones

+

+ {statisticsData?.total_forecasts || forecasts.length} +

+

generadas

+
+
+ + + +
+
+
-
-
+ + )} {/* Controls */} @@ -308,7 +365,7 @@ const ForecastingPage: React.FC = () => { period={forecastPeriod} /> ) : ( - + )}
@@ -317,67 +374,92 @@ const ForecastingPage: React.FC = () => { {/* Weather Impact */} - -

Impacto Meteorológico

-
-
- Hoy: -
- {weatherImpact.temperature}°C -
+ {weatherImpact && ( + +

Impacto Meteorológico

+
+
+ Hoy: +
+ {weatherImpact.temperature}°C +
+
-
- -
- Factor de demanda: - {weatherImpact.demandFactor}x -
- -
-

Categorías afectadas:

-
- {weatherImpact.affectedCategories.map((category, index) => ( - {category} - ))} + +
+ Condiciones: + {weatherImpact.today}
-
-
-
- {/* Seasonal Insights */} - -

Patrones Estacionales

-
- {seasonalInsights.map((insight, index) => ( -
+
+ Factor de demanda: + {weatherImpact.demandFactor}x +
+ + {weatherImpact.affectedCategories.length > 0 && ( +
+

Categorías afectadas:

+
+ {weatherImpact.affectedCategories.map((category, index) => ( + {category} + ))} +
+
+ )} +
+ + )} + + {/* Model Performance */} + {statisticsData?.model_performance && ( + +

Rendimiento del Modelo

+
+
- {insight.period} - {insight.factor}x + Algoritmo principal + {statisticsData.model_performance.most_used_algorithm}
-
- {insight.products.map((product, idx) => ( - {product} - ))} +
+ Tiempo de procesamiento promedio + + {Math.round(statisticsData.model_performance.average_processing_time)}ms +
- ))} -
- +
+
+ )}
{/* Detailed Forecasts Table */} - -

Predicciones Detalladas

- - + {!isLoading && !hasError && transformedForecasts.length > 0 && ( + +

Predicciones Detalladas

+
+ + )} + + {!isLoading && !hasError && transformedForecasts.length === 0 && ( + +
+ +

No hay predicciones disponibles

+

+ No se encontraron predicciones para el período seleccionado. + Prueba ajustando los filtros o genera nuevas predicciones. +

+
+
+ )} ); }; diff --git a/frontend/src/pages/app/settings/bakery-config/BakeryConfigPage.tsx b/frontend/src/pages/app/settings/bakery-config/BakeryConfigPage.tsx index 1ae557dc..898f1fe1 100644 --- a/frontend/src/pages/app/settings/bakery-config/BakeryConfigPage.tsx +++ b/frontend/src/pages/app/settings/bakery-config/BakeryConfigPage.tsx @@ -3,7 +3,11 @@ import { Store, MapPin, Clock, Phone, Mail, Globe, Save, X, Edit3, Zap, Plus, Se import { Button, Card, Input, Select, Modal, Badge } from '../../../../components/ui'; import { PageHeader } from '../../../../components/layout'; import { useToast } from '../../../../hooks/ui/useToast'; -import { posService, POSConfiguration } from '../../../../api/services/pos.service'; +import { usePOSConfigurationData, usePOSConfigurationManager } from '../../../../api/hooks/pos'; +import { POSConfiguration, POSProviderConfig } from '../../../../api/types/pos'; +import { posService } from '../../../../api/services/pos'; +import { useTenant, useUpdateTenant } from '../../../../api/hooks/tenant'; +import { useAuthUser } from '../../../../stores/auth.store'; interface BakeryConfig { // General Info @@ -32,33 +36,25 @@ interface BusinessHours { }; } -interface POSProviderConfig { - id: string; - name: string; - logo: string; - description: string; - features: string[]; - required_fields: { - field: string; - label: string; - type: 'text' | 'password' | 'url' | 'select'; - placeholder?: string; - required: boolean; - help_text?: string; - options?: { value: string; label: string }[]; - }[]; -} const BakeryConfigPage: React.FC = () => { const { addToast } = useToast(); + const user = useAuthUser(); + const tenantId = user?.tenant_id || ''; + + const { data: tenant, isLoading: tenantLoading, error: tenantError } = useTenant(tenantId, { enabled: !!tenantId }); + + const updateTenantMutation = useUpdateTenant(); + + // POS Configuration hooks + const posData = usePOSConfigurationData(tenantId); + const posManager = usePOSConfigurationManager(tenantId); const [activeTab, setActiveTab] = useState<'general' | 'location' | 'business' | 'hours' | 'pos'>('general'); const [isEditing, setIsEditing] = useState(false); const [isLoading, setIsLoading] = useState(false); // POS Configuration State - const [posConfigurations, setPosConfigurations] = useState([]); - const [posLoading, setPosLoading] = useState(false); const [showAddPosModal, setShowAddPosModal] = useState(false); const [showEditPosModal, setShowEditPosModal] = useState(false); const [selectedPosConfig, setSelectedPosConfig] = useState(null); @@ -67,20 +63,41 @@ const BakeryConfigPage: React.FC = () => { const [showCredentials, setShowCredentials] = useState>({}); const [config, setConfig] = useState({ - name: 'Panadería Artesanal San Miguel', - description: 'Panadería tradicional con más de 30 años de experiencia', - email: 'info@panaderiasanmiguel.com', - phone: '+34 912 345 678', - website: 'https://panaderiasanmiguel.com', - address: 'Calle Mayor 123', - city: 'Madrid', - postalCode: '28001', - country: 'España', - taxId: 'B12345678', + name: '', + description: '', + email: '', + phone: '', + website: '', + address: '', + city: '', + postalCode: '', + country: '', + taxId: '', currency: 'EUR', timezone: 'Europe/Madrid', language: 'es' }); + + // Update config when tenant data is loaded + React.useEffect(() => { + if (tenant) { + setConfig({ + name: tenant.name || '', + description: tenant.description || '', + email: tenant.email || '', // Fixed: use email instead of contact_email + phone: tenant.phone || '', // Fixed: use phone instead of contact_phone + website: tenant.website || '', + address: tenant.address || '', + city: tenant.city || '', + postalCode: tenant.postal_code || '', + country: tenant.country || '', + taxId: '', // Not supported by backend yet + currency: 'EUR', // Default value + timezone: 'Europe/Madrid', // Default value + language: 'es' // Default value + }); + } + }, [tenant]); const [businessHours, setBusinessHours] = useState({ monday: { open: '07:00', close: '20:00', closed: false }, @@ -185,27 +202,10 @@ const BakeryConfigPage: React.FC = () => { { value: 'en', label: 'English' } ]; - // Load POS configurations when POS tab is selected - useEffect(() => { - if (activeTab === 'pos') { - loadPosConfigurations(); - } - }, [activeTab]); - - const loadPosConfigurations = async () => { - try { - setPosLoading(true); - const response = await posService.getPOSConfigs(); - if (response.success) { - setPosConfigurations(response.data); - } else { - addToast('Error al cargar configuraciones POS', 'error'); - } - } catch (error) { - addToast('Error al conectar con el servidor', 'error'); - } finally { - setPosLoading(false); - } + // Load POS configurations function for refetching after updates + const loadPosConfigurations = () => { + // This will trigger a refetch of POS configurations + posManager.refetch(); }; const validateConfig = (): boolean => { @@ -234,13 +234,26 @@ const BakeryConfigPage: React.FC = () => { }; const handleSaveConfig = async () => { - if (!validateConfig()) return; + if (!validateConfig() || !tenantId) return; setIsLoading(true); try { - // Simulate API call - await new Promise(resolve => setTimeout(resolve, 1000)); + await updateTenantMutation.mutateAsync({ + tenantId, + updateData: { + name: config.name, + description: config.description, + email: config.email, // Fixed: use email instead of contact_email + phone: config.phone, // Fixed: use phone instead of contact_phone + website: config.website, + address: config.address, + city: config.city, + postal_code: config.postalCode, + country: config.country + // Note: tax_id, currency, timezone, language might not be supported by backend + } + }); setIsEditing(false); addToast('Configuración actualizada correctamente', 'success'); @@ -317,24 +330,23 @@ const BakeryConfigPage: React.FC = () => { if (selectedPosConfig) { // Update existing - const response = await posService.updatePOSConfig(selectedPosConfig.id, posFormData); - if (response.success) { - addToast('Configuración actualizada correctamente', 'success'); - setShowEditPosModal(false); - loadPosConfigurations(); - } else { - addToast('Error al actualizar la configuración', 'error'); - } + await posService.updatePOSConfiguration({ + tenant_id: tenantId, + config_id: selectedPosConfig.id, + ...posFormData, + }); + addToast('Configuración actualizada correctamente', 'success'); + setShowEditPosModal(false); + loadPosConfigurations(); } else { // Create new - const response = await posService.createPOSConfig(posFormData); - if (response.success) { - addToast('Configuración creada correctamente', 'success'); - setShowAddPosModal(false); - loadPosConfigurations(); - } else { - addToast('Error al crear la configuración', 'error'); - } + await posService.createPOSConfiguration({ + tenant_id: tenantId, + ...posFormData, + }); + addToast('Configuración creada correctamente', 'success'); + setShowAddPosModal(false); + loadPosConfigurations(); } } catch (error) { addToast('Error al guardar la configuración', 'error'); @@ -344,12 +356,15 @@ const BakeryConfigPage: React.FC = () => { const handleTestPosConnection = async (configId: string) => { try { setTestingConnection(configId); - const response = await posService.testPOSConnection(configId); + const response = await posService.testPOSConnection({ + tenant_id: tenantId, + config_id: configId, + }); - if (response.success && response.data.success) { + if (response.success) { addToast('Conexión exitosa', 'success'); } else { - addToast(`Error en la conexión: ${response.data.message}`, 'error'); + addToast(`Error en la conexión: ${response.message || 'Error desconocido'}`, 'error'); } } catch (error) { addToast('Error al probar la conexión', 'error'); @@ -364,13 +379,12 @@ const BakeryConfigPage: React.FC = () => { } try { - const response = await posService.deletePOSConfig(configId); - if (response.success) { - addToast('Configuración eliminada correctamente', 'success'); - loadPosConfigurations(); - } else { - addToast('Error al eliminar la configuración', 'error'); - } + await posService.deletePOSConfiguration({ + tenant_id: tenantId, + config_id: configId, + }); + addToast('Configuración eliminada correctamente', 'success'); + loadPosConfigurations(); } catch (error) { addToast('Error al eliminar la configuración', 'error'); } @@ -543,6 +557,37 @@ const BakeryConfigPage: React.FC = () => { ); }; + if (tenantLoading) { + return ( +
+ +
+ + Cargando configuración... +
+
+ ); + } + + if (tenantError) { + return ( +
+ + +
+ Error al cargar la configuración: {tenantError.message} +
+
+
+ ); + } + return (
{
- {posLoading ? ( + {posData.isLoading ? (
- ) : posConfigurations.length === 0 ? ( + ) : posData.configurations.length === 0 ? (
@@ -848,20 +893,20 @@ const BakeryConfigPage: React.FC = () => { ) : (
- {posConfigurations.map(config => { - const provider = supportedProviders.find(p => p.id === config.provider); + {posData.configurations.map(config => { + const provider = posData.supportedSystems.find(p => p.id === config.pos_system); return (
-
{provider?.logo || '📊'}
+
📊
-

{config.config_name}

-

{provider?.name || config.provider}

+

{config.provider_name}

+

{provider?.name || config.pos_system}

- {config.is_active ? ( + {config.is_connected ? ( ) : ( diff --git a/frontend/src/pages/app/settings/preferences/PreferencesPage.tsx b/frontend/src/pages/app/settings/preferences/PreferencesPage.tsx index 0a78ad0b..f9b96bfb 100644 --- a/frontend/src/pages/app/settings/preferences/PreferencesPage.tsx +++ b/frontend/src/pages/app/settings/preferences/PreferencesPage.tsx @@ -2,8 +2,14 @@ import React, { useState } from 'react'; import { Settings, Bell, Mail, MessageSquare, Smartphone, Save, RotateCcw } from 'lucide-react'; import { Button, Card } from '../../../../components/ui'; import { PageHeader } from '../../../../components/layout'; +import { useAuthProfile, useUpdateProfile } from '../../../../api/hooks/auth'; +import { useToast } from '../../../../hooks/ui/useToast'; const PreferencesPage: React.FC = () => { + const { addToast } = useToast(); + const { data: profile, isLoading: profileLoading } = useAuthProfile(); + const updateProfileMutation = useUpdateProfile(); + const [preferences, setPreferences] = useState({ notifications: { inventory: { @@ -50,12 +56,31 @@ const PreferencesPage: React.FC = () => { vibrationEnabled: true }, channels: { - email: 'panaderia@example.com', - phone: '+34 600 123 456', + email: profile?.email || '', + phone: profile?.phone || '', slack: false, webhook: '' } }); + + // Update preferences when profile loads + React.useEffect(() => { + if (profile) { + setPreferences(prev => ({ + ...prev, + global: { + ...prev.global, + language: profile.language || 'es', + timezone: profile.timezone || 'Europe/Madrid' + }, + channels: { + ...prev.channels, + email: profile.email || '', + phone: profile.phone || '' + } + })); + } + }, [profile]); const [hasChanges, setHasChanges] = useState(false); @@ -149,14 +174,49 @@ const PreferencesPage: React.FC = () => { setHasChanges(true); }; - const handleSave = () => { - // Handle save logic - console.log('Saving preferences:', preferences); - setHasChanges(false); + const handleSave = async () => { + try { + // Save notification preferences and contact info + await updateProfileMutation.mutateAsync({ + language: preferences.global.language, + timezone: preferences.global.timezone, + phone: preferences.channels.phone, + notification_preferences: preferences.notifications + }); + + addToast('Preferencias guardadas correctamente', 'success'); + setHasChanges(false); + } catch (error) { + addToast('Error al guardar las preferencias', 'error'); + } }; const handleReset = () => { - // Reset to defaults + if (profile) { + setPreferences({ + notifications: { + inventory: { app: true, email: false, sms: true, frequency: 'immediate' }, + sales: { app: true, email: true, sms: false, frequency: 'hourly' }, + production: { app: true, email: false, sms: true, frequency: 'immediate' }, + system: { app: true, email: true, sms: false, frequency: 'daily' }, + marketing: { app: false, email: true, sms: false, frequency: 'weekly' } + }, + global: { + doNotDisturb: false, + quietHours: { enabled: false, start: '22:00', end: '07:00' }, + language: profile.language || 'es', + timezone: profile.timezone || 'Europe/Madrid', + soundEnabled: true, + vibrationEnabled: true + }, + channels: { + email: profile.email || '', + phone: profile.phone || '', + slack: false, + webhook: '' + } + }); + } setHasChanges(false); }; diff --git a/frontend/src/pages/app/settings/profile/ProfilePage.tsx b/frontend/src/pages/app/settings/profile/ProfilePage.tsx index 7a5c3e3f..1520501b 100644 --- a/frontend/src/pages/app/settings/profile/ProfilePage.tsx +++ b/frontend/src/pages/app/settings/profile/ProfilePage.tsx @@ -4,6 +4,7 @@ import { Button, Card, Avatar, Input, Select } from '../../../../components/ui'; import { PageHeader } from '../../../../components/layout'; import { useAuthUser } from '../../../../stores/auth.store'; import { useToast } from '../../../../hooks/ui/useToast'; +import { useAuthProfile, useUpdateProfile, useChangePassword } from '../../../../api/hooks/auth'; interface ProfileFormData { first_name: string; @@ -22,20 +23,38 @@ interface PasswordData { const ProfilePage: React.FC = () => { const user = useAuthUser(); - const { showToast } = useToast(); + const { addToast } = useToast(); + + const { data: profile, isLoading: profileLoading, error: profileError } = useAuthProfile(); + const updateProfileMutation = useUpdateProfile(); + const changePasswordMutation = useChangePassword(); const [isEditing, setIsEditing] = useState(false); const [isLoading, setIsLoading] = useState(false); const [showPasswordForm, setShowPasswordForm] = useState(false); const [profileData, setProfileData] = useState({ - first_name: 'María', - last_name: 'González Pérez', - email: 'admin@bakery.com', - phone: '+34 612 345 678', + first_name: '', + last_name: '', + email: '', + phone: '', language: 'es', timezone: 'Europe/Madrid' }); + + // Update profile data when profile is loaded + React.useEffect(() => { + if (profile) { + setProfileData({ + first_name: profile.first_name || '', + last_name: profile.last_name || '', + email: profile.email || '', + phone: profile.phone || '', + language: profile.language || 'es', + timezone: profile.timezone || 'Europe/Madrid' + }); + } + }, [profile]); const [passwordData, setPasswordData] = useState({ currentPassword: '', @@ -105,48 +124,34 @@ const ProfilePage: React.FC = () => { setIsLoading(true); try { - // Simulate API call - await new Promise(resolve => setTimeout(resolve, 1000)); + await updateProfileMutation.mutateAsync(profileData); setIsEditing(false); - showToast({ - type: 'success', - title: 'Perfil actualizado', - message: 'Tu información ha sido guardada correctamente' - }); + addToast('Perfil actualizado correctamente', 'success'); } catch (error) { - showToast({ - type: 'error', - title: 'Error', - message: 'No se pudo actualizar tu perfil' - }); + addToast('No se pudo actualizar tu perfil', 'error'); } finally { setIsLoading(false); } }; - const handleChangePassword = async () => { + const handleChangePasswordSubmit = async () => { if (!validatePassword()) return; setIsLoading(true); try { - // Simulate API call - await new Promise(resolve => setTimeout(resolve, 1000)); + await changePasswordMutation.mutateAsync({ + current_password: passwordData.currentPassword, + new_password: passwordData.newPassword, + confirm_password: passwordData.confirmPassword + }); setShowPasswordForm(false); setPasswordData({ currentPassword: '', newPassword: '', confirmPassword: '' }); - showToast({ - type: 'success', - title: 'Contraseña actualizada', - message: 'Tu contraseña ha sido cambiada correctamente' - }); + addToast('Contraseña actualizada correctamente', 'success'); } catch (error) { - showToast({ - type: 'error', - title: 'Error', - message: 'No se pudo cambiar tu contraseña' - }); + addToast('No se pudo cambiar tu contraseña', 'error'); } finally { setIsLoading(false); } @@ -182,7 +187,7 @@ const ProfilePage: React.FC = () => {
{