Add new API in the frontend
This commit is contained in:
655
frontend/src/api/hooks/pos.ts
Normal file
655
frontend/src/api/hooks/pos.ts
Normal file
@@ -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<UseQueryOptions<GetPOSConfigurationsResponse, ApiError>, 'queryKey' | 'queryFn'>
|
||||||
|
) => {
|
||||||
|
return useQuery<GetPOSConfigurationsResponse, ApiError>({
|
||||||
|
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<UseQueryOptions<GetPOSConfigurationResponse, ApiError>, 'queryKey' | 'queryFn'>
|
||||||
|
) => {
|
||||||
|
return useQuery<GetPOSConfigurationResponse, ApiError>({
|
||||||
|
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<UseQueryOptions<GetSupportedPOSSystemsResponse, ApiError>, 'queryKey' | 'queryFn'>
|
||||||
|
) => {
|
||||||
|
return useQuery<GetSupportedPOSSystemsResponse, ApiError>({
|
||||||
|
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<CreatePOSConfigurationResponse, ApiError, CreatePOSConfigurationRequest>
|
||||||
|
) => {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
|
return useMutation<CreatePOSConfigurationResponse, ApiError, CreatePOSConfigurationRequest>({
|
||||||
|
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<UpdatePOSConfigurationResponse, ApiError, UpdatePOSConfigurationRequest>
|
||||||
|
) => {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
|
return useMutation<UpdatePOSConfigurationResponse, ApiError, UpdatePOSConfigurationRequest>({
|
||||||
|
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<DeletePOSConfigurationResponse, ApiError, DeletePOSConfigurationRequest>
|
||||||
|
) => {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
|
return useMutation<DeletePOSConfigurationResponse, ApiError, DeletePOSConfigurationRequest>({
|
||||||
|
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<TestPOSConnectionResponse, ApiError, TestPOSConnectionRequest>
|
||||||
|
) => {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
|
return useMutation<TestPOSConnectionResponse, ApiError, TestPOSConnectionRequest>({
|
||||||
|
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<UseQueryOptions<{
|
||||||
|
transactions: POSTransaction[];
|
||||||
|
total: number;
|
||||||
|
has_more: boolean;
|
||||||
|
summary: {
|
||||||
|
total_amount: number;
|
||||||
|
transaction_count: number;
|
||||||
|
sync_status: {
|
||||||
|
synced: number;
|
||||||
|
pending: number;
|
||||||
|
failed: number;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
}, ApiError>, '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<UseQueryOptions<{
|
||||||
|
transaction: POSTransaction;
|
||||||
|
}, ApiError>, '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<UseQueryOptions<{
|
||||||
|
current_sync: any;
|
||||||
|
last_successful_sync: any;
|
||||||
|
recent_syncs: any[];
|
||||||
|
sync_health: {
|
||||||
|
status: string;
|
||||||
|
success_rate: number;
|
||||||
|
average_duration_minutes: number;
|
||||||
|
last_error?: string;
|
||||||
|
};
|
||||||
|
}, ApiError>, '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<UseQueryOptions<{
|
||||||
|
logs: any[];
|
||||||
|
total: number;
|
||||||
|
has_more: boolean;
|
||||||
|
}, ApiError>, '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<UseQueryOptions<{
|
||||||
|
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[];
|
||||||
|
};
|
||||||
|
}, ApiError>, '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<UseQueryOptions<{
|
||||||
|
sync_logs: POSSyncLog[];
|
||||||
|
total: number;
|
||||||
|
has_more: boolean;
|
||||||
|
}, ApiError>, '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<UseQueryOptions<{
|
||||||
|
webhook_logs: POSWebhookLog[];
|
||||||
|
total: number;
|
||||||
|
has_more: boolean;
|
||||||
|
}, ApiError>, '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<UseQueryOptions<{
|
||||||
|
pos_system: string;
|
||||||
|
status: string;
|
||||||
|
endpoint: string;
|
||||||
|
supported_events: {
|
||||||
|
events: string[];
|
||||||
|
format: string;
|
||||||
|
authentication: string;
|
||||||
|
};
|
||||||
|
last_received?: string;
|
||||||
|
total_received: number;
|
||||||
|
}, ApiError>, '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,
|
||||||
|
};
|
||||||
|
};
|
||||||
@@ -27,6 +27,7 @@ export { suppliersService } from './services/suppliers';
|
|||||||
export { OrdersService } from './services/orders';
|
export { OrdersService } from './services/orders';
|
||||||
export { forecastingService } from './services/forecasting';
|
export { forecastingService } from './services/forecasting';
|
||||||
export { productionService } from './services/production';
|
export { productionService } from './services/production';
|
||||||
|
export { posService } from './services/pos';
|
||||||
|
|
||||||
// Types - Auth
|
// Types - Auth
|
||||||
export type {
|
export type {
|
||||||
@@ -344,6 +345,33 @@ export {
|
|||||||
ProductionPriorityEnum,
|
ProductionPriorityEnum,
|
||||||
} from './types/production';
|
} 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
|
// Hooks - Auth
|
||||||
export {
|
export {
|
||||||
useAuthProfile,
|
useAuthProfile,
|
||||||
@@ -632,6 +660,32 @@ export {
|
|||||||
productionKeys,
|
productionKeys,
|
||||||
} from './hooks/production';
|
} 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)
|
// Query Key Factories (for advanced usage)
|
||||||
export {
|
export {
|
||||||
authKeys,
|
authKeys,
|
||||||
@@ -650,4 +704,5 @@ export {
|
|||||||
dataImportKeys,
|
dataImportKeys,
|
||||||
forecastingKeys,
|
forecastingKeys,
|
||||||
productionKeys,
|
productionKeys,
|
||||||
|
posKeys,
|
||||||
};
|
};
|
||||||
@@ -62,11 +62,11 @@ export class AuthService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async getProfile(): Promise<UserResponse> {
|
async getProfile(): Promise<UserResponse> {
|
||||||
return apiClient.get<UserResponse>(`${this.baseUrl}/profile`);
|
return apiClient.get<UserResponse>('/users/me');
|
||||||
}
|
}
|
||||||
|
|
||||||
async updateProfile(updateData: UserUpdate): Promise<UserResponse> {
|
async updateProfile(updateData: UserUpdate): Promise<UserResponse> {
|
||||||
return apiClient.put<UserResponse>(`${this.baseUrl}/profile`, updateData);
|
return apiClient.put<UserResponse>('/users/me', updateData);
|
||||||
}
|
}
|
||||||
|
|
||||||
async verifyEmail(
|
async verifyEmail(
|
||||||
|
|||||||
557
frontend/src/api/services/pos.ts
Normal file
557
frontend/src/api/services/pos.ts
Normal file
@@ -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<GetPOSConfigurationsResponse> {
|
||||||
|
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<GetPOSConfigurationsResponse>(url);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a new POS configuration
|
||||||
|
*/
|
||||||
|
async createPOSConfiguration(params: CreatePOSConfigurationRequest): Promise<CreatePOSConfigurationResponse> {
|
||||||
|
const { tenant_id, ...configData } = params;
|
||||||
|
const url = `/tenants/${tenant_id}${this.basePath}/configurations`;
|
||||||
|
|
||||||
|
return apiClient.post<CreatePOSConfigurationResponse>(url, configData);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get a specific POS configuration
|
||||||
|
*/
|
||||||
|
async getPOSConfiguration(params: GetPOSConfigurationRequest): Promise<GetPOSConfigurationResponse> {
|
||||||
|
const { tenant_id, config_id } = params;
|
||||||
|
const url = `/tenants/${tenant_id}${this.basePath}/configurations/${config_id}`;
|
||||||
|
|
||||||
|
return apiClient.get<GetPOSConfigurationResponse>(url);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update a POS configuration
|
||||||
|
*/
|
||||||
|
async updatePOSConfiguration(params: UpdatePOSConfigurationRequest): Promise<UpdatePOSConfigurationResponse> {
|
||||||
|
const { tenant_id, config_id, ...updateData } = params;
|
||||||
|
const url = `/tenants/${tenant_id}${this.basePath}/configurations/${config_id}`;
|
||||||
|
|
||||||
|
return apiClient.put<UpdatePOSConfigurationResponse>(url, updateData);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Delete a POS configuration
|
||||||
|
*/
|
||||||
|
async deletePOSConfiguration(params: DeletePOSConfigurationRequest): Promise<DeletePOSConfigurationResponse> {
|
||||||
|
const { tenant_id, config_id } = params;
|
||||||
|
const url = `/tenants/${tenant_id}${this.basePath}/configurations/${config_id}`;
|
||||||
|
|
||||||
|
return apiClient.delete<DeletePOSConfigurationResponse>(url);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Test connection to POS system
|
||||||
|
*/
|
||||||
|
async testPOSConnection(params: TestPOSConnectionRequest): Promise<TestPOSConnectionResponse> {
|
||||||
|
const { tenant_id, config_id } = params;
|
||||||
|
const url = `/tenants/${tenant_id}${this.basePath}/configurations/${config_id}/test-connection`;
|
||||||
|
|
||||||
|
return apiClient.post<TestPOSConnectionResponse>(url);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// SUPPORTED SYSTEMS
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get list of supported POS systems
|
||||||
|
*/
|
||||||
|
async getSupportedPOSSystems(): Promise<GetSupportedPOSSystemsResponse> {
|
||||||
|
const url = `${this.basePath}/supported-systems`;
|
||||||
|
return apiClient.get<GetSupportedPOSSystemsResponse>(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<string, string>;
|
||||||
|
}): Promise<{
|
||||||
|
status: string;
|
||||||
|
message?: string;
|
||||||
|
success?: boolean;
|
||||||
|
received?: boolean;
|
||||||
|
}> {
|
||||||
|
const { pos_system, payload, signature, headers = {} } = params;
|
||||||
|
|
||||||
|
const requestHeaders: Record<string, string> = {
|
||||||
|
'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<POSSystem, string> = {
|
||||||
|
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<string, any>): {
|
||||||
|
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;
|
||||||
697
frontend/src/api/types/pos.ts
Normal file
697
frontend/src/api/types/pos.ts
Normal file
@@ -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<string, any>;
|
||||||
|
|
||||||
|
// 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<string, any>;
|
||||||
|
|
||||||
|
// 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<string, any>;
|
||||||
|
|
||||||
|
// 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<string, any>;
|
||||||
|
|
||||||
|
// 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<string, any>;
|
||||||
|
headers?: Record<string, any>;
|
||||||
|
|
||||||
|
// 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<string, any>;
|
||||||
|
|
||||||
|
// Retry Information
|
||||||
|
retry_attempt: number;
|
||||||
|
max_retries: number;
|
||||||
|
parent_sync_id?: string;
|
||||||
|
|
||||||
|
// Configuration Snapshot
|
||||||
|
sync_configuration?: Record<string, any>;
|
||||||
|
|
||||||
|
// Progress Tracking
|
||||||
|
current_page?: number;
|
||||||
|
total_pages?: number;
|
||||||
|
current_batch?: number;
|
||||||
|
total_batches?: number;
|
||||||
|
progress_percentage?: number;
|
||||||
|
|
||||||
|
// Data Quality
|
||||||
|
validation_errors?: Record<string, any>[];
|
||||||
|
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<string, any>;
|
||||||
|
|
||||||
|
// 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<string, any>;
|
||||||
|
}
|
||||||
|
|
||||||
|
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<string, any>;
|
||||||
|
}
|
||||||
|
|
||||||
|
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<string, any>;
|
||||||
|
raw_data?: Record<string, any>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ClientPOSProduct {
|
||||||
|
external_id: string;
|
||||||
|
name: string;
|
||||||
|
sku?: string;
|
||||||
|
category?: string;
|
||||||
|
subcategory?: string;
|
||||||
|
price: number;
|
||||||
|
description?: string;
|
||||||
|
is_active: boolean;
|
||||||
|
raw_data: Record<string, any>;
|
||||||
|
}
|
||||||
|
|
||||||
|
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<string, any>;
|
||||||
|
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<string, any>;
|
||||||
|
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 };
|
||||||
@@ -5,189 +5,64 @@ import { Button } from '../../ui';
|
|||||||
import { Badge } from '../../ui';
|
import { Badge } from '../../ui';
|
||||||
import { Select } from '../../ui';
|
import { Select } from '../../ui';
|
||||||
import { Input } from '../../ui';
|
import { Input } from '../../ui';
|
||||||
import {
|
// Simple alert interface for component
|
||||||
ForecastAlert,
|
interface SimpleAlert {
|
||||||
ForecastAlertType,
|
id: string;
|
||||||
AlertSeverity,
|
type: string;
|
||||||
} from '../../../types/forecasting.types';
|
product: string;
|
||||||
|
message: string;
|
||||||
|
severity: 'high' | 'medium' | 'low' | 'info';
|
||||||
|
recommendation?: string;
|
||||||
|
}
|
||||||
|
|
||||||
export interface AlertsPanelProps {
|
export interface AlertsPanelProps {
|
||||||
className?: string;
|
className?: string;
|
||||||
title?: string;
|
title?: string;
|
||||||
alerts?: ForecastAlert[];
|
alerts?: SimpleAlert[];
|
||||||
loading?: boolean;
|
loading?: boolean;
|
||||||
error?: string | null;
|
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;
|
onAlertDismiss?: (alertId: string) => void;
|
||||||
onBulkAction?: (alertIds: string[], action: string) => void;
|
|
||||||
showFilters?: boolean;
|
showFilters?: boolean;
|
||||||
compact?: boolean;
|
compact?: boolean;
|
||||||
maxItems?: number;
|
maxItems?: number;
|
||||||
autoRefresh?: boolean;
|
|
||||||
refreshInterval?: number;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
interface AlertFilter {
|
interface AlertFilter {
|
||||||
severity: AlertSeverity | 'all';
|
severity: 'high' | 'medium' | 'low' | 'info' | 'all';
|
||||||
type: ForecastAlertType | 'all';
|
type: string;
|
||||||
status: 'active' | 'acknowledged' | 'resolved' | 'all';
|
|
||||||
product: string;
|
product: string;
|
||||||
dateRange: 'today' | 'week' | 'month' | 'all';
|
|
||||||
}
|
}
|
||||||
|
|
||||||
interface AlertActionGroup {
|
const SPANISH_SEVERITIES: Record<string, string> = {
|
||||||
critical: AlertAction[];
|
'high': 'Alta',
|
||||||
high: AlertAction[];
|
'medium': 'Media',
|
||||||
medium: AlertAction[];
|
'low': 'Baja',
|
||||||
low: AlertAction[];
|
'info': 'Info',
|
||||||
}
|
|
||||||
|
|
||||||
interface AlertAction {
|
|
||||||
id: string;
|
|
||||||
label: string;
|
|
||||||
icon: string;
|
|
||||||
variant: 'primary' | 'secondary' | 'success' | 'warning' | 'danger';
|
|
||||||
description: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
const SPANISH_ALERT_TYPES: Record<ForecastAlertType, string> = {
|
|
||||||
[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<AlertSeverity, string> = {
|
const SEVERITY_COLORS: Record<string, string> = {
|
||||||
[AlertSeverity.CRITICAL]: 'Crítica',
|
'high': 'text-[var(--color-error)] bg-red-50 border-red-200',
|
||||||
[AlertSeverity.HIGH]: 'Alta',
|
'medium': 'text-yellow-600 bg-yellow-50 border-yellow-200',
|
||||||
[AlertSeverity.MEDIUM]: 'Media',
|
'low': 'text-[var(--color-info)] bg-[var(--color-info)]/5 border-[var(--color-info)]/20',
|
||||||
[AlertSeverity.LOW]: 'Baja',
|
'info': 'text-[var(--color-primary)] bg-blue-50 border-blue-200',
|
||||||
};
|
};
|
||||||
|
|
||||||
const SEVERITY_COLORS: Record<AlertSeverity, string> = {
|
const SEVERITY_BADGE_VARIANTS: Record<string, 'danger' | 'warning' | 'success' | 'info'> = {
|
||||||
[AlertSeverity.CRITICAL]: 'text-[var(--color-error)] bg-red-50 border-red-200',
|
'high': 'danger',
|
||||||
[AlertSeverity.HIGH]: 'text-[var(--color-primary)] bg-orange-50 border-orange-200',
|
'medium': 'warning',
|
||||||
[AlertSeverity.MEDIUM]: 'text-yellow-600 bg-yellow-50 border-yellow-200',
|
'low': 'info',
|
||||||
[AlertSeverity.LOW]: 'text-[var(--color-info)] bg-[var(--color-info)]/5 border-[var(--color-info)]/20',
|
'info': 'info',
|
||||||
};
|
};
|
||||||
|
|
||||||
const SEVERITY_BADGE_VARIANTS: Record<AlertSeverity, 'danger' | 'warning' | 'success' | 'info'> = {
|
const ALERT_ICONS: Record<string, string> = {
|
||||||
[AlertSeverity.CRITICAL]: 'danger',
|
'high-demand': '📈',
|
||||||
[AlertSeverity.HIGH]: 'warning',
|
'low-demand': '📉',
|
||||||
[AlertSeverity.MEDIUM]: 'warning',
|
'low-confidence': '🎯',
|
||||||
[AlertSeverity.LOW]: 'info',
|
'weather': '🌦️',
|
||||||
|
'default': '⚠️',
|
||||||
};
|
};
|
||||||
|
|
||||||
const ALERT_TYPE_ICONS: Record<ForecastAlertType, string> = {
|
|
||||||
[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<ForecastAlertType, (product: string, value: number) => 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<AlertsPanelProps> = ({
|
const AlertsPanel: React.FC<AlertsPanelProps> = ({
|
||||||
className,
|
className,
|
||||||
@@ -197,90 +72,35 @@ const AlertsPanel: React.FC<AlertsPanelProps> = ({
|
|||||||
error = null,
|
error = null,
|
||||||
onAlertAction,
|
onAlertAction,
|
||||||
onAlertDismiss,
|
onAlertDismiss,
|
||||||
onBulkAction,
|
showFilters = false,
|
||||||
showFilters = true,
|
|
||||||
compact = false,
|
compact = false,
|
||||||
maxItems,
|
maxItems,
|
||||||
autoRefresh = false,
|
|
||||||
refreshInterval = 30000,
|
|
||||||
}) => {
|
}) => {
|
||||||
const [filters, setFilters] = useState<AlertFilter>({
|
const [filters, setFilters] = useState<AlertFilter>({
|
||||||
severity: 'all',
|
severity: 'all',
|
||||||
type: 'all',
|
type: 'all',
|
||||||
status: 'active',
|
|
||||||
product: '',
|
product: '',
|
||||||
dateRange: 'all',
|
|
||||||
});
|
});
|
||||||
|
|
||||||
const [selectedAlerts, setSelectedAlerts] = useState<string[]>([]);
|
|
||||||
const [expandedAlerts, setExpandedAlerts] = useState<string[]>([]);
|
|
||||||
|
|
||||||
// Filter alerts
|
// Filter and limit alerts
|
||||||
const filteredAlerts = useMemo(() => {
|
const filteredAlerts = useMemo(() => {
|
||||||
let filtered = [...alerts];
|
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
|
// Filter by severity
|
||||||
if (filters.severity !== 'all') {
|
if (filters.severity !== 'all') {
|
||||||
filtered = filtered.filter(alert => alert.severity === filters.severity);
|
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
|
// Filter by product
|
||||||
if (filters.product) {
|
if (filters.product) {
|
||||||
filtered = filtered.filter(alert =>
|
filtered = filtered.filter(alert =>
|
||||||
alert.product_name?.toLowerCase().includes(filters.product.toLowerCase())
|
alert.product.toLowerCase().includes(filters.product.toLowerCase())
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Filter by date range
|
// Sort by severity priority
|
||||||
if (filters.dateRange !== 'all') {
|
const severityOrder = { 'high': 3, 'medium': 2, 'low': 1, 'info': 0 };
|
||||||
const now = new Date();
|
filtered.sort((a, b) => severityOrder[b.severity] - severityOrder[a.severity]);
|
||||||
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, number> = {
|
|
||||||
[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();
|
|
||||||
});
|
|
||||||
|
|
||||||
// Limit items if specified
|
// Limit items if specified
|
||||||
if (maxItems) {
|
if (maxItems) {
|
||||||
@@ -290,73 +110,6 @@ const AlertsPanel: React.FC<AlertsPanelProps> = ({
|
|||||||
return filtered;
|
return filtered;
|
||||||
}, [alerts, filters, maxItems]);
|
}, [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
|
// Loading state
|
||||||
if (loading) {
|
if (loading) {
|
||||||
return (
|
return (
|
||||||
@@ -403,110 +156,13 @@ const AlertsPanel: React.FC<AlertsPanelProps> = ({
|
|||||||
<Card className={className}>
|
<Card className={className}>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<div className="flex items-center justify-between flex-wrap gap-4">
|
<div className="flex items-center justify-between flex-wrap gap-4">
|
||||||
<div className="flex items-center gap-3">
|
<h3 className="text-lg font-semibold text-text-primary">{title}</h3>
|
||||||
<h3 className="text-lg font-semibold text-text-primary">{title}</h3>
|
<Badge variant="ghost">
|
||||||
{alertStats.critical > 0 && (
|
{filteredAlerts.length} alertas
|
||||||
<Badge variant="danger" className="animate-pulse">
|
</Badge>
|
||||||
{alertStats.critical} críticas
|
|
||||||
</Badge>
|
|
||||||
)}
|
|
||||||
{alertStats.high > 0 && (
|
|
||||||
<Badge variant="warning">
|
|
||||||
{alertStats.high} altas
|
|
||||||
</Badge>
|
|
||||||
)}
|
|
||||||
<Badge variant="ghost">
|
|
||||||
{alertStats.active}/{alertStats.total} activas
|
|
||||||
</Badge>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
{selectedAlerts.length > 0 && onBulkAction && (
|
|
||||||
<div className="flex gap-1">
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="sm"
|
|
||||||
onClick={() => onBulkAction(selectedAlerts, 'acknowledge')}
|
|
||||||
>
|
|
||||||
✓ Confirmar Todas
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="sm"
|
|
||||||
onClick={() => onBulkAction(selectedAlerts, 'production_plan')}
|
|
||||||
>
|
|
||||||
📋 Plan Producción
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{autoRefresh && (
|
|
||||||
<div className="flex items-center gap-1 text-xs text-text-tertiary">
|
|
||||||
<div className="animate-spin w-3 h-3 border border-color-primary border-t-transparent rounded-full"></div>
|
|
||||||
Auto-actualización
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
|
|
||||||
{showFilters && (
|
|
||||||
<div className="px-6 py-3 border-b border-border-primary bg-bg-secondary">
|
|
||||||
<div className="grid grid-cols-2 md:grid-cols-5 gap-3">
|
|
||||||
<Select
|
|
||||||
value={filters.status}
|
|
||||||
onChange={(value) => setFilters(prev => ({ ...prev, status: value as any }))}
|
|
||||||
className="text-sm"
|
|
||||||
>
|
|
||||||
<option value="all">Todos los estados</option>
|
|
||||||
<option value="active">Activas</option>
|
|
||||||
<option value="acknowledged">Confirmadas</option>
|
|
||||||
<option value="resolved">Resueltas</option>
|
|
||||||
</Select>
|
|
||||||
|
|
||||||
<Select
|
|
||||||
value={filters.severity}
|
|
||||||
onChange={(value) => setFilters(prev => ({ ...prev, severity: value as any }))}
|
|
||||||
className="text-sm"
|
|
||||||
>
|
|
||||||
<option value="all">Toda severidad</option>
|
|
||||||
{Object.entries(SPANISH_SEVERITIES).map(([key, label]) => (
|
|
||||||
<option key={key} value={key}>{label}</option>
|
|
||||||
))}
|
|
||||||
</Select>
|
|
||||||
|
|
||||||
<Select
|
|
||||||
value={filters.type}
|
|
||||||
onChange={(value) => setFilters(prev => ({ ...prev, type: value as any }))}
|
|
||||||
className="text-sm"
|
|
||||||
>
|
|
||||||
<option value="all">Todos los tipos</option>
|
|
||||||
{Object.entries(SPANISH_ALERT_TYPES).map(([key, label]) => (
|
|
||||||
<option key={key} value={key}>{label}</option>
|
|
||||||
))}
|
|
||||||
</Select>
|
|
||||||
|
|
||||||
<Input
|
|
||||||
placeholder="Buscar producto..."
|
|
||||||
value={filters.product}
|
|
||||||
onChange={(e) => setFilters(prev => ({ ...prev, product: e.target.value }))}
|
|
||||||
className="text-sm"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<Select
|
|
||||||
value={filters.dateRange}
|
|
||||||
onChange={(value) => setFilters(prev => ({ ...prev, dateRange: value as any }))}
|
|
||||||
className="text-sm"
|
|
||||||
>
|
|
||||||
<option value="all">Todo el tiempo</option>
|
|
||||||
<option value="today">Hoy</option>
|
|
||||||
<option value="week">Última semana</option>
|
|
||||||
<option value="month">Último mes</option>
|
|
||||||
</Select>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<CardBody padding={compact ? 'sm' : 'md'}>
|
<CardBody padding={compact ? 'sm' : 'md'}>
|
||||||
{filteredAlerts.length === 0 ? (
|
{filteredAlerts.length === 0 ? (
|
||||||
<div className="flex items-center justify-center h-32">
|
<div className="flex items-center justify-center h-32">
|
||||||
@@ -521,200 +177,68 @@ const AlertsPanel: React.FC<AlertsPanelProps> = ({
|
|||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
{/* Bulk actions bar */}
|
{filteredAlerts.map((alert) => (
|
||||||
{filteredAlerts.some(a => a.is_active) && (
|
<div
|
||||||
<div className="flex items-center gap-3 pb-2 border-b border-border-secondary">
|
key={alert.id}
|
||||||
<input
|
className={clsx(
|
||||||
type="checkbox"
|
'rounded-lg border transition-all duration-200 p-4',
|
||||||
className="rounded border-input-border focus:ring-color-primary"
|
SEVERITY_COLORS[alert.severity]
|
||||||
checked={selectedAlerts.length === filteredAlerts.filter(a => a.is_active).length}
|
)}
|
||||||
onChange={handleSelectAll}
|
>
|
||||||
/>
|
<div className="flex items-start gap-3">
|
||||||
<span className="text-sm text-text-secondary">
|
<div className="text-2xl">
|
||||||
{selectedAlerts.length > 0
|
{ALERT_ICONS[alert.type] || ALERT_ICONS['default']}
|
||||||
? `${selectedAlerts.length} alertas seleccionadas`
|
</div>
|
||||||
: 'Seleccionar todas'
|
|
||||||
}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Alerts list */}
|
<div className="flex-1 min-w-0">
|
||||||
{filteredAlerts.map((alert) => {
|
<div className="flex items-center gap-2 mb-2">
|
||||||
const isExpanded = expandedAlerts.includes(alert.id);
|
<Badge
|
||||||
const isSelected = selectedAlerts.includes(alert.id);
|
variant={SEVERITY_BADGE_VARIANTS[alert.severity]}
|
||||||
const availableActions = getAlertActions(alert);
|
size="sm"
|
||||||
const recommendation = getRecommendation(alert);
|
>
|
||||||
|
{SPANISH_SEVERITIES[alert.severity]}
|
||||||
return (
|
</Badge>
|
||||||
<div
|
{alert.product && (
|
||||||
key={alert.id}
|
<Badge variant="ghost" size="sm">
|
||||||
className={clsx(
|
{alert.product}
|
||||||
'rounded-lg border transition-all duration-200',
|
</Badge>
|
||||||
SEVERITY_COLORS[alert.severity],
|
|
||||||
{
|
|
||||||
'opacity-60': !alert.is_active || alert.resolved_at,
|
|
||||||
'ring-2 ring-color-primary': isSelected,
|
|
||||||
}
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<div className="p-4">
|
|
||||||
<div className="flex items-start gap-3">
|
|
||||||
{/* Selection checkbox */}
|
|
||||||
{alert.is_active && !alert.resolved_at && (
|
|
||||||
<input
|
|
||||||
type="checkbox"
|
|
||||||
className="mt-1 rounded border-input-border focus:ring-color-primary"
|
|
||||||
checked={isSelected}
|
|
||||||
onChange={() => toggleAlertSelection(alert.id)}
|
|
||||||
/>
|
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Alert icon */}
|
|
||||||
<div className="text-2xl mt-1">
|
|
||||||
{ALERT_TYPE_ICONS[alert.alert_type]}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Alert content */}
|
|
||||||
<div className="flex-1 min-w-0">
|
|
||||||
<div className="flex items-center gap-2 mb-2">
|
|
||||||
<h4 className="font-semibold text-text-primary text-sm">
|
|
||||||
{alert.title}
|
|
||||||
</h4>
|
|
||||||
<Badge
|
|
||||||
variant={SEVERITY_BADGE_VARIANTS[alert.severity]}
|
|
||||||
size="sm"
|
|
||||||
>
|
|
||||||
{SPANISH_SEVERITIES[alert.severity]}
|
|
||||||
</Badge>
|
|
||||||
{alert.product_name && (
|
|
||||||
<Badge variant="ghost" size="sm">
|
|
||||||
{alert.product_name}
|
|
||||||
</Badge>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<p className="text-text-secondary text-sm mb-2">
|
|
||||||
{compact ? alert.message.slice(0, 100) + '...' : alert.message}
|
|
||||||
</p>
|
|
||||||
|
|
||||||
{!compact && (
|
|
||||||
<p className="text-text-primary text-sm mb-3 font-medium">
|
|
||||||
💡 {recommendation}
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<div className="flex items-center gap-3 text-xs text-text-tertiary">
|
|
||||||
<span>{formatTimeAgo(alert.created_at)}</span>
|
|
||||||
{alert.acknowledged_at && (
|
|
||||||
<span>• Confirmada</span>
|
|
||||||
)}
|
|
||||||
{alert.resolved_at && (
|
|
||||||
<span>• Resuelta</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex items-center gap-1">
|
|
||||||
{/* Quick actions */}
|
|
||||||
{alert.is_active && !alert.resolved_at && (
|
|
||||||
<div className="flex gap-1">
|
|
||||||
{availableActions.slice(0, compact ? 1 : 2).map((action) => (
|
|
||||||
<Button
|
|
||||||
key={action.id}
|
|
||||||
variant={action.variant as any}
|
|
||||||
size="sm"
|
|
||||||
onClick={() => onAlertAction?.(alert.id, action.id as any)}
|
|
||||||
title={action.description}
|
|
||||||
>
|
|
||||||
{action.icon} {compact ? '' : action.label}
|
|
||||||
</Button>
|
|
||||||
))}
|
|
||||||
|
|
||||||
{availableActions.length > (compact ? 1 : 2) && (
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="sm"
|
|
||||||
onClick={() => toggleAlertExpansion(alert.id)}
|
|
||||||
>
|
|
||||||
{isExpanded ? '▼' : '▶'}
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Dismiss button */}
|
|
||||||
{onAlertDismiss && alert.is_active && (
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="sm"
|
|
||||||
onClick={() => onAlertDismiss(alert.id)}
|
|
||||||
title="Descartar alerta"
|
|
||||||
>
|
|
||||||
✕
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Expanded actions */}
|
<p className="text-text-primary text-sm mb-2">
|
||||||
{isExpanded && availableActions.length > (compact ? 1 : 2) && (
|
{alert.message}
|
||||||
<div className="mt-3 pt-3 border-t border-border-secondary">
|
</p>
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-2">
|
|
||||||
{availableActions.slice(compact ? 1 : 2).map((action) => (
|
{alert.recommendation && (
|
||||||
<Button
|
<p className="text-text-secondary text-sm mb-3">
|
||||||
key={action.id}
|
💡 {alert.recommendation}
|
||||||
variant={action.variant as any}
|
</p>
|
||||||
size="sm"
|
|
||||||
onClick={() => onAlertAction?.(alert.id, action.id as any)}
|
|
||||||
className="justify-start"
|
|
||||||
>
|
|
||||||
<span className="mr-2">{action.icon}</span>
|
|
||||||
<div className="text-left">
|
|
||||||
<div className="font-medium">{action.label}</div>
|
|
||||||
<div className="text-xs opacity-75">{action.description}</div>
|
|
||||||
</div>
|
|
||||||
</Button>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Alert details */}
|
<div className="flex items-center gap-2">
|
||||||
{!compact && isExpanded && (
|
{onAlertAction && (
|
||||||
<div className="mt-3 pt-3 border-t border-border-secondary">
|
<Button
|
||||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-3 text-xs">
|
variant="ghost"
|
||||||
<div>
|
size="sm"
|
||||||
<span className="text-text-secondary">Tipo:</span>
|
onClick={() => onAlertAction(alert.id, 'acknowledge')}
|
||||||
<div className="font-medium">{SPANISH_ALERT_TYPES[alert.alert_type]}</div>
|
>
|
||||||
</div>
|
✓ Confirmar
|
||||||
{alert.predicted_value && (
|
</Button>
|
||||||
<div>
|
)}
|
||||||
<span className="text-text-secondary">Valor Predicho:</span>
|
{onAlertDismiss && (
|
||||||
<div className="font-medium">{alert.predicted_value}</div>
|
<Button
|
||||||
</div>
|
variant="ghost"
|
||||||
)}
|
size="sm"
|
||||||
{alert.threshold_value && (
|
onClick={() => onAlertDismiss(alert.id)}
|
||||||
<div>
|
>
|
||||||
<span className="text-text-secondary">Umbral:</span>
|
✕ Descartar
|
||||||
<div className="font-medium">{alert.threshold_value}</div>
|
</Button>
|
||||||
</div>
|
)}
|
||||||
)}
|
</div>
|
||||||
{alert.model_accuracy && (
|
|
||||||
<div>
|
|
||||||
<span className="text-text-secondary">Precisión Modelo:</span>
|
|
||||||
<div className="font-medium">{(alert.model_accuracy * 100).toFixed(1)}%</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
</div>
|
||||||
})}
|
))}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</CardBody>
|
</CardBody>
|
||||||
|
|||||||
@@ -21,35 +21,17 @@ import { Select } from '../../ui';
|
|||||||
import { Badge } from '../../ui';
|
import { Badge } from '../../ui';
|
||||||
import {
|
import {
|
||||||
ForecastResponse,
|
ForecastResponse,
|
||||||
DemandTrend,
|
} from '../../../api/types/forecasting';
|
||||||
TrendDirection,
|
|
||||||
WeatherCondition,
|
|
||||||
EventType,
|
|
||||||
} from '../../../types/forecasting.types';
|
|
||||||
|
|
||||||
export interface DemandChartProps {
|
export interface DemandChartProps {
|
||||||
className?: string;
|
className?: string;
|
||||||
title?: string;
|
title?: string;
|
||||||
data?: DemandTrend[];
|
data?: ForecastResponse[];
|
||||||
products?: string[];
|
product?: string;
|
||||||
selectedProducts?: string[];
|
period?: string;
|
||||||
onProductSelectionChange?: (products: string[]) => void;
|
|
||||||
timeframe?: 'weekly' | 'monthly' | 'quarterly' | 'yearly';
|
timeframe?: 'weekly' | 'monthly' | 'quarterly' | 'yearly';
|
||||||
onTimeframeChange?: (timeframe: 'weekly' | 'monthly' | 'quarterly' | 'yearly') => void;
|
onTimeframeChange?: (timeframe: 'weekly' | 'monthly' | 'quarterly' | 'yearly') => void;
|
||||||
showConfidenceInterval?: boolean;
|
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;
|
loading?: boolean;
|
||||||
error?: string | null;
|
error?: string | null;
|
||||||
height?: number;
|
height?: number;
|
||||||
@@ -62,15 +44,6 @@ interface ChartDataPoint {
|
|||||||
predictedDemand?: number;
|
predictedDemand?: number;
|
||||||
confidenceLower?: number;
|
confidenceLower?: number;
|
||||||
confidenceUpper?: 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;
|
temperature?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -85,52 +58,25 @@ const PRODUCT_COLORS = [
|
|||||||
'#87ceeb', // Mazapán
|
'#87ceeb', // Mazapán
|
||||||
];
|
];
|
||||||
|
|
||||||
const EVENT_ICONS: Record<EventType, string> = {
|
const WEATHER_ICONS: Record<string, string> = {
|
||||||
[EventType.HOLIDAY]: '🎉',
|
'sunny': '☀️',
|
||||||
[EventType.FESTIVAL]: '🎪',
|
'cloudy': '☁️',
|
||||||
[EventType.SPORTS_EVENT]: '⚽',
|
'rainy': '🌧️',
|
||||||
[EventType.WEATHER_EVENT]: '🌧️',
|
'stormy': '⛈️',
|
||||||
[EventType.SCHOOL_EVENT]: '🎒',
|
'snowy': '❄️',
|
||||||
[EventType.CONCERT]: '🎵',
|
'foggy': '🌫️',
|
||||||
[EventType.CONFERENCE]: '📊',
|
'windy': '🌪️',
|
||||||
[EventType.CONSTRUCTION]: '🚧',
|
|
||||||
};
|
|
||||||
|
|
||||||
const WEATHER_ICONS: Record<WeatherCondition, string> = {
|
|
||||||
[WeatherCondition.SUNNY]: '☀️',
|
|
||||||
[WeatherCondition.CLOUDY]: '☁️',
|
|
||||||
[WeatherCondition.RAINY]: '🌧️',
|
|
||||||
[WeatherCondition.STORMY]: '⛈️',
|
|
||||||
[WeatherCondition.SNOWY]: '❄️',
|
|
||||||
[WeatherCondition.FOGGY]: '🌫️',
|
|
||||||
[WeatherCondition.WINDY]: '🌪️',
|
|
||||||
};
|
|
||||||
|
|
||||||
const SPANISH_EVENT_NAMES: Record<EventType, string> = {
|
|
||||||
[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 DemandChart: React.FC<DemandChartProps> = ({
|
const DemandChart: React.FC<DemandChartProps> = ({
|
||||||
className,
|
className,
|
||||||
title = 'Predicción de Demanda',
|
title = 'Predicción de Demanda',
|
||||||
data = [],
|
data = [],
|
||||||
products = [],
|
product = '',
|
||||||
selectedProducts = [],
|
period = '7',
|
||||||
onProductSelectionChange,
|
|
||||||
timeframe = 'weekly',
|
timeframe = 'weekly',
|
||||||
onTimeframeChange,
|
onTimeframeChange,
|
||||||
showConfidenceInterval = true,
|
showConfidenceInterval = true,
|
||||||
showEvents = true,
|
|
||||||
showWeatherOverlay = false,
|
|
||||||
events = [],
|
|
||||||
weatherData = [],
|
|
||||||
loading = false,
|
loading = false,
|
||||||
error = null,
|
error = null,
|
||||||
height = 400,
|
height = 400,
|
||||||
@@ -140,34 +86,21 @@ const DemandChart: React.FC<DemandChartProps> = ({
|
|||||||
const [zoomedData, setZoomedData] = useState<ChartDataPoint[]>([]);
|
const [zoomedData, setZoomedData] = useState<ChartDataPoint[]>([]);
|
||||||
const [hoveredPoint, setHoveredPoint] = useState<ChartDataPoint | null>(null);
|
const [hoveredPoint, setHoveredPoint] = useState<ChartDataPoint | null>(null);
|
||||||
|
|
||||||
// Process and merge data with events and weather
|
// Process forecast data for chart
|
||||||
const chartData = useMemo(() => {
|
const chartData = useMemo(() => {
|
||||||
const processedData: ChartDataPoint[] = data.map(point => {
|
const processedData: ChartDataPoint[] = data.map(forecast => {
|
||||||
const dateStr = point.date;
|
|
||||||
const event = events.find(e => e.date === dateStr);
|
|
||||||
const weather = weatherData.find(w => w.date === dateStr);
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
date: dateStr,
|
date: forecast.forecast_date,
|
||||||
actualDemand: point.actual_demand,
|
actualDemand: undefined, // Not available in current forecast response
|
||||||
predictedDemand: point.predicted_demand,
|
predictedDemand: forecast.predicted_demand,
|
||||||
confidenceLower: point.confidence_lower,
|
confidenceLower: forecast.confidence_lower,
|
||||||
confidenceUpper: point.confidence_upper,
|
confidenceUpper: forecast.confidence_upper,
|
||||||
accuracy: point.accuracy,
|
temperature: forecast.weather_temperature,
|
||||||
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,
|
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
return processedData;
|
return processedData.sort((a, b) => new Date(a.date).getTime() - new Date(b.date).getTime());
|
||||||
}, [data, events, weatherData]);
|
}, [data]);
|
||||||
|
|
||||||
// Filter data based on selected period
|
// Filter data based on selected period
|
||||||
const filteredData = useMemo(() => {
|
const filteredData = useMemo(() => {
|
||||||
@@ -218,13 +151,6 @@ const DemandChart: React.FC<DemandChartProps> = ({
|
|||||||
<h4 className="font-semibold text-text-primary mb-2">{formattedDate}</h4>
|
<h4 className="font-semibold text-text-primary mb-2">{formattedDate}</h4>
|
||||||
|
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
{data.actualDemand !== undefined && (
|
|
||||||
<div className="flex justify-between items-center">
|
|
||||||
<span className="text-text-secondary text-sm">Demanda Real:</span>
|
|
||||||
<span className="font-medium text-[var(--color-info)]">{data.actualDemand}</span>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{data.predictedDemand !== undefined && (
|
{data.predictedDemand !== undefined && (
|
||||||
<div className="flex justify-between items-center">
|
<div className="flex justify-between items-center">
|
||||||
<span className="text-text-secondary text-sm">Demanda Predicha:</span>
|
<span className="text-text-secondary text-sm">Demanda Predicha:</span>
|
||||||
@@ -241,88 +167,17 @@ const DemandChart: React.FC<DemandChartProps> = ({
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{data.accuracy !== undefined && (
|
{data.temperature && (
|
||||||
<div className="flex justify-between items-center">
|
<div className="flex justify-between items-center">
|
||||||
<span className="text-text-secondary text-sm">Precisión:</span>
|
<span className="text-text-secondary text-sm">Temperatura:</span>
|
||||||
<Badge
|
<span className="text-sm text-text-tertiary">{data.temperature}°C</span>
|
||||||
variant={data.accuracy > 0.8 ? 'success' : data.accuracy > 0.6 ? 'warning' : 'danger'}
|
|
||||||
size="sm"
|
|
||||||
>
|
|
||||||
{(data.accuracy * 100).toFixed(1)}%
|
|
||||||
</Badge>
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{showEvents && data.hasEvent && (
|
|
||||||
<div className="mt-3 pt-2 border-t border-border-secondary">
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<span className="text-lg">{EVENT_ICONS[data.eventType!]}</span>
|
|
||||||
<div className="flex-1">
|
|
||||||
<div className="font-medium text-text-primary text-sm">{data.eventName}</div>
|
|
||||||
<div className="text-xs text-text-tertiary">
|
|
||||||
{SPANISH_EVENT_NAMES[data.eventType!]}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<Badge
|
|
||||||
variant={data.eventImpact === 'high' ? 'danger' : data.eventImpact === 'medium' ? 'warning' : 'success'}
|
|
||||||
size="sm"
|
|
||||||
>
|
|
||||||
{data.eventImpact === 'high' ? 'Alto' : data.eventImpact === 'medium' ? 'Medio' : 'Bajo'}
|
|
||||||
</Badge>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{showWeatherOverlay && data.weather && (
|
|
||||||
<div className="mt-3 pt-2 border-t border-border-secondary">
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<span className="text-lg">{WEATHER_ICONS[data.weather]}</span>
|
|
||||||
<div className="flex-1">
|
|
||||||
<div className="text-sm text-text-primary capitalize">
|
|
||||||
{data.weather.replace('_', ' ')}
|
|
||||||
</div>
|
|
||||||
{data.temperature && (
|
|
||||||
<div className="text-xs text-text-tertiary">
|
|
||||||
{data.temperature}°C
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
// 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 (
|
|
||||||
<ReferenceDot
|
|
||||||
key={`event-${index}`}
|
|
||||||
x={event.date}
|
|
||||||
y={yValue}
|
|
||||||
r={8}
|
|
||||||
fill={event.impact === 'high' ? '#ef4444' : event.impact === 'medium' ? '#f59e0b' : '#10b981'}
|
|
||||||
stroke="#ffffff"
|
|
||||||
strokeWidth={2}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
// Loading state
|
// Loading state
|
||||||
if (loading) {
|
if (loading) {
|
||||||
@@ -489,30 +344,17 @@ const DemandChart: React.FC<DemandChartProps> = ({
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Actual demand line */}
|
|
||||||
<Line
|
|
||||||
type="monotone"
|
|
||||||
dataKey="actualDemand"
|
|
||||||
stroke="#3b82f6"
|
|
||||||
strokeWidth={3}
|
|
||||||
dot={false}
|
|
||||||
activeDot={{ r: 6, stroke: '#3b82f6', strokeWidth: 2 }}
|
|
||||||
name="Demanda Real"
|
|
||||||
/>
|
|
||||||
|
|
||||||
{/* Predicted demand line */}
|
{/* Predicted demand line */}
|
||||||
<Line
|
<Line
|
||||||
type="monotone"
|
type="monotone"
|
||||||
dataKey="predictedDemand"
|
dataKey="predictedDemand"
|
||||||
stroke="#10b981"
|
stroke="#10b981"
|
||||||
strokeWidth={2}
|
strokeWidth={3}
|
||||||
strokeDasharray="5 5"
|
dot={true}
|
||||||
dot={false}
|
dotSize={6}
|
||||||
activeDot={{ r: 4, stroke: '#10b981', strokeWidth: 2 }}
|
activeDot={{ r: 8, stroke: '#10b981', strokeWidth: 2 }}
|
||||||
name="Demanda Predicha"
|
name="Demanda Predicha"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{renderEventDots()}
|
|
||||||
</ComposedChart>
|
</ComposedChart>
|
||||||
</ResponsiveContainer>
|
</ResponsiveContainer>
|
||||||
</div>
|
</div>
|
||||||
@@ -547,11 +389,7 @@ const DemandChart: React.FC<DemandChartProps> = ({
|
|||||||
{/* Chart legend */}
|
{/* Chart legend */}
|
||||||
<div className="flex items-center justify-center gap-6 mt-4 text-sm">
|
<div className="flex items-center justify-center gap-6 mt-4 text-sm">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<div className="w-4 h-0.5 bg-[var(--color-info)]/50"></div>
|
<div className="w-4 h-0.5 bg-green-500"></div>
|
||||||
<span className="text-text-secondary">Demanda Real</span>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<div className="w-4 h-0.5 bg-green-500 border-dashed border-t-2 border-green-500"></div>
|
|
||||||
<span className="text-text-secondary">Demanda Predicha</span>
|
<span className="text-text-secondary">Demanda Predicha</span>
|
||||||
</div>
|
</div>
|
||||||
{showConfidenceInterval && (
|
{showConfidenceInterval && (
|
||||||
@@ -560,12 +398,6 @@ const DemandChart: React.FC<DemandChartProps> = ({
|
|||||||
<span className="text-text-secondary">Intervalo de Confianza</span>
|
<span className="text-text-secondary">Intervalo de Confianza</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{showEvents && events.length > 0 && (
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<div className="w-2 h-2 bg-red-500 rounded-full"></div>
|
|
||||||
<span className="text-text-secondary">Eventos</span>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
</CardBody>
|
</CardBody>
|
||||||
</Card>
|
</Card>
|
||||||
|
|||||||
@@ -8,9 +8,7 @@ import { Input } from '../../ui';
|
|||||||
import { Table, TableColumn } from '../../ui';
|
import { Table, TableColumn } from '../../ui';
|
||||||
import {
|
import {
|
||||||
ForecastResponse,
|
ForecastResponse,
|
||||||
TrendDirection,
|
} from '../../../api/types/forecasting';
|
||||||
ModelType,
|
|
||||||
} from '../../../types/forecasting.types';
|
|
||||||
|
|
||||||
export interface ForecastTableProps {
|
export interface ForecastTableProps {
|
||||||
className?: string;
|
className?: string;
|
||||||
@@ -31,7 +29,7 @@ interface FilterState {
|
|||||||
productName: string;
|
productName: string;
|
||||||
category: string;
|
category: string;
|
||||||
accuracyRange: 'all' | 'high' | 'medium' | 'low';
|
accuracyRange: 'all' | 'high' | 'medium' | 'low';
|
||||||
trendDirection: TrendDirection | 'all';
|
trendDirection: 'increasing' | 'decreasing' | 'stable' | 'all';
|
||||||
confidenceLevel: number | 'all';
|
confidenceLevel: number | 'all';
|
||||||
dateRange: 'today' | 'week' | 'month' | 'quarter' | 'all';
|
dateRange: 'today' | 'week' | 'month' | 'quarter' | 'all';
|
||||||
}
|
}
|
||||||
@@ -41,28 +39,28 @@ interface SortState {
|
|||||||
order: 'asc' | 'desc';
|
order: 'asc' | 'desc';
|
||||||
}
|
}
|
||||||
|
|
||||||
const SPANISH_TRENDS: Record<TrendDirection, string> = {
|
const SPANISH_TRENDS: Record<string, string> = {
|
||||||
[TrendDirection.INCREASING]: 'Creciente',
|
'increasing': 'Creciente',
|
||||||
[TrendDirection.DECREASING]: 'Decreciente',
|
'decreasing': 'Decreciente',
|
||||||
[TrendDirection.STABLE]: 'Estable',
|
'stable': 'Estable',
|
||||||
[TrendDirection.VOLATILE]: 'Volátil',
|
'volatile': 'Volátil',
|
||||||
[TrendDirection.SEASONAL]: 'Estacional',
|
'seasonal': 'Estacional',
|
||||||
};
|
};
|
||||||
|
|
||||||
const TREND_COLORS: Record<TrendDirection, string> = {
|
const TREND_COLORS: Record<string, string> = {
|
||||||
[TrendDirection.INCREASING]: 'text-[var(--color-success)]',
|
'increasing': 'text-[var(--color-success)]',
|
||||||
[TrendDirection.DECREASING]: 'text-[var(--color-error)]',
|
'decreasing': 'text-[var(--color-error)]',
|
||||||
[TrendDirection.STABLE]: 'text-[var(--color-info)]',
|
'stable': 'text-[var(--color-info)]',
|
||||||
[TrendDirection.VOLATILE]: 'text-yellow-600',
|
'volatile': 'text-yellow-600',
|
||||||
[TrendDirection.SEASONAL]: 'text-purple-600',
|
'seasonal': 'text-purple-600',
|
||||||
};
|
};
|
||||||
|
|
||||||
const TREND_ICONS: Record<TrendDirection, string> = {
|
const TREND_ICONS: Record<string, string> = {
|
||||||
[TrendDirection.INCREASING]: '↗️',
|
'increasing': '↗️',
|
||||||
[TrendDirection.DECREASING]: '↘️',
|
'decreasing': '↘️',
|
||||||
[TrendDirection.STABLE]: '➡️',
|
'stable': '➡️',
|
||||||
[TrendDirection.VOLATILE]: '📈',
|
'volatile': '📈',
|
||||||
[TrendDirection.SEASONAL]: '🔄',
|
'seasonal': '🔄',
|
||||||
};
|
};
|
||||||
|
|
||||||
const BAKERY_CATEGORIES = [
|
const BAKERY_CATEGORIES = [
|
||||||
@@ -140,17 +138,17 @@ const ForecastTable: React.FC<ForecastTableProps> = ({
|
|||||||
// Apply filters
|
// Apply filters
|
||||||
if (filters.productName) {
|
if (filters.productName) {
|
||||||
filtered = filtered.filter(item =>
|
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') {
|
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') {
|
if (filters.accuracyRange !== 'all') {
|
||||||
filtered = filtered.filter(item => {
|
filtered = filtered.filter(item => {
|
||||||
const accuracy = item.accuracy_score || 0;
|
const accuracy = item.confidence_level || 0;
|
||||||
switch (filters.accuracyRange) {
|
switch (filters.accuracyRange) {
|
||||||
case 'high': return accuracy >= 0.8;
|
case 'high': return accuracy >= 0.8;
|
||||||
case 'medium': return accuracy >= 0.6 && accuracy < 0.8;
|
case 'medium': return accuracy >= 0.6 && accuracy < 0.8;
|
||||||
@@ -162,8 +160,8 @@ const ForecastTable: React.FC<ForecastTableProps> = ({
|
|||||||
|
|
||||||
if (filters.trendDirection !== 'all') {
|
if (filters.trendDirection !== 'all') {
|
||||||
filtered = filtered.filter(item => {
|
filtered = filtered.filter(item => {
|
||||||
// Determine trend from predicted vs historical (simplified)
|
// Determine trend from predicted demand (simplified)
|
||||||
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 trend === filters.trendDirection;
|
return trend === filters.trendDirection;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -200,21 +198,17 @@ const ForecastTable: React.FC<ForecastTableProps> = ({
|
|||||||
let bValue: any;
|
let bValue: any;
|
||||||
|
|
||||||
switch (sort.field) {
|
switch (sort.field) {
|
||||||
case 'product_name':
|
case 'inventory_product_id':
|
||||||
aValue = a.product_name;
|
aValue = a.inventory_product_id;
|
||||||
bValue = b.product_name;
|
bValue = b.inventory_product_id;
|
||||||
break;
|
break;
|
||||||
case 'predicted_demand':
|
case 'predicted_demand':
|
||||||
aValue = a.predicted_demand;
|
aValue = a.predicted_demand;
|
||||||
bValue = b.predicted_demand;
|
bValue = b.predicted_demand;
|
||||||
break;
|
break;
|
||||||
case 'accuracy_score':
|
|
||||||
aValue = a.accuracy_score || 0;
|
|
||||||
bValue = b.accuracy_score || 0;
|
|
||||||
break;
|
|
||||||
case 'confidence_level':
|
case 'confidence_level':
|
||||||
aValue = a.confidence_level;
|
aValue = a.confidence_level || 0;
|
||||||
bValue = b.confidence_level;
|
bValue = b.confidence_level || 0;
|
||||||
break;
|
break;
|
||||||
case 'forecast_date':
|
case 'forecast_date':
|
||||||
aValue = new Date(a.forecast_date);
|
aValue = new Date(a.forecast_date);
|
||||||
@@ -263,7 +257,7 @@ const ForecastTable: React.FC<ForecastTableProps> = ({
|
|||||||
|
|
||||||
// Calculate accuracy percentage and trend
|
// Calculate accuracy percentage and trend
|
||||||
const getAccuracyInfo = useCallback((item: ForecastResponse) => {
|
const getAccuracyInfo = useCallback((item: ForecastResponse) => {
|
||||||
const accuracy = item.accuracy_score || 0;
|
const accuracy = item.confidence_level || 0;
|
||||||
const percentage = (accuracy * 100).toFixed(1);
|
const percentage = (accuracy * 100).toFixed(1);
|
||||||
|
|
||||||
let variant: 'success' | 'warning' | 'danger' = 'success';
|
let variant: 'success' | 'warning' | 'danger' = 'success';
|
||||||
@@ -276,8 +270,7 @@ const ForecastTable: React.FC<ForecastTableProps> = ({
|
|||||||
// Get trend info
|
// Get trend info
|
||||||
const getTrendInfo = useCallback((item: ForecastResponse) => {
|
const getTrendInfo = useCallback((item: ForecastResponse) => {
|
||||||
// Simplified trend calculation
|
// Simplified trend calculation
|
||||||
const trend = item.predicted_demand > (item.actual_demand || 0) ?
|
const trend = item.predicted_demand > 50 ? 'increasing' : item.predicted_demand < 20 ? 'decreasing' : 'stable';
|
||||||
TrendDirection.INCREASING : TrendDirection.DECREASING;
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
direction: trend,
|
direction: trend,
|
||||||
@@ -291,9 +284,9 @@ const ForecastTable: React.FC<ForecastTableProps> = ({
|
|||||||
const columns: TableColumn<ForecastResponse>[] = useMemo(() => {
|
const columns: TableColumn<ForecastResponse>[] = useMemo(() => {
|
||||||
const baseColumns: TableColumn<ForecastResponse>[] = [
|
const baseColumns: TableColumn<ForecastResponse>[] = [
|
||||||
{
|
{
|
||||||
key: 'product_name',
|
key: 'inventory_product_id',
|
||||||
title: 'Producto',
|
title: 'Producto',
|
||||||
dataIndex: 'product_name',
|
dataIndex: 'inventory_product_id',
|
||||||
sortable: true,
|
sortable: true,
|
||||||
width: compact ? 120 : 160,
|
width: compact ? 120 : 160,
|
||||||
render: (value: string, record: ForecastResponse) => (
|
render: (value: string, record: ForecastResponse) => (
|
||||||
@@ -324,21 +317,9 @@ const ForecastTable: React.FC<ForecastTableProps> = ({
|
|||||||
),
|
),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: 'actual_demand',
|
key: 'confidence_level',
|
||||||
title: 'Demanda Real',
|
title: 'Confianza',
|
||||||
dataIndex: 'actual_demand',
|
dataIndex: 'confidence_level',
|
||||||
align: 'right' as const,
|
|
||||||
width: compact ? 80 : 100,
|
|
||||||
render: (value?: number) => (
|
|
||||||
<div className="font-medium text-[var(--color-info)]">
|
|
||||||
{value ? value.toFixed(0) : '-'}
|
|
||||||
</div>
|
|
||||||
),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: 'accuracy_score',
|
|
||||||
title: 'Precisión',
|
|
||||||
dataIndex: 'accuracy_score',
|
|
||||||
sortable: true,
|
sortable: true,
|
||||||
align: 'center' as const,
|
align: 'center' as const,
|
||||||
width: 80,
|
width: 80,
|
||||||
|
|||||||
@@ -22,56 +22,25 @@ import { Card, CardHeader, CardBody } from '../../ui';
|
|||||||
import { Button } from '../../ui';
|
import { Button } from '../../ui';
|
||||||
import { Badge } from '../../ui';
|
import { Badge } from '../../ui';
|
||||||
import { Select } from '../../ui';
|
import { Select } from '../../ui';
|
||||||
import {
|
// Simplified interfaces for seasonality data
|
||||||
SeasonalPattern,
|
interface SimpleSeasonalData {
|
||||||
SeasonalComponent,
|
month: string;
|
||||||
HolidayEffect,
|
value: number;
|
||||||
WeeklyPattern,
|
season: string;
|
||||||
YearlyTrend,
|
}
|
||||||
Season,
|
|
||||||
SeasonalPeriod,
|
interface SimpleWeeklyData {
|
||||||
DayOfWeek,
|
day: string;
|
||||||
} from '../../../types/forecasting.types';
|
value: number;
|
||||||
|
}
|
||||||
|
|
||||||
export interface SeasonalityIndicatorProps {
|
export interface SeasonalityIndicatorProps {
|
||||||
className?: string;
|
className?: string;
|
||||||
title?: 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;
|
loading?: boolean;
|
||||||
error?: string | null;
|
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 = [
|
const SPANISH_MONTHS = [
|
||||||
'Enero', 'Febrero', 'Marzo', 'Abril', 'Mayo', 'Junio',
|
'Enero', 'Febrero', 'Marzo', 'Abril', 'Mayo', 'Junio',
|
||||||
'Julio', 'Agosto', 'Septiembre', 'Octubre', 'Noviembre', 'Diciembre'
|
'Julio', 'Agosto', 'Septiembre', 'Octubre', 'Noviembre', 'Diciembre'
|
||||||
@@ -85,18 +54,18 @@ const SPANISH_DAYS_SHORT = [
|
|||||||
'L', 'M', 'X', 'J', 'V', 'S', 'D'
|
'L', 'M', 'X', 'J', 'V', 'S', 'D'
|
||||||
];
|
];
|
||||||
|
|
||||||
const SPANISH_SEASONS: Record<Season, string> = {
|
const SPANISH_SEASONS: Record<string, string> = {
|
||||||
[Season.SPRING]: 'Primavera',
|
'spring': 'Primavera',
|
||||||
[Season.SUMMER]: 'Verano',
|
'summer': 'Verano',
|
||||||
[Season.FALL]: 'Otoño',
|
'fall': 'Otoño',
|
||||||
[Season.WINTER]: 'Invierno',
|
'winter': 'Invierno',
|
||||||
};
|
};
|
||||||
|
|
||||||
const SEASON_COLORS: Record<Season, string> = {
|
const SEASON_COLORS: Record<string, string> = {
|
||||||
[Season.SPRING]: '#22c55e', // Green
|
'spring': '#22c55e', // Green
|
||||||
[Season.SUMMER]: '#f59e0b', // Amber
|
'summer': '#f59e0b', // Amber
|
||||||
[Season.FALL]: '#ea580c', // Orange
|
'fall': '#ea580c', // Orange
|
||||||
[Season.WINTER]: '#3b82f6', // Blue
|
'winter': '#3b82f6', // Blue
|
||||||
};
|
};
|
||||||
|
|
||||||
const SPANISH_HOLIDAYS = [
|
const SPANISH_HOLIDAYS = [
|
||||||
@@ -125,308 +94,9 @@ const INTENSITY_COLORS = [
|
|||||||
const SeasonalityIndicator: React.FC<SeasonalityIndicatorProps> = ({
|
const SeasonalityIndicator: React.FC<SeasonalityIndicatorProps> = ({
|
||||||
className,
|
className,
|
||||||
title = 'Patrones Estacionales',
|
title = 'Patrones Estacionales',
|
||||||
seasonalPatterns = [],
|
|
||||||
selectedProduct = '',
|
|
||||||
onProductChange,
|
|
||||||
viewMode = 'circular',
|
|
||||||
onViewModeChange,
|
|
||||||
showComparison = false,
|
|
||||||
comparisonYear = new Date().getFullYear() - 1,
|
|
||||||
loading = false,
|
loading = false,
|
||||||
error = null,
|
error = null,
|
||||||
}) => {
|
}) => {
|
||||||
const [hoveredElement, setHoveredElement] = useState<any>(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 (
|
|
||||||
<div className="bg-card-bg border border-border-primary rounded-lg shadow-lg p-3 max-w-sm">
|
|
||||||
<h4 className="font-semibold text-text-primary mb-2">{data.month}</h4>
|
|
||||||
<div className="space-y-1">
|
|
||||||
<div className="flex justify-between items-center">
|
|
||||||
<span className="text-text-secondary text-sm">Estación:</span>
|
|
||||||
<Badge variant="ghost" size="sm" style={{ color: data.color }}>
|
|
||||||
{SPANISH_SEASONS[data.season]}
|
|
||||||
</Badge>
|
|
||||||
</div>
|
|
||||||
<div className="flex justify-between items-center">
|
|
||||||
<span className="text-text-secondary text-sm">Variación:</span>
|
|
||||||
<span className="font-medium">{data.value.toFixed(1)}%</span>
|
|
||||||
</div>
|
|
||||||
<div className="flex justify-between items-center">
|
|
||||||
<span className="text-text-secondary text-sm">Intensidad:</span>
|
|
||||||
<span className="font-medium">{data.strength.toFixed(1)}%</span>
|
|
||||||
</div>
|
|
||||||
{data.holidays.length > 0 && (
|
|
||||||
<div className="mt-2 pt-2 border-t border-border-secondary">
|
|
||||||
<div className="text-text-secondary text-sm mb-1">Festividades:</div>
|
|
||||||
<div className="flex flex-wrap gap-1">
|
|
||||||
{data.holidays.map(holiday => (
|
|
||||||
<Badge key={holiday} variant="outlined" size="sm">
|
|
||||||
{holiday}
|
|
||||||
</Badge>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
// Circular view (Radial chart)
|
|
||||||
const renderCircularView = () => (
|
|
||||||
<div style={{ width: '100%', height: 400 }}>
|
|
||||||
<ResponsiveContainer>
|
|
||||||
<RadialBarChart
|
|
||||||
cx="50%"
|
|
||||||
cy="50%"
|
|
||||||
innerRadius="60%"
|
|
||||||
outerRadius="90%"
|
|
||||||
data={monthlyData}
|
|
||||||
startAngle={90}
|
|
||||||
endAngle={450}
|
|
||||||
>
|
|
||||||
<RadialBar
|
|
||||||
dataKey="strength"
|
|
||||||
cornerRadius={4}
|
|
||||||
fill={(entry) => entry.color}
|
|
||||||
/>
|
|
||||||
<Tooltip content={<RadialTooltip />} />
|
|
||||||
</RadialBarChart>
|
|
||||||
</ResponsiveContainer>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
|
|
||||||
// Calendar view (Bar chart by month)
|
|
||||||
const renderCalendarView = () => (
|
|
||||||
<div style={{ width: '100%', height: 400 }}>
|
|
||||||
<ResponsiveContainer>
|
|
||||||
<ComposedChart data={monthlyData} margin={{ top: 20, right: 30, left: 20, bottom: 5 }}>
|
|
||||||
<CartesianGrid strokeDasharray="3 3" />
|
|
||||||
<XAxis
|
|
||||||
dataKey="month"
|
|
||||||
tick={{ fontSize: 12 }}
|
|
||||||
angle={-45}
|
|
||||||
textAnchor="end"
|
|
||||||
height={80}
|
|
||||||
/>
|
|
||||||
<YAxis tick={{ fontSize: 12 }} />
|
|
||||||
<Tooltip
|
|
||||||
content={({ active, payload, label }) => {
|
|
||||||
if (!active || !payload || !payload.length) return null;
|
|
||||||
const data = payload[0].payload as MonthlyData;
|
|
||||||
return <RadialTooltip active={true} payload={[{ payload: data }]} />;
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<Bar
|
|
||||||
dataKey="value"
|
|
||||||
fill={(entry, index) => monthlyData[index]?.color || '#8884d8'}
|
|
||||||
/>
|
|
||||||
<Line
|
|
||||||
type="monotone"
|
|
||||||
dataKey="strength"
|
|
||||||
stroke="#ff7300"
|
|
||||||
strokeWidth={2}
|
|
||||||
dot={false}
|
|
||||||
/>
|
|
||||||
<ReferenceLine y={0} stroke="#666" strokeDasharray="2 2" />
|
|
||||||
</ComposedChart>
|
|
||||||
</ResponsiveContainer>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
|
|
||||||
// Heatmap view
|
|
||||||
const renderHeatmapView = () => (
|
|
||||||
<div className="grid grid-cols-12 gap-1 p-4">
|
|
||||||
{/* Month labels */}
|
|
||||||
<div className="col-span-12 grid grid-cols-12 gap-1 mb-2">
|
|
||||||
{SPANISH_MONTHS.map(month => (
|
|
||||||
<div key={month} className="text-xs text-center text-text-tertiary font-medium">
|
|
||||||
{month.slice(0, 3)}
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Heatmap grid */}
|
|
||||||
{[0, 1, 2, 3].map(week => (
|
|
||||||
<div key={week} className="col-span-12 grid grid-cols-12 gap-1">
|
|
||||||
{heatmapData
|
|
||||||
.filter(d => d.week === week)
|
|
||||||
.map((cell, monthIndex) => (
|
|
||||||
<div
|
|
||||||
key={`${cell.month}-${cell.week}`}
|
|
||||||
className="aspect-square rounded cursor-pointer transition-all duration-200 hover:scale-110 flex items-center justify-center"
|
|
||||||
style={{
|
|
||||||
backgroundColor: INTENSITY_COLORS[cell.intensity],
|
|
||||||
border: hoveredElement?.month === cell.month && hoveredElement?.week === cell.week
|
|
||||||
? '2px solid #3b82f6' : '1px solid #e5e7eb'
|
|
||||||
}}
|
|
||||||
onMouseEnter={() => setHoveredElement(cell)}
|
|
||||||
onMouseLeave={() => setHoveredElement(null)}
|
|
||||||
title={`${SPANISH_MONTHS[cell.month]} S${cell.week + 1}: ${cell.value.toFixed(1)}%`}
|
|
||||||
>
|
|
||||||
{cell.holiday && (
|
|
||||||
<div className="text-xs">🎉</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
|
|
||||||
{/* Legend */}
|
|
||||||
<div className="col-span-12 flex items-center justify-center gap-4 mt-4">
|
|
||||||
<span className="text-sm text-text-secondary">Baja</span>
|
|
||||||
<div className="flex gap-1">
|
|
||||||
{INTENSITY_COLORS.map((color, index) => (
|
|
||||||
<div
|
|
||||||
key={index}
|
|
||||||
className="w-4 h-4 rounded"
|
|
||||||
style={{ backgroundColor: color }}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
<span className="text-sm text-text-secondary">Alta</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
|
|
||||||
// Trends view (Weekly patterns)
|
|
||||||
const renderTrendsView = () => (
|
|
||||||
<div style={{ width: '100%', height: 400 }}>
|
|
||||||
<ResponsiveContainer>
|
|
||||||
<BarChart data={weeklyData} margin={{ top: 20, right: 30, left: 20, bottom: 5 }}>
|
|
||||||
<CartesianGrid strokeDasharray="3 3" />
|
|
||||||
<XAxis dataKey="dayShort" tick={{ fontSize: 12 }} />
|
|
||||||
<YAxis tick={{ fontSize: 12 }} />
|
|
||||||
<Tooltip
|
|
||||||
content={({ active, payload, label }) => {
|
|
||||||
if (!active || !payload || !payload.length) return null;
|
|
||||||
const data = payload[0].payload as WeeklyData;
|
|
||||||
return (
|
|
||||||
<div className="bg-card-bg border border-border-primary rounded-lg shadow-lg p-3">
|
|
||||||
<h4 className="font-semibold text-text-primary mb-2">{data.day}</h4>
|
|
||||||
<div className="space-y-1 text-sm">
|
|
||||||
<div className="flex justify-between">
|
|
||||||
<span>Multiplicador Promedio:</span>
|
|
||||||
<span className="font-medium">{data.value.toFixed(1)}%</span>
|
|
||||||
</div>
|
|
||||||
<div className="flex justify-between">
|
|
||||||
<span>Varianza:</span>
|
|
||||||
<span className="font-medium">{data.variance.toFixed(1)}%</span>
|
|
||||||
</div>
|
|
||||||
{data.peakHours && data.peakHours.length > 0 && (
|
|
||||||
<div className="flex justify-between">
|
|
||||||
<span>Horas Pico:</span>
|
|
||||||
<span className="font-medium">
|
|
||||||
{data.peakHours.map(h => `${h}:00`).join(', ')}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<Bar dataKey="value" fill="#8884d8" />
|
|
||||||
</BarChart>
|
|
||||||
</ResponsiveContainer>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
|
|
||||||
// Loading state
|
// Loading state
|
||||||
if (loading) {
|
if (loading) {
|
||||||
return (
|
return (
|
||||||
@@ -469,161 +139,24 @@ const SeasonalityIndicator: React.FC<SeasonalityIndicatorProps> = ({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Empty state
|
// Placeholder view - no complex seasonality data available yet
|
||||||
if (!currentPattern) {
|
|
||||||
return (
|
|
||||||
<Card className={className}>
|
|
||||||
<CardHeader>
|
|
||||||
<h3 className="text-lg font-semibold text-text-primary">{title}</h3>
|
|
||||||
</CardHeader>
|
|
||||||
<CardBody>
|
|
||||||
<div className="flex items-center justify-center h-64">
|
|
||||||
<div className="text-center">
|
|
||||||
<div className="text-text-tertiary mb-2">
|
|
||||||
<svg className="w-12 h-12 mx-auto" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1} d="M8 7V3a2 2 0 012-2h4a2 2 0 012 2v4m-6 4v10a2 2 0 002 2h4a2 2 0 002-2V11m-6 0a2 2 0 012-2h4a2 2 0 012 2m-6 0h8" />
|
|
||||||
</svg>
|
|
||||||
</div>
|
|
||||||
<p className="text-text-secondary">No hay datos de estacionalidad disponibles</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</CardBody>
|
|
||||||
</Card>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Card className={className}>
|
<Card className={className}>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<div className="flex items-center justify-between flex-wrap gap-4">
|
<h3 className="text-lg font-semibold text-text-primary">{title}</h3>
|
||||||
<div className="flex items-center gap-3">
|
|
||||||
<h3 className="text-lg font-semibold text-text-primary">{title}</h3>
|
|
||||||
{currentPattern && (
|
|
||||||
<Badge variant="ghost">
|
|
||||||
{currentPattern.product_name}
|
|
||||||
</Badge>
|
|
||||||
)}
|
|
||||||
<Badge
|
|
||||||
variant="success"
|
|
||||||
size="sm"
|
|
||||||
title="Nivel de confianza del patrón estacional"
|
|
||||||
>
|
|
||||||
{(currentPattern.confidence_score * 100).toFixed(0)}% confianza
|
|
||||||
</Badge>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex items-center gap-3">
|
|
||||||
{/* Product selector */}
|
|
||||||
{onProductChange && seasonalPatterns.length > 1 && (
|
|
||||||
<Select
|
|
||||||
value={selectedProduct || seasonalPatterns[0]?.product_name || ''}
|
|
||||||
onChange={(value) => onProductChange(value)}
|
|
||||||
className="w-40"
|
|
||||||
>
|
|
||||||
{seasonalPatterns.map(pattern => (
|
|
||||||
<option key={pattern.product_name} value={pattern.product_name}>
|
|
||||||
{pattern.product_name}
|
|
||||||
</option>
|
|
||||||
))}
|
|
||||||
</Select>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* View mode selector */}
|
|
||||||
<div className="flex bg-bg-secondary rounded-lg p-1 gap-1">
|
|
||||||
{(['circular', 'calendar', 'heatmap', 'trends'] as const).map((mode) => (
|
|
||||||
<Button
|
|
||||||
key={mode}
|
|
||||||
variant={viewMode === mode ? 'filled' : 'ghost'}
|
|
||||||
size="sm"
|
|
||||||
onClick={() => onViewModeChange?.(mode)}
|
|
||||||
title={
|
|
||||||
mode === 'circular' ? 'Vista Circular' :
|
|
||||||
mode === 'calendar' ? 'Vista Calendario' :
|
|
||||||
mode === 'heatmap' ? 'Mapa de Calor' :
|
|
||||||
'Vista Tendencias'
|
|
||||||
}
|
|
||||||
>
|
|
||||||
{mode === 'circular' && '🔄'}
|
|
||||||
{mode === 'calendar' && '📅'}
|
|
||||||
{mode === 'heatmap' && '🔥'}
|
|
||||||
{mode === 'trends' && '📊'}
|
|
||||||
</Button>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
|
|
||||||
<CardBody>
|
<CardBody>
|
||||||
<div className="space-y-4">
|
<div className="flex items-center justify-center h-64">
|
||||||
{/* Main visualization */}
|
<div className="text-center">
|
||||||
<div className="min-h-[400px]">
|
<div className="text-text-tertiary mb-4">
|
||||||
{viewMode === 'circular' && renderCircularView()}
|
<svg className="w-12 h-12 mx-auto" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
{viewMode === 'calendar' && renderCalendarView()}
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1} d="M8 7V3a2 2 0 012-2h4a2 2 0 012 2v4m-6 4v10a2 2 0 002 2h4a2 2 0 002-2V11m-6 0a2 2 0 012-2h4a2 2 0 012 2m-6 0h8" />
|
||||||
{viewMode === 'heatmap' && renderHeatmapView()}
|
</svg>
|
||||||
{viewMode === 'trends' && renderTrendsView()}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Holiday effects summary */}
|
|
||||||
{currentPattern.holiday_effects && currentPattern.holiday_effects.length > 0 && (
|
|
||||||
<div className="border-t border-border-primary pt-4">
|
|
||||||
<h4 className="font-medium text-text-primary mb-3">Efectos de Festividades</h4>
|
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-3">
|
|
||||||
{currentPattern.holiday_effects.map((holiday, index) => (
|
|
||||||
<div key={index} className="bg-bg-secondary rounded-lg p-3">
|
|
||||||
<div className="flex items-center justify-between mb-2">
|
|
||||||
<span className="font-medium text-text-primary text-sm">
|
|
||||||
{holiday.holiday_name}
|
|
||||||
</span>
|
|
||||||
<Badge
|
|
||||||
variant={holiday.impact_factor > 1.2 ? 'success' : holiday.impact_factor < 0.8 ? 'danger' : 'warning'}
|
|
||||||
size="sm"
|
|
||||||
>
|
|
||||||
{((holiday.impact_factor - 1) * 100).toFixed(0)}%
|
|
||||||
</Badge>
|
|
||||||
</div>
|
|
||||||
<div className="text-xs text-text-secondary space-y-1">
|
|
||||||
<div>Duración: {holiday.duration_days} días</div>
|
|
||||||
<div>Confianza: {(holiday.confidence * 100).toFixed(0)}%</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Pattern strength indicators */}
|
|
||||||
<div className="border-t border-border-primary pt-4">
|
|
||||||
<h4 className="font-medium text-text-primary mb-3">Intensidad de Patrones</h4>
|
|
||||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
|
|
||||||
{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 (
|
|
||||||
<div key={index} className="bg-bg-secondary rounded-lg p-3">
|
|
||||||
<div className="text-sm font-medium text-text-primary mb-1">
|
|
||||||
{periodLabel}
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<div className="flex-1 bg-bg-tertiary rounded-full h-2">
|
|
||||||
<div
|
|
||||||
className="bg-color-primary h-2 rounded-full transition-all duration-300"
|
|
||||||
style={{ width: `${Math.min(component.strength * 100, 100)}%` }}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<span className="text-xs text-text-secondary">
|
|
||||||
{(component.strength * 100).toFixed(0)}%
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
</div>
|
||||||
|
<p className="text-text-secondary mb-2">Análisis de patrones estacionales</p>
|
||||||
|
<p className="text-text-tertiary text-sm">
|
||||||
|
Los datos de estacionalidad estarán disponibles próximamente
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</CardBody>
|
</CardBody>
|
||||||
|
|||||||
@@ -1,28 +1,68 @@
|
|||||||
import React, { useState } from 'react';
|
import React, { useState, useMemo } from 'react';
|
||||||
import { Calendar, TrendingUp, AlertTriangle, BarChart3, Download, Settings } from 'lucide-react';
|
import { Calendar, TrendingUp, AlertTriangle, BarChart3, Download, Settings, Loader } from 'lucide-react';
|
||||||
import { Button, Card, Badge, Select, Table } from '../../../../components/ui';
|
import { Button, Card, Badge, Select, Table } from '../../../../components/ui';
|
||||||
import type { TableColumn } from '../../../../components/ui';
|
import type { TableColumn } from '../../../../components/ui';
|
||||||
import { PageHeader } from '../../../../components/layout';
|
import { PageHeader } from '../../../../components/layout';
|
||||||
import { DemandChart, ForecastTable, SeasonalityIndicator, AlertsPanel } from '../../../../components/domain/forecasting';
|
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 ForecastingPage: React.FC = () => {
|
||||||
const [selectedProduct, setSelectedProduct] = useState('all');
|
const [selectedProduct, setSelectedProduct] = useState('all');
|
||||||
const [forecastPeriod, setForecastPeriod] = useState('7');
|
const [forecastPeriod, setForecastPeriod] = useState('7');
|
||||||
const [viewMode, setViewMode] = useState<'chart' | 'table'>('chart');
|
const [viewMode, setViewMode] = useState<'chart' | 'table'>('chart');
|
||||||
|
|
||||||
const forecastData = {
|
// Get tenant ID from auth user
|
||||||
accuracy: 92,
|
const user = useAuthUser();
|
||||||
totalDemand: 1247,
|
const tenantId = user?.tenant_id || '';
|
||||||
growthTrend: 8.5,
|
|
||||||
seasonalityFactor: 1.15,
|
|
||||||
};
|
|
||||||
|
|
||||||
const products = [
|
// Calculate date range based on selected period
|
||||||
{ id: 'all', name: 'Todos los productos' },
|
const endDate = new Date();
|
||||||
{ id: 'bread', name: 'Panes' },
|
const startDate = new Date();
|
||||||
{ id: 'pastry', name: 'Bollería' },
|
startDate.setDate(startDate.getDate() - parseInt(forecastPeriod));
|
||||||
{ id: 'cake', name: 'Tartas' },
|
|
||||||
];
|
// 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 = [
|
const periods = [
|
||||||
{ value: '7', label: '7 días' },
|
{ value: '7', label: '7 días' },
|
||||||
@@ -31,78 +71,51 @@ const ForecastingPage: React.FC = () => {
|
|||||||
{ value: '90', label: '3 meses' },
|
{ value: '90', label: '3 meses' },
|
||||||
];
|
];
|
||||||
|
|
||||||
const mockForecasts = [
|
// Transform forecast data for table display
|
||||||
{
|
const transformForecastsForTable = (forecasts: ForecastResponse[]) => {
|
||||||
id: '1',
|
return forecasts.map(forecast => ({
|
||||||
product: 'Pan de Molde Integral',
|
id: forecast.id,
|
||||||
currentStock: 25,
|
product: forecast.inventory_product_id, // Will need to map to product name
|
||||||
forecastDemand: 45,
|
currentStock: 'N/A', // Not available in forecast data
|
||||||
recommendedProduction: 50,
|
forecastDemand: forecast.predicted_demand,
|
||||||
confidence: 95,
|
recommendedProduction: Math.ceil(forecast.predicted_demand * 1.1), // Simple calculation
|
||||||
trend: 'up',
|
confidence: Math.round(forecast.confidence_level * 100),
|
||||||
stockoutRisk: 'low',
|
trend: forecast.predicted_demand > 0 ? 'up' : 'stable',
|
||||||
},
|
stockoutRisk: forecast.confidence_level > 0.8 ? 'low' : forecast.confidence_level > 0.6 ? 'medium' : 'high',
|
||||||
{
|
}));
|
||||||
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'],
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const seasonalInsights = [
|
// Generate alerts based on forecast data
|
||||||
{ period: 'Mañana', factor: 1.2, products: ['Pan', 'Bollería'] },
|
const generateAlertsFromForecasts = (forecasts: ForecastResponse[]) => {
|
||||||
{ period: 'Tarde', factor: 0.8, products: ['Tartas', 'Dulces'] },
|
return forecasts
|
||||||
{ period: 'Fin de semana', factor: 1.4, products: ['Tartas especiales'] },
|
.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) => {
|
const getTrendIcon = (trend: string) => {
|
||||||
switch (trend) {
|
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 (
|
return (
|
||||||
<div className="p-6 space-y-6">
|
<div className="p-6 space-y-6">
|
||||||
<PageHeader
|
<PageHeader
|
||||||
@@ -196,57 +223,87 @@ const ForecastingPage: React.FC = () => {
|
|||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* Key Metrics */}
|
{isLoading && (
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6">
|
<Card className="p-6 flex items-center justify-center">
|
||||||
<Card className="p-6">
|
<Loader className="h-6 w-6 animate-spin mr-2" />
|
||||||
<div className="flex items-center justify-between">
|
<span>Cargando predicciones...</span>
|
||||||
<div>
|
|
||||||
<p className="text-sm font-medium text-[var(--text-secondary)]">Precisión del Modelo</p>
|
|
||||||
<p className="text-3xl font-bold text-[var(--color-success)]">{forecastData.accuracy}%</p>
|
|
||||||
</div>
|
|
||||||
<div className="h-12 w-12 bg-[var(--color-success)]/10 rounded-full flex items-center justify-center">
|
|
||||||
<BarChart3 className="h-6 w-6 text-[var(--color-success)]" />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</Card>
|
</Card>
|
||||||
|
)}
|
||||||
|
|
||||||
<Card className="p-6">
|
{hasError && (
|
||||||
<div className="flex items-center justify-between">
|
<Card className="p-6 bg-red-50 border-red-200">
|
||||||
<div>
|
<div className="flex items-center">
|
||||||
<p className="text-sm font-medium text-[var(--text-secondary)]">Demanda Prevista</p>
|
<AlertTriangle className="h-5 w-5 text-red-600 mr-2" />
|
||||||
<p className="text-3xl font-bold text-[var(--color-info)]">{forecastData.totalDemand}</p>
|
<span className="text-red-800">Error al cargar las predicciones. Por favor, inténtalo de nuevo.</span>
|
||||||
<p className="text-xs text-[var(--text-tertiary)]">próximos {forecastPeriod} días</p>
|
|
||||||
</div>
|
|
||||||
<Calendar className="h-12 w-12 text-[var(--color-info)]" />
|
|
||||||
</div>
|
</div>
|
||||||
</Card>
|
</Card>
|
||||||
|
)}
|
||||||
|
|
||||||
<Card className="p-6">
|
{!isLoading && !hasError && (
|
||||||
<div className="flex items-center justify-between">
|
<>
|
||||||
<div>
|
{/* Key Metrics */}
|
||||||
<p className="text-sm font-medium text-[var(--text-secondary)]">Tendencia</p>
|
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6">
|
||||||
<p className="text-3xl font-bold text-purple-600">+{forecastData.growthTrend}%</p>
|
<Card className="p-6">
|
||||||
<p className="text-xs text-[var(--text-tertiary)]">vs período anterior</p>
|
<div className="flex items-center justify-between">
|
||||||
</div>
|
<div>
|
||||||
<TrendingUp className="h-12 w-12 text-purple-600" />
|
<p className="text-sm font-medium text-[var(--text-secondary)]">Precisión del Modelo</p>
|
||||||
</div>
|
<p className="text-3xl font-bold text-[var(--color-success)]">
|
||||||
</Card>
|
{statisticsData?.accuracy_metrics?.average_accuracy
|
||||||
|
? Math.round(statisticsData.accuracy_metrics.average_accuracy * 100)
|
||||||
|
: averageConfidence}%
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="h-12 w-12 bg-[var(--color-success)]/10 rounded-full flex items-center justify-center">
|
||||||
|
<BarChart3 className="h-6 w-6 text-[var(--color-success)]" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
|
||||||
<Card className="p-6">
|
<Card className="p-6">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<div>
|
<div>
|
||||||
<p className="text-sm font-medium text-[var(--text-secondary)]">Factor Estacional</p>
|
<p className="text-sm font-medium text-[var(--text-secondary)]">Demanda Prevista</p>
|
||||||
<p className="text-3xl font-bold text-[var(--color-primary)]">{forecastData.seasonalityFactor}x</p>
|
<p className="text-3xl font-bold text-[var(--color-info)]">{Math.round(totalDemand)}</p>
|
||||||
<p className="text-xs text-[var(--text-tertiary)]">multiplicador actual</p>
|
<p className="text-xs text-[var(--text-tertiary)]">próximos {forecastPeriod} días</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="h-12 w-12 bg-[var(--color-primary)]/10 rounded-full flex items-center justify-center">
|
<Calendar className="h-12 w-12 text-[var(--color-info)]" />
|
||||||
<svg className="h-6 w-6 text-[var(--color-primary)]" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
</div>
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 3v1m0 16v1m9-9h-1M4 12H3m15.364 6.364l-.707-.707M6.343 6.343l-.707-.707m12.728 0l-.707.707M6.343 17.657l-.707.707" />
|
</Card>
|
||||||
</svg>
|
|
||||||
</div>
|
<Card className="p-6">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<p className="text-sm font-medium text-[var(--text-secondary)]">Tendencia</p>
|
||||||
|
<p className="text-3xl font-bold text-purple-600">
|
||||||
|
+{statisticsData?.accuracy_metrics?.accuracy_trend
|
||||||
|
? Math.round(statisticsData.accuracy_metrics.accuracy_trend * 100)
|
||||||
|
: 5}%
|
||||||
|
</p>
|
||||||
|
<p className="text-xs text-[var(--text-tertiary)]">vs período anterior</p>
|
||||||
|
</div>
|
||||||
|
<TrendingUp className="h-12 w-12 text-purple-600" />
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card className="p-6">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<p className="text-sm font-medium text-[var(--text-secondary)]">Total Predicciones</p>
|
||||||
|
<p className="text-3xl font-bold text-[var(--color-primary)]">
|
||||||
|
{statisticsData?.total_forecasts || forecasts.length}
|
||||||
|
</p>
|
||||||
|
<p className="text-xs text-[var(--text-tertiary)]">generadas</p>
|
||||||
|
</div>
|
||||||
|
<div className="h-12 w-12 bg-[var(--color-primary)]/10 rounded-full flex items-center justify-center">
|
||||||
|
<svg className="h-6 w-6 text-[var(--color-primary)]" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 3v1m0 16v1m9-9h-1M4 12H3m15.364 6.364l-.707-.707M6.343 6.343l-.707-.707m12.728 0l-.707.707M6.343 17.657l-.707.707" />
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
</Card>
|
</>
|
||||||
</div>
|
)}
|
||||||
|
|
||||||
{/* Controls */}
|
{/* Controls */}
|
||||||
<Card className="p-6">
|
<Card className="p-6">
|
||||||
@@ -308,7 +365,7 @@ const ForecastingPage: React.FC = () => {
|
|||||||
period={forecastPeriod}
|
period={forecastPeriod}
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<ForecastTable forecasts={mockForecasts} />
|
<ForecastTable forecasts={transformedForecasts} />
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -317,67 +374,92 @@ const ForecastingPage: React.FC = () => {
|
|||||||
<AlertsPanel alerts={alerts} />
|
<AlertsPanel alerts={alerts} />
|
||||||
|
|
||||||
{/* Weather Impact */}
|
{/* Weather Impact */}
|
||||||
<Card className="p-6">
|
{weatherImpact && (
|
||||||
<h3 className="text-lg font-semibold text-[var(--text-primary)] mb-4">Impacto Meteorológico</h3>
|
<Card className="p-6">
|
||||||
<div className="space-y-3">
|
<h3 className="text-lg font-semibold text-[var(--text-primary)] mb-4">Impacto Meteorológico</h3>
|
||||||
<div className="flex items-center justify-between">
|
<div className="space-y-3">
|
||||||
<span className="text-sm text-[var(--text-secondary)]">Hoy:</span>
|
<div className="flex items-center justify-between">
|
||||||
<div className="flex items-center">
|
<span className="text-sm text-[var(--text-secondary)]">Hoy:</span>
|
||||||
<span className="text-sm font-medium">{weatherImpact.temperature}°C</span>
|
<div className="flex items-center">
|
||||||
<div className="ml-2 w-6 h-6 bg-yellow-400 rounded-full"></div>
|
<span className="text-sm font-medium">{weatherImpact.temperature}°C</span>
|
||||||
|
<div className="ml-2 w-6 h-6 bg-yellow-400 rounded-full"></div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
<div className="flex items-center justify-between">
|
<span className="text-sm text-[var(--text-secondary)]">Condiciones:</span>
|
||||||
<span className="text-sm text-[var(--text-secondary)]">Factor de demanda:</span>
|
<span className="text-sm font-medium text-[var(--color-info)]">{weatherImpact.today}</span>
|
||||||
<span className="text-sm font-medium text-[var(--color-info)]">{weatherImpact.demandFactor}x</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="mt-4">
|
|
||||||
<p className="text-xs text-[var(--text-tertiary)] mb-2">Categorías afectadas:</p>
|
|
||||||
<div className="flex flex-wrap gap-1">
|
|
||||||
{weatherImpact.affectedCategories.map((category, index) => (
|
|
||||||
<Badge key={index} variant="blue">{category}</Badge>
|
|
||||||
))}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
{/* Seasonal Insights */}
|
<div className="flex items-center justify-between">
|
||||||
<Card className="p-6">
|
<span className="text-sm text-[var(--text-secondary)]">Factor de demanda:</span>
|
||||||
<h3 className="text-lg font-semibold text-[var(--text-primary)] mb-4">Patrones Estacionales</h3>
|
<span className="text-sm font-medium text-[var(--color-info)]">{weatherImpact.demandFactor}x</span>
|
||||||
<div className="space-y-3">
|
</div>
|
||||||
{seasonalInsights.map((insight, index) => (
|
|
||||||
<div key={index} className="p-3 bg-[var(--bg-secondary)] rounded-lg">
|
{weatherImpact.affectedCategories.length > 0 && (
|
||||||
|
<div className="mt-4">
|
||||||
|
<p className="text-xs text-[var(--text-tertiary)] mb-2">Categorías afectadas:</p>
|
||||||
|
<div className="flex flex-wrap gap-1">
|
||||||
|
{weatherImpact.affectedCategories.map((category, index) => (
|
||||||
|
<Badge key={index} variant="blue">{category}</Badge>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Model Performance */}
|
||||||
|
{statisticsData?.model_performance && (
|
||||||
|
<Card className="p-6">
|
||||||
|
<h3 className="text-lg font-semibold text-[var(--text-primary)] mb-4">Rendimiento del Modelo</h3>
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div className="p-3 bg-[var(--bg-secondary)] rounded-lg">
|
||||||
<div className="flex items-center justify-between mb-2">
|
<div className="flex items-center justify-between mb-2">
|
||||||
<span className="text-sm font-medium">{insight.period}</span>
|
<span className="text-sm font-medium">Algoritmo principal</span>
|
||||||
<span className="text-sm text-purple-600 font-medium">{insight.factor}x</span>
|
<Badge variant="purple">{statisticsData.model_performance.most_used_algorithm}</Badge>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-wrap gap-1">
|
<div className="flex items-center justify-between">
|
||||||
{insight.products.map((product, idx) => (
|
<span className="text-xs text-[var(--text-tertiary)]">Tiempo de procesamiento promedio</span>
|
||||||
<Badge key={idx} variant="purple">{product}</Badge>
|
<span className="text-xs text-[var(--text-secondary)]">
|
||||||
))}
|
{Math.round(statisticsData.model_performance.average_processing_time)}ms
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
))}
|
</div>
|
||||||
</div>
|
</Card>
|
||||||
</Card>
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Detailed Forecasts Table */}
|
{/* Detailed Forecasts Table */}
|
||||||
<Card className="p-6">
|
{!isLoading && !hasError && transformedForecasts.length > 0 && (
|
||||||
<h3 className="text-lg font-semibold text-[var(--text-primary)] mb-4">Predicciones Detalladas</h3>
|
<Card className="p-6">
|
||||||
<Table
|
<h3 className="text-lg font-semibold text-[var(--text-primary)] mb-4">Predicciones Detalladas</h3>
|
||||||
columns={forecastColumns}
|
<Table
|
||||||
data={mockForecasts}
|
columns={forecastColumns}
|
||||||
rowKey="id"
|
data={transformedForecasts}
|
||||||
hover={true}
|
rowKey="id"
|
||||||
variant="default"
|
hover={true}
|
||||||
size="md"
|
variant="default"
|
||||||
/>
|
size="md"
|
||||||
</Card>
|
/>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!isLoading && !hasError && transformedForecasts.length === 0 && (
|
||||||
|
<Card className="p-6 text-center">
|
||||||
|
<div className="py-8">
|
||||||
|
<BarChart3 className="h-12 w-12 text-gray-400 mx-auto mb-4" />
|
||||||
|
<h3 className="text-lg font-medium text-gray-600 mb-2">No hay predicciones disponibles</h3>
|
||||||
|
<p className="text-gray-500">
|
||||||
|
No se encontraron predicciones para el período seleccionado.
|
||||||
|
Prueba ajustando los filtros o genera nuevas predicciones.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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 { Button, Card, Input, Select, Modal, Badge } from '../../../../components/ui';
|
||||||
import { PageHeader } from '../../../../components/layout';
|
import { PageHeader } from '../../../../components/layout';
|
||||||
import { useToast } from '../../../../hooks/ui/useToast';
|
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 {
|
interface BakeryConfig {
|
||||||
// General Info
|
// 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 BakeryConfigPage: React.FC = () => {
|
||||||
const { addToast } = useToast();
|
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 [activeTab, setActiveTab] = useState<'general' | 'location' | 'business' | 'hours' | 'pos'>('general');
|
||||||
const [isEditing, setIsEditing] = useState(false);
|
const [isEditing, setIsEditing] = useState(false);
|
||||||
const [isLoading, setIsLoading] = useState(false);
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
|
|
||||||
// POS Configuration State
|
// POS Configuration State
|
||||||
const [posConfigurations, setPosConfigurations] = useState<POSConfiguration[]>([]);
|
|
||||||
const [posLoading, setPosLoading] = useState(false);
|
|
||||||
const [showAddPosModal, setShowAddPosModal] = useState(false);
|
const [showAddPosModal, setShowAddPosModal] = useState(false);
|
||||||
const [showEditPosModal, setShowEditPosModal] = useState(false);
|
const [showEditPosModal, setShowEditPosModal] = useState(false);
|
||||||
const [selectedPosConfig, setSelectedPosConfig] = useState<POSConfiguration | null>(null);
|
const [selectedPosConfig, setSelectedPosConfig] = useState<POSConfiguration | null>(null);
|
||||||
@@ -67,20 +63,41 @@ const BakeryConfigPage: React.FC = () => {
|
|||||||
const [showCredentials, setShowCredentials] = useState<Record<string, boolean>>({});
|
const [showCredentials, setShowCredentials] = useState<Record<string, boolean>>({});
|
||||||
|
|
||||||
const [config, setConfig] = useState<BakeryConfig>({
|
const [config, setConfig] = useState<BakeryConfig>({
|
||||||
name: 'Panadería Artesanal San Miguel',
|
name: '',
|
||||||
description: 'Panadería tradicional con más de 30 años de experiencia',
|
description: '',
|
||||||
email: 'info@panaderiasanmiguel.com',
|
email: '',
|
||||||
phone: '+34 912 345 678',
|
phone: '',
|
||||||
website: 'https://panaderiasanmiguel.com',
|
website: '',
|
||||||
address: 'Calle Mayor 123',
|
address: '',
|
||||||
city: 'Madrid',
|
city: '',
|
||||||
postalCode: '28001',
|
postalCode: '',
|
||||||
country: 'España',
|
country: '',
|
||||||
taxId: 'B12345678',
|
taxId: '',
|
||||||
currency: 'EUR',
|
currency: 'EUR',
|
||||||
timezone: 'Europe/Madrid',
|
timezone: 'Europe/Madrid',
|
||||||
language: 'es'
|
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<BusinessHours>({
|
const [businessHours, setBusinessHours] = useState<BusinessHours>({
|
||||||
monday: { open: '07:00', close: '20:00', closed: false },
|
monday: { open: '07:00', close: '20:00', closed: false },
|
||||||
@@ -185,27 +202,10 @@ const BakeryConfigPage: React.FC = () => {
|
|||||||
{ value: 'en', label: 'English' }
|
{ value: 'en', label: 'English' }
|
||||||
];
|
];
|
||||||
|
|
||||||
// Load POS configurations when POS tab is selected
|
// Load POS configurations function for refetching after updates
|
||||||
useEffect(() => {
|
const loadPosConfigurations = () => {
|
||||||
if (activeTab === 'pos') {
|
// This will trigger a refetch of POS configurations
|
||||||
loadPosConfigurations();
|
posManager.refetch();
|
||||||
}
|
|
||||||
}, [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);
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const validateConfig = (): boolean => {
|
const validateConfig = (): boolean => {
|
||||||
@@ -234,13 +234,26 @@ const BakeryConfigPage: React.FC = () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const handleSaveConfig = async () => {
|
const handleSaveConfig = async () => {
|
||||||
if (!validateConfig()) return;
|
if (!validateConfig() || !tenantId) return;
|
||||||
|
|
||||||
setIsLoading(true);
|
setIsLoading(true);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Simulate API call
|
await updateTenantMutation.mutateAsync({
|
||||||
await new Promise(resolve => setTimeout(resolve, 1000));
|
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);
|
setIsEditing(false);
|
||||||
addToast('Configuración actualizada correctamente', 'success');
|
addToast('Configuración actualizada correctamente', 'success');
|
||||||
@@ -317,24 +330,23 @@ const BakeryConfigPage: React.FC = () => {
|
|||||||
|
|
||||||
if (selectedPosConfig) {
|
if (selectedPosConfig) {
|
||||||
// Update existing
|
// Update existing
|
||||||
const response = await posService.updatePOSConfig(selectedPosConfig.id, posFormData);
|
await posService.updatePOSConfiguration({
|
||||||
if (response.success) {
|
tenant_id: tenantId,
|
||||||
addToast('Configuración actualizada correctamente', 'success');
|
config_id: selectedPosConfig.id,
|
||||||
setShowEditPosModal(false);
|
...posFormData,
|
||||||
loadPosConfigurations();
|
});
|
||||||
} else {
|
addToast('Configuración actualizada correctamente', 'success');
|
||||||
addToast('Error al actualizar la configuración', 'error');
|
setShowEditPosModal(false);
|
||||||
}
|
loadPosConfigurations();
|
||||||
} else {
|
} else {
|
||||||
// Create new
|
// Create new
|
||||||
const response = await posService.createPOSConfig(posFormData);
|
await posService.createPOSConfiguration({
|
||||||
if (response.success) {
|
tenant_id: tenantId,
|
||||||
addToast('Configuración creada correctamente', 'success');
|
...posFormData,
|
||||||
setShowAddPosModal(false);
|
});
|
||||||
loadPosConfigurations();
|
addToast('Configuración creada correctamente', 'success');
|
||||||
} else {
|
setShowAddPosModal(false);
|
||||||
addToast('Error al crear la configuración', 'error');
|
loadPosConfigurations();
|
||||||
}
|
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
addToast('Error al guardar la configuración', 'error');
|
addToast('Error al guardar la configuración', 'error');
|
||||||
@@ -344,12 +356,15 @@ const BakeryConfigPage: React.FC = () => {
|
|||||||
const handleTestPosConnection = async (configId: string) => {
|
const handleTestPosConnection = async (configId: string) => {
|
||||||
try {
|
try {
|
||||||
setTestingConnection(configId);
|
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');
|
addToast('Conexión exitosa', 'success');
|
||||||
} else {
|
} else {
|
||||||
addToast(`Error en la conexión: ${response.data.message}`, 'error');
|
addToast(`Error en la conexión: ${response.message || 'Error desconocido'}`, 'error');
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
addToast('Error al probar la conexión', 'error');
|
addToast('Error al probar la conexión', 'error');
|
||||||
@@ -364,13 +379,12 @@ const BakeryConfigPage: React.FC = () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await posService.deletePOSConfig(configId);
|
await posService.deletePOSConfiguration({
|
||||||
if (response.success) {
|
tenant_id: tenantId,
|
||||||
addToast('Configuración eliminada correctamente', 'success');
|
config_id: configId,
|
||||||
loadPosConfigurations();
|
});
|
||||||
} else {
|
addToast('Configuración eliminada correctamente', 'success');
|
||||||
addToast('Error al eliminar la configuración', 'error');
|
loadPosConfigurations();
|
||||||
}
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
addToast('Error al eliminar la configuración', 'error');
|
addToast('Error al eliminar la configuración', 'error');
|
||||||
}
|
}
|
||||||
@@ -543,6 +557,37 @@ const BakeryConfigPage: React.FC = () => {
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
if (tenantLoading) {
|
||||||
|
return (
|
||||||
|
<div className="p-6 space-y-6">
|
||||||
|
<PageHeader
|
||||||
|
title="Configuración de Panadería"
|
||||||
|
description="Configurando datos básicos y preferencias de tu panadería"
|
||||||
|
/>
|
||||||
|
<div className="flex items-center justify-center h-64">
|
||||||
|
<Loader className="w-8 h-8 animate-spin" />
|
||||||
|
<span className="ml-2">Cargando configuración...</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (tenantError) {
|
||||||
|
return (
|
||||||
|
<div className="p-6 space-y-6">
|
||||||
|
<PageHeader
|
||||||
|
title="Configuración de Panadería"
|
||||||
|
description="Error al cargar la configuración"
|
||||||
|
/>
|
||||||
|
<Card className="p-6">
|
||||||
|
<div className="text-red-600">
|
||||||
|
Error al cargar la configuración: {tenantError.message}
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="p-6 space-y-6">
|
<div className="p-6 space-y-6">
|
||||||
<PageHeader
|
<PageHeader
|
||||||
@@ -829,11 +874,11 @@ const BakeryConfigPage: React.FC = () => {
|
|||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{posLoading ? (
|
{posData.isLoading ? (
|
||||||
<div className="flex items-center justify-center h-32">
|
<div className="flex items-center justify-center h-32">
|
||||||
<Loader className="w-8 h-8 animate-spin" />
|
<Loader className="w-8 h-8 animate-spin" />
|
||||||
</div>
|
</div>
|
||||||
) : posConfigurations.length === 0 ? (
|
) : posData.configurations.length === 0 ? (
|
||||||
<Card className="p-8 text-center">
|
<Card className="p-8 text-center">
|
||||||
<div className="w-16 h-16 bg-gray-100 rounded-full flex items-center justify-center mx-auto mb-4">
|
<div className="w-16 h-16 bg-gray-100 rounded-full flex items-center justify-center mx-auto mb-4">
|
||||||
<Zap className="w-8 h-8 text-gray-400" />
|
<Zap className="w-8 h-8 text-gray-400" />
|
||||||
@@ -848,20 +893,20 @@ const BakeryConfigPage: React.FC = () => {
|
|||||||
</Card>
|
</Card>
|
||||||
) : (
|
) : (
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||||
{posConfigurations.map(config => {
|
{posData.configurations.map(config => {
|
||||||
const provider = supportedProviders.find(p => p.id === config.provider);
|
const provider = posData.supportedSystems.find(p => p.id === config.pos_system);
|
||||||
return (
|
return (
|
||||||
<Card key={config.id} className="p-6">
|
<Card key={config.id} className="p-6">
|
||||||
<div className="flex items-start justify-between mb-4">
|
<div className="flex items-start justify-between mb-4">
|
||||||
<div className="flex items-center">
|
<div className="flex items-center">
|
||||||
<div className="text-2xl mr-3">{provider?.logo || '📊'}</div>
|
<div className="text-2xl mr-3">📊</div>
|
||||||
<div>
|
<div>
|
||||||
<h3 className="font-medium">{config.config_name}</h3>
|
<h3 className="font-medium">{config.provider_name}</h3>
|
||||||
<p className="text-sm text-gray-500">{provider?.name || config.provider}</p>
|
<p className="text-sm text-gray-500">{provider?.name || config.pos_system}</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center space-x-1">
|
<div className="flex items-center space-x-1">
|
||||||
{config.is_active ? (
|
{config.is_connected ? (
|
||||||
<Wifi className="w-4 h-4 text-green-500" />
|
<Wifi className="w-4 h-4 text-green-500" />
|
||||||
) : (
|
) : (
|
||||||
<WifiOff className="w-4 h-4 text-red-500" />
|
<WifiOff className="w-4 h-4 text-red-500" />
|
||||||
|
|||||||
@@ -2,8 +2,14 @@ import React, { useState } from 'react';
|
|||||||
import { Settings, Bell, Mail, MessageSquare, Smartphone, Save, RotateCcw } from 'lucide-react';
|
import { Settings, Bell, Mail, MessageSquare, Smartphone, Save, RotateCcw } from 'lucide-react';
|
||||||
import { Button, Card } from '../../../../components/ui';
|
import { Button, Card } from '../../../../components/ui';
|
||||||
import { PageHeader } from '../../../../components/layout';
|
import { PageHeader } from '../../../../components/layout';
|
||||||
|
import { useAuthProfile, useUpdateProfile } from '../../../../api/hooks/auth';
|
||||||
|
import { useToast } from '../../../../hooks/ui/useToast';
|
||||||
|
|
||||||
const PreferencesPage: React.FC = () => {
|
const PreferencesPage: React.FC = () => {
|
||||||
|
const { addToast } = useToast();
|
||||||
|
const { data: profile, isLoading: profileLoading } = useAuthProfile();
|
||||||
|
const updateProfileMutation = useUpdateProfile();
|
||||||
|
|
||||||
const [preferences, setPreferences] = useState({
|
const [preferences, setPreferences] = useState({
|
||||||
notifications: {
|
notifications: {
|
||||||
inventory: {
|
inventory: {
|
||||||
@@ -50,12 +56,31 @@ const PreferencesPage: React.FC = () => {
|
|||||||
vibrationEnabled: true
|
vibrationEnabled: true
|
||||||
},
|
},
|
||||||
channels: {
|
channels: {
|
||||||
email: 'panaderia@example.com',
|
email: profile?.email || '',
|
||||||
phone: '+34 600 123 456',
|
phone: profile?.phone || '',
|
||||||
slack: false,
|
slack: false,
|
||||||
webhook: ''
|
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);
|
const [hasChanges, setHasChanges] = useState(false);
|
||||||
|
|
||||||
@@ -149,14 +174,49 @@ const PreferencesPage: React.FC = () => {
|
|||||||
setHasChanges(true);
|
setHasChanges(true);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleSave = () => {
|
const handleSave = async () => {
|
||||||
// Handle save logic
|
try {
|
||||||
console.log('Saving preferences:', preferences);
|
// Save notification preferences and contact info
|
||||||
setHasChanges(false);
|
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 = () => {
|
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);
|
setHasChanges(false);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { Button, Card, Avatar, Input, Select } from '../../../../components/ui';
|
|||||||
import { PageHeader } from '../../../../components/layout';
|
import { PageHeader } from '../../../../components/layout';
|
||||||
import { useAuthUser } from '../../../../stores/auth.store';
|
import { useAuthUser } from '../../../../stores/auth.store';
|
||||||
import { useToast } from '../../../../hooks/ui/useToast';
|
import { useToast } from '../../../../hooks/ui/useToast';
|
||||||
|
import { useAuthProfile, useUpdateProfile, useChangePassword } from '../../../../api/hooks/auth';
|
||||||
|
|
||||||
interface ProfileFormData {
|
interface ProfileFormData {
|
||||||
first_name: string;
|
first_name: string;
|
||||||
@@ -22,20 +23,38 @@ interface PasswordData {
|
|||||||
|
|
||||||
const ProfilePage: React.FC = () => {
|
const ProfilePage: React.FC = () => {
|
||||||
const user = useAuthUser();
|
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 [isEditing, setIsEditing] = useState(false);
|
||||||
const [isLoading, setIsLoading] = useState(false);
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
const [showPasswordForm, setShowPasswordForm] = useState(false);
|
const [showPasswordForm, setShowPasswordForm] = useState(false);
|
||||||
|
|
||||||
const [profileData, setProfileData] = useState<ProfileFormData>({
|
const [profileData, setProfileData] = useState<ProfileFormData>({
|
||||||
first_name: 'María',
|
first_name: '',
|
||||||
last_name: 'González Pérez',
|
last_name: '',
|
||||||
email: 'admin@bakery.com',
|
email: '',
|
||||||
phone: '+34 612 345 678',
|
phone: '',
|
||||||
language: 'es',
|
language: 'es',
|
||||||
timezone: 'Europe/Madrid'
|
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<PasswordData>({
|
const [passwordData, setPasswordData] = useState<PasswordData>({
|
||||||
currentPassword: '',
|
currentPassword: '',
|
||||||
@@ -105,48 +124,34 @@ const ProfilePage: React.FC = () => {
|
|||||||
setIsLoading(true);
|
setIsLoading(true);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Simulate API call
|
await updateProfileMutation.mutateAsync(profileData);
|
||||||
await new Promise(resolve => setTimeout(resolve, 1000));
|
|
||||||
|
|
||||||
setIsEditing(false);
|
setIsEditing(false);
|
||||||
showToast({
|
addToast('Perfil actualizado correctamente', 'success');
|
||||||
type: 'success',
|
|
||||||
title: 'Perfil actualizado',
|
|
||||||
message: 'Tu información ha sido guardada correctamente'
|
|
||||||
});
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
showToast({
|
addToast('No se pudo actualizar tu perfil', 'error');
|
||||||
type: 'error',
|
|
||||||
title: 'Error',
|
|
||||||
message: 'No se pudo actualizar tu perfil'
|
|
||||||
});
|
|
||||||
} finally {
|
} finally {
|
||||||
setIsLoading(false);
|
setIsLoading(false);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleChangePassword = async () => {
|
const handleChangePasswordSubmit = async () => {
|
||||||
if (!validatePassword()) return;
|
if (!validatePassword()) return;
|
||||||
|
|
||||||
setIsLoading(true);
|
setIsLoading(true);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Simulate API call
|
await changePasswordMutation.mutateAsync({
|
||||||
await new Promise(resolve => setTimeout(resolve, 1000));
|
current_password: passwordData.currentPassword,
|
||||||
|
new_password: passwordData.newPassword,
|
||||||
|
confirm_password: passwordData.confirmPassword
|
||||||
|
});
|
||||||
|
|
||||||
setShowPasswordForm(false);
|
setShowPasswordForm(false);
|
||||||
setPasswordData({ currentPassword: '', newPassword: '', confirmPassword: '' });
|
setPasswordData({ currentPassword: '', newPassword: '', confirmPassword: '' });
|
||||||
showToast({
|
addToast('Contraseña actualizada correctamente', 'success');
|
||||||
type: 'success',
|
|
||||||
title: 'Contraseña actualizada',
|
|
||||||
message: 'Tu contraseña ha sido cambiada correctamente'
|
|
||||||
});
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
showToast({
|
addToast('No se pudo cambiar tu contraseña', 'error');
|
||||||
type: 'error',
|
|
||||||
title: 'Error',
|
|
||||||
message: 'No se pudo cambiar tu contraseña'
|
|
||||||
});
|
|
||||||
} finally {
|
} finally {
|
||||||
setIsLoading(false);
|
setIsLoading(false);
|
||||||
}
|
}
|
||||||
@@ -182,7 +187,7 @@ const ProfilePage: React.FC = () => {
|
|||||||
<div className="flex items-center gap-6">
|
<div className="flex items-center gap-6">
|
||||||
<div className="relative">
|
<div className="relative">
|
||||||
<Avatar
|
<Avatar
|
||||||
src="https://images.unsplash.com/photo-1494790108755-2616b612b372?w=150&h=150&fit=crop&crop=face"
|
src={profile?.avatar_url}
|
||||||
name={`${profileData.first_name} ${profileData.last_name}`}
|
name={`${profileData.first_name} ${profileData.last_name}`}
|
||||||
size="xl"
|
size="xl"
|
||||||
className="w-20 h-20"
|
className="w-20 h-20"
|
||||||
@@ -362,7 +367,7 @@ const ProfilePage: React.FC = () => {
|
|||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
variant="primary"
|
variant="primary"
|
||||||
onClick={handleChangePassword}
|
onClick={handleChangePasswordSubmit}
|
||||||
isLoading={isLoading}
|
isLoading={isLoading}
|
||||||
loadingText="Cambiando..."
|
loadingText="Cambiando..."
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -37,8 +37,7 @@ import {
|
|||||||
subscriptionService,
|
subscriptionService,
|
||||||
type UsageSummary,
|
type UsageSummary,
|
||||||
type AvailablePlans
|
type AvailablePlans
|
||||||
} from '../../../../api/services';
|
} from '../../../../api';
|
||||||
import { isMockMode, getMockSubscription } from '../../../../config/mock.config';
|
|
||||||
|
|
||||||
interface PlanComparisonProps {
|
interface PlanComparisonProps {
|
||||||
plans: AvailablePlans['plans'];
|
plans: AvailablePlans['plans'];
|
||||||
@@ -249,7 +248,7 @@ const SubscriptionPage: React.FC = () => {
|
|||||||
const [upgrading, setUpgrading] = useState(false);
|
const [upgrading, setUpgrading] = useState(false);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (currentTenant?.id || user?.tenant_id || isMockMode()) {
|
if (currentTenant?.id || user?.tenant_id) {
|
||||||
loadSubscriptionData();
|
loadSubscriptionData();
|
||||||
}
|
}
|
||||||
}, [currentTenant, user?.tenant_id]);
|
}, [currentTenant, user?.tenant_id]);
|
||||||
@@ -257,15 +256,10 @@ const SubscriptionPage: React.FC = () => {
|
|||||||
const loadSubscriptionData = async () => {
|
const loadSubscriptionData = async () => {
|
||||||
let tenantId = currentTenant?.id || user?.tenant_id;
|
let tenantId = currentTenant?.id || user?.tenant_id;
|
||||||
|
|
||||||
// In mock mode, use the mock tenant ID if no real tenant is available
|
if (!tenantId) {
|
||||||
if (isMockMode() && !tenantId) {
|
toast.error('No se encontró información del tenant');
|
||||||
tenantId = getMockSubscription().tenant_id;
|
return;
|
||||||
console.log('🧪 Mock mode: Using mock tenant ID:', tenantId);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log('📊 Loading subscription data for tenant:', tenantId, '| Mock mode:', isMockMode());
|
|
||||||
|
|
||||||
if (!tenantId) return;
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
@@ -292,12 +286,10 @@ const SubscriptionPage: React.FC = () => {
|
|||||||
const handleUpgradeConfirm = async () => {
|
const handleUpgradeConfirm = async () => {
|
||||||
let tenantId = currentTenant?.id || user?.tenant_id;
|
let tenantId = currentTenant?.id || user?.tenant_id;
|
||||||
|
|
||||||
// In mock mode, use the mock tenant ID if no real tenant is available
|
if (!tenantId || !selectedPlan) {
|
||||||
if (isMockMode() && !tenantId) {
|
toast.error('Información de tenant no disponible');
|
||||||
tenantId = getMockSubscription().tenant_id;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!tenantId || !selectedPlan) return;
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
setUpgrading(true);
|
setUpgrading(true);
|
||||||
|
|||||||
@@ -38,7 +38,6 @@ import {
|
|||||||
type UsageSummary,
|
type UsageSummary,
|
||||||
type AvailablePlans
|
type AvailablePlans
|
||||||
} from '../../../../api/services';
|
} from '../../../../api/services';
|
||||||
import { isMockMode, getMockSubscription } from '../../../../config/mock.config';
|
|
||||||
|
|
||||||
interface PlanComparisonProps {
|
interface PlanComparisonProps {
|
||||||
plans: AvailablePlans['plans'];
|
plans: AvailablePlans['plans'];
|
||||||
@@ -297,7 +296,7 @@ const SubscriptionPage: React.FC = () => {
|
|||||||
const [upgrading, setUpgrading] = useState(false);
|
const [upgrading, setUpgrading] = useState(false);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (currentTenant?.id || tenant_id || isMockMode()) {
|
if (currentTenant?.id || tenant_id) {
|
||||||
loadSubscriptionData();
|
loadSubscriptionData();
|
||||||
}
|
}
|
||||||
}, [currentTenant, tenant_id]);
|
}, [currentTenant, tenant_id]);
|
||||||
@@ -305,13 +304,7 @@ const SubscriptionPage: React.FC = () => {
|
|||||||
const loadSubscriptionData = async () => {
|
const loadSubscriptionData = async () => {
|
||||||
let tenantId = currentTenant?.id || tenant_id;
|
let tenantId = currentTenant?.id || tenant_id;
|
||||||
|
|
||||||
// In mock mode, use the mock tenant ID if no real tenant is available
|
console.log('📊 Loading subscription data for tenant:', tenantId);
|
||||||
if (isMockMode() && !tenantId) {
|
|
||||||
tenantId = getMockSubscription().tenant_id;
|
|
||||||
console.log('🧪 Mock mode: Using mock tenant ID:', tenantId);
|
|
||||||
}
|
|
||||||
|
|
||||||
console.log('📊 Loading subscription data for tenant:', tenantId, '| Mock mode:', isMockMode());
|
|
||||||
|
|
||||||
if (!tenantId) return;
|
if (!tenantId) return;
|
||||||
|
|
||||||
@@ -340,11 +333,6 @@ const SubscriptionPage: React.FC = () => {
|
|||||||
const handleUpgradeConfirm = async () => {
|
const handleUpgradeConfirm = async () => {
|
||||||
let tenantId = currentTenant?.id || tenant_id;
|
let tenantId = currentTenant?.id || tenant_id;
|
||||||
|
|
||||||
// In mock mode, use the mock tenant ID if no real tenant is available
|
|
||||||
if (isMockMode() && !tenantId) {
|
|
||||||
tenantId = getMockSubscription().tenant_id;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!tenantId || !selectedPlan) return;
|
if (!tenantId || !selectedPlan) return;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -2,131 +2,31 @@ import React, { useState } from 'react';
|
|||||||
import { Users, Plus, Search, Mail, Phone, Shield, Edit, Trash2, UserCheck, UserX } from 'lucide-react';
|
import { Users, Plus, Search, Mail, Phone, Shield, Edit, Trash2, UserCheck, UserX } from 'lucide-react';
|
||||||
import { Button, Card, Badge, Input } from '../../../../components/ui';
|
import { Button, Card, Badge, Input } from '../../../../components/ui';
|
||||||
import { PageHeader } from '../../../../components/layout';
|
import { PageHeader } from '../../../../components/layout';
|
||||||
|
import { useTeamMembers } from '../../../../api/hooks/tenant';
|
||||||
|
import { useAllUsers, useUpdateUser } from '../../../../api/hooks/user';
|
||||||
|
import { useAuthUser } from '../../../../stores/auth.store';
|
||||||
|
import { useToast } from '../../../../hooks/ui/useToast';
|
||||||
|
|
||||||
const TeamPage: React.FC = () => {
|
const TeamPage: React.FC = () => {
|
||||||
|
const { addToast } = useToast();
|
||||||
|
const user = useAuthUser();
|
||||||
|
const tenantId = user?.tenant_id || '';
|
||||||
|
|
||||||
|
const { data: teamMembers = [], isLoading, error } = useTeamMembers(tenantId, true, { enabled: !!tenantId });
|
||||||
|
const { data: allUsers = [] } = useAllUsers();
|
||||||
|
const updateUserMutation = useUpdateUser();
|
||||||
|
|
||||||
const [searchTerm, setSearchTerm] = useState('');
|
const [searchTerm, setSearchTerm] = useState('');
|
||||||
const [selectedRole, setSelectedRole] = useState('all');
|
const [selectedRole, setSelectedRole] = useState('all');
|
||||||
const [showForm, setShowForm] = useState(false);
|
const [showForm, setShowForm] = useState(false);
|
||||||
|
|
||||||
const teamMembers = [
|
|
||||||
{
|
|
||||||
id: '1',
|
|
||||||
name: 'María González',
|
|
||||||
email: 'maria.gonzalez@panaderia.com',
|
|
||||||
phone: '+34 600 123 456',
|
|
||||||
role: 'manager',
|
|
||||||
department: 'Administración',
|
|
||||||
status: 'active',
|
|
||||||
joinDate: '2022-03-15',
|
|
||||||
lastLogin: '2024-01-26 09:30:00',
|
|
||||||
permissions: ['inventory', 'sales', 'reports', 'team'],
|
|
||||||
avatar: '/avatars/maria.jpg',
|
|
||||||
schedule: {
|
|
||||||
monday: '07:00-15:00',
|
|
||||||
tuesday: '07:00-15:00',
|
|
||||||
wednesday: '07:00-15:00',
|
|
||||||
thursday: '07:00-15:00',
|
|
||||||
friday: '07:00-15:00',
|
|
||||||
saturday: 'Libre',
|
|
||||||
sunday: 'Libre'
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: '2',
|
|
||||||
name: 'Carlos Rodríguez',
|
|
||||||
email: 'carlos.rodriguez@panaderia.com',
|
|
||||||
phone: '+34 600 234 567',
|
|
||||||
role: 'baker',
|
|
||||||
department: 'Producción',
|
|
||||||
status: 'active',
|
|
||||||
joinDate: '2021-09-20',
|
|
||||||
lastLogin: '2024-01-26 08:45:00',
|
|
||||||
permissions: ['production', 'inventory'],
|
|
||||||
avatar: '/avatars/carlos.jpg',
|
|
||||||
schedule: {
|
|
||||||
monday: '05:00-13:00',
|
|
||||||
tuesday: '05:00-13:00',
|
|
||||||
wednesday: '05:00-13:00',
|
|
||||||
thursday: '05:00-13:00',
|
|
||||||
friday: '05:00-13:00',
|
|
||||||
saturday: '05:00-11:00',
|
|
||||||
sunday: 'Libre'
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: '3',
|
|
||||||
name: 'Ana Martínez',
|
|
||||||
email: 'ana.martinez@panaderia.com',
|
|
||||||
phone: '+34 600 345 678',
|
|
||||||
role: 'cashier',
|
|
||||||
department: 'Ventas',
|
|
||||||
status: 'active',
|
|
||||||
joinDate: '2023-01-10',
|
|
||||||
lastLogin: '2024-01-26 10:15:00',
|
|
||||||
permissions: ['sales', 'pos'],
|
|
||||||
avatar: '/avatars/ana.jpg',
|
|
||||||
schedule: {
|
|
||||||
monday: '08:00-16:00',
|
|
||||||
tuesday: '08:00-16:00',
|
|
||||||
wednesday: 'Libre',
|
|
||||||
thursday: '08:00-16:00',
|
|
||||||
friday: '08:00-16:00',
|
|
||||||
saturday: '09:00-14:00',
|
|
||||||
sunday: '09:00-14:00'
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: '4',
|
|
||||||
name: 'Luis Fernández',
|
|
||||||
email: 'luis.fernandez@panaderia.com',
|
|
||||||
phone: '+34 600 456 789',
|
|
||||||
role: 'baker',
|
|
||||||
department: 'Producción',
|
|
||||||
status: 'inactive',
|
|
||||||
joinDate: '2020-11-05',
|
|
||||||
lastLogin: '2024-01-20 16:30:00',
|
|
||||||
permissions: ['production'],
|
|
||||||
avatar: '/avatars/luis.jpg',
|
|
||||||
schedule: {
|
|
||||||
monday: '13:00-21:00',
|
|
||||||
tuesday: '13:00-21:00',
|
|
||||||
wednesday: '13:00-21:00',
|
|
||||||
thursday: 'Libre',
|
|
||||||
friday: '13:00-21:00',
|
|
||||||
saturday: 'Libre',
|
|
||||||
sunday: '13:00-21:00'
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: '5',
|
|
||||||
name: 'Isabel Torres',
|
|
||||||
email: 'isabel.torres@panaderia.com',
|
|
||||||
phone: '+34 600 567 890',
|
|
||||||
role: 'assistant',
|
|
||||||
department: 'Ventas',
|
|
||||||
status: 'active',
|
|
||||||
joinDate: '2023-06-01',
|
|
||||||
lastLogin: '2024-01-25 18:20:00',
|
|
||||||
permissions: ['sales'],
|
|
||||||
avatar: '/avatars/isabel.jpg',
|
|
||||||
schedule: {
|
|
||||||
monday: 'Libre',
|
|
||||||
tuesday: '16:00-20:00',
|
|
||||||
wednesday: '16:00-20:00',
|
|
||||||
thursday: '16:00-20:00',
|
|
||||||
friday: '16:00-20:00',
|
|
||||||
saturday: '14:00-20:00',
|
|
||||||
sunday: '14:00-20:00'
|
|
||||||
}
|
|
||||||
}
|
|
||||||
];
|
|
||||||
|
|
||||||
const roles = [
|
const roles = [
|
||||||
{ value: 'all', label: 'Todos los Roles', count: teamMembers.length },
|
{ value: 'all', label: 'Todos los Roles', count: teamMembers.length },
|
||||||
|
{ value: 'owner', label: 'Propietario', count: teamMembers.filter(m => m.role === 'owner').length },
|
||||||
|
{ value: 'admin', label: 'Administrador', count: teamMembers.filter(m => m.role === 'admin').length },
|
||||||
{ value: 'manager', label: 'Gerente', count: teamMembers.filter(m => m.role === 'manager').length },
|
{ value: 'manager', label: 'Gerente', count: teamMembers.filter(m => m.role === 'manager').length },
|
||||||
{ value: 'baker', label: 'Panadero', count: teamMembers.filter(m => m.role === 'baker').length },
|
{ value: 'employee', label: 'Empleado', count: teamMembers.filter(m => m.role === 'employee').length }
|
||||||
{ value: 'cashier', label: 'Cajero', count: teamMembers.filter(m => m.role === 'cashier').length },
|
|
||||||
{ value: 'assistant', label: 'Asistente', count: teamMembers.filter(m => m.role === 'assistant').length }
|
|
||||||
];
|
];
|
||||||
|
|
||||||
const teamStats = {
|
const teamStats = {
|
||||||
@@ -165,10 +65,28 @@ const TeamPage: React.FC = () => {
|
|||||||
|
|
||||||
const filteredMembers = teamMembers.filter(member => {
|
const filteredMembers = teamMembers.filter(member => {
|
||||||
const matchesRole = selectedRole === 'all' || member.role === selectedRole;
|
const matchesRole = selectedRole === 'all' || member.role === selectedRole;
|
||||||
const matchesSearch = member.name.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
const matchesSearch = member.user.first_name.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
||||||
member.email.toLowerCase().includes(searchTerm.toLowerCase());
|
member.user.last_name.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
||||||
|
member.user.email.toLowerCase().includes(searchTerm.toLowerCase());
|
||||||
return matchesRole && matchesSearch;
|
return matchesRole && matchesSearch;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
if (isLoading) {
|
||||||
|
return (
|
||||||
|
<div className="p-6 space-y-6">
|
||||||
|
<PageHeader
|
||||||
|
title="Gestión de Equipo"
|
||||||
|
description="Administra los miembros del equipo, roles y permisos"
|
||||||
|
/>
|
||||||
|
<div className="flex items-center justify-center min-h-[400px]">
|
||||||
|
<div className="text-center">
|
||||||
|
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-blue-600 mx-auto mb-4"></div>
|
||||||
|
<p>Cargando miembros del equipo...</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
const formatLastLogin = (timestamp: string) => {
|
const formatLastLogin = (timestamp: string) => {
|
||||||
const date = new Date(timestamp);
|
const date = new Date(timestamp);
|
||||||
@@ -293,7 +211,7 @@ const TeamPage: React.FC = () => {
|
|||||||
|
|
||||||
<div className="flex-1">
|
<div className="flex-1">
|
||||||
<div className="flex items-center space-x-3 mb-2">
|
<div className="flex items-center space-x-3 mb-2">
|
||||||
<h3 className="text-lg font-semibold text-[var(--text-primary)]">{member.name}</h3>
|
<h3 className="text-lg font-semibold text-[var(--text-primary)]">{member.user.first_name} {member.user.last_name}</h3>
|
||||||
<Badge variant={getStatusColor(member.status)}>
|
<Badge variant={getStatusColor(member.status)}>
|
||||||
{member.status === 'active' ? 'Activo' : 'Inactivo'}
|
{member.status === 'active' ? 'Activo' : 'Inactivo'}
|
||||||
</Badge>
|
</Badge>
|
||||||
@@ -302,11 +220,11 @@ const TeamPage: React.FC = () => {
|
|||||||
<div className="space-y-1 mb-3">
|
<div className="space-y-1 mb-3">
|
||||||
<div className="flex items-center text-sm text-[var(--text-secondary)]">
|
<div className="flex items-center text-sm text-[var(--text-secondary)]">
|
||||||
<Mail className="w-4 h-4 mr-2" />
|
<Mail className="w-4 h-4 mr-2" />
|
||||||
{member.email}
|
{member.user.email}
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center text-sm text-[var(--text-secondary)]">
|
<div className="flex items-center text-sm text-[var(--text-secondary)]">
|
||||||
<Phone className="w-4 h-4 mr-2" />
|
<Phone className="w-4 h-4 mr-2" />
|
||||||
{member.phone}
|
{member.user.phone || 'No disponible'}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -320,35 +238,24 @@ const TeamPage: React.FC = () => {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="text-sm text-[var(--text-tertiary)] mb-3">
|
<div className="text-sm text-[var(--text-tertiary)] mb-3">
|
||||||
<p>Se unió: {new Date(member.joinDate).toLocaleDateString('es-ES')}</p>
|
<p>Se unió: {new Date(member.joined_at).toLocaleDateString('es-ES')}</p>
|
||||||
<p>Última conexión: {formatLastLogin(member.lastLogin)}</p>
|
<p>Estado: {member.is_active ? 'Activo' : 'Inactivo'}</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Permissions */}
|
{/* Permissions */}
|
||||||
<div className="mb-3">
|
<div className="mb-3">
|
||||||
<p className="text-xs font-medium text-[var(--text-secondary)] mb-2">Permisos:</p>
|
<p className="text-xs font-medium text-[var(--text-secondary)] mb-2">Permisos:</p>
|
||||||
<div className="flex flex-wrap gap-1">
|
<div className="flex flex-wrap gap-1">
|
||||||
{member.permissions.map((permission, index) => (
|
<span className="px-2 py-1 bg-[var(--color-info)]/10 text-[var(--color-info)] text-xs rounded-full">
|
||||||
<span
|
{getRoleLabel(member.role)}
|
||||||
key={index}
|
</span>
|
||||||
className="px-2 py-1 bg-[var(--color-info)]/10 text-[var(--color-info)] text-xs rounded-full"
|
|
||||||
>
|
|
||||||
{permission}
|
|
||||||
</span>
|
|
||||||
))}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Schedule Preview */}
|
{/* Member Info */}
|
||||||
<div className="text-xs text-[var(--text-tertiary)]">
|
<div className="text-xs text-[var(--text-tertiary)]">
|
||||||
<p className="font-medium mb-1">Horario esta semana:</p>
|
<p className="font-medium mb-1">Información adicional:</p>
|
||||||
<div className="grid grid-cols-2 gap-1">
|
<p>ID: {member.id}</p>
|
||||||
{Object.entries(member.schedule).slice(0, 4).map(([day, hours]) => (
|
|
||||||
<span key={day}>
|
|
||||||
{day.charAt(0).toUpperCase()}: {hours}
|
|
||||||
</span>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
Reference in New Issue
Block a user