From 23c5f50111511a38fac5c0b1befd1ae18acf3946 Mon Sep 17 00:00:00 2001 From: Urtzi Alfaro Date: Sat, 16 Aug 2025 15:00:36 +0200 Subject: [PATCH] Add POS service --- docker-compose.yml | 59 ++ frontend/src/api/hooks/usePOS.ts | 337 ++++++++++ frontend/src/api/services/pos.service.ts | 392 ++++++++++++ .../components/pos/POSConfigurationCard.tsx | 356 +++++++++++ .../components/pos/POSConfigurationForm.tsx | 595 ++++++++++++++++++ .../src/components/pos/POSManagementPage.tsx | 394 ++++++++++++ frontend/src/components/pos/POSSyncStatus.tsx | 341 ++++++++++ frontend/src/components/pos/index.ts | 4 + services/pos/Dockerfile | 32 + services/pos/README.md | 138 ++++ services/pos/app/__init__.py | 1 + services/pos/app/api/__init__.py | 1 + services/pos/app/api/pos_config.py | 192 ++++++ services/pos/app/api/sync.py | 273 ++++++++ services/pos/app/api/webhooks.py | 179 ++++++ services/pos/app/core/__init__.py | 1 + services/pos/app/core/config.py | 179 ++++++ services/pos/app/core/database.py | 85 +++ services/pos/app/integrations/__init__.py | 1 + .../pos/app/integrations/base_pos_client.py | 365 +++++++++++ .../pos/app/integrations/square_client.py | 463 ++++++++++++++ services/pos/app/main.py | 140 +++++ services/pos/app/models/__init__.py | 16 + services/pos/app/models/pos_config.py | 83 +++ services/pos/app/models/pos_sync.py | 126 ++++ services/pos/app/models/pos_transaction.py | 174 +++++ services/pos/app/models/pos_webhook.py | 109 ++++ services/pos/app/services/__init__.py | 1 + .../app/services/pos_integration_service.py | 473 ++++++++++++++ services/pos/migrations/alembic.ini | 45 ++ services/pos/migrations/env.py | 97 +++ services/pos/migrations/script.py.mako | 24 + .../versions/001_initial_pos_tables.py | 394 ++++++++++++ services/pos/requirements.txt | 16 + 34 files changed, 6086 insertions(+) create mode 100644 frontend/src/api/hooks/usePOS.ts create mode 100644 frontend/src/api/services/pos.service.ts create mode 100644 frontend/src/components/pos/POSConfigurationCard.tsx create mode 100644 frontend/src/components/pos/POSConfigurationForm.tsx create mode 100644 frontend/src/components/pos/POSManagementPage.tsx create mode 100644 frontend/src/components/pos/POSSyncStatus.tsx create mode 100644 frontend/src/components/pos/index.ts create mode 100644 services/pos/Dockerfile create mode 100644 services/pos/README.md create mode 100644 services/pos/app/__init__.py create mode 100644 services/pos/app/api/__init__.py create mode 100644 services/pos/app/api/pos_config.py create mode 100644 services/pos/app/api/sync.py create mode 100644 services/pos/app/api/webhooks.py create mode 100644 services/pos/app/core/__init__.py create mode 100644 services/pos/app/core/config.py create mode 100644 services/pos/app/core/database.py create mode 100644 services/pos/app/integrations/__init__.py create mode 100644 services/pos/app/integrations/base_pos_client.py create mode 100644 services/pos/app/integrations/square_client.py create mode 100644 services/pos/app/main.py create mode 100644 services/pos/app/models/__init__.py create mode 100644 services/pos/app/models/pos_config.py create mode 100644 services/pos/app/models/pos_sync.py create mode 100644 services/pos/app/models/pos_transaction.py create mode 100644 services/pos/app/models/pos_webhook.py create mode 100644 services/pos/app/services/__init__.py create mode 100644 services/pos/app/services/pos_integration_service.py create mode 100644 services/pos/migrations/alembic.ini create mode 100644 services/pos/migrations/env.py create mode 100644 services/pos/migrations/script.py.mako create mode 100644 services/pos/migrations/versions/001_initial_pos_tables.py create mode 100644 services/pos/requirements.txt diff --git a/docker-compose.yml b/docker-compose.yml index 9fa3d26a..8f851a67 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -25,6 +25,7 @@ volumes: inventory_db_data: recipes_db_data: suppliers_db_data: + pos_db_data: redis_data: rabbitmq_data: prometheus_data: @@ -305,6 +306,27 @@ services: timeout: 5s retries: 5 + pos-db: + image: postgres:15-alpine + container_name: bakery-pos-db + restart: unless-stopped + environment: + - POSTGRES_DB=${POS_DB_NAME} + - POSTGRES_USER=${POS_DB_USER} + - POSTGRES_PASSWORD=${POS_DB_PASSWORD} + - POSTGRES_INITDB_ARGS=${POSTGRES_INITDB_ARGS} + - PGDATA=/var/lib/postgresql/data/pgdata + volumes: + - pos_db_data:/var/lib/postgresql/data + networks: + bakery-network: + ipv4_address: 172.20.0.31 + healthcheck: + test: ["CMD-SHELL", "pg_isready -U ${POS_DB_USER} -d ${POS_DB_NAME}"] + interval: 10s + timeout: 5s + retries: 5 + # ================================================================ # LOCATION SERVICES (NEW SECTION) @@ -774,6 +796,43 @@ services: timeout: 10s retries: 3 + pos-service: + build: + context: . + dockerfile: ./services/pos/Dockerfile + args: + - ENVIRONMENT=${ENVIRONMENT} + - BUILD_DATE=${BUILD_DATE} + image: bakery/pos-service:${IMAGE_TAG} + container_name: bakery-pos-service + restart: unless-stopped + env_file: .env + ports: + - "${POS_SERVICE_PORT}:8000" + depends_on: + pos-db: + condition: service_healthy + redis: + condition: service_healthy + rabbitmq: + condition: service_healthy + auth-service: + condition: service_healthy + sales-service: + condition: service_healthy + networks: + bakery-network: + ipv4_address: 172.20.0.112 + volumes: + - log_storage:/app/logs + - ./services/pos:/app + - ./shared:/app/shared + healthcheck: + test: ["CMD", "curl", "-f", "http://localhost:8000/health"] + interval: 30s + timeout: 10s + retries: 3 + # ================================================================ # MONITORING - SIMPLE APPROACH # ================================================================ diff --git a/frontend/src/api/hooks/usePOS.ts b/frontend/src/api/hooks/usePOS.ts new file mode 100644 index 00000000..044d968a --- /dev/null +++ b/frontend/src/api/hooks/usePOS.ts @@ -0,0 +1,337 @@ +// frontend/src/api/hooks/usePOS.ts +/** + * React hooks for POS Integration functionality + */ + +import { useState, useEffect } from 'react'; +import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; +import { + posService, + POSConfiguration, + CreatePOSConfigurationRequest, + UpdatePOSConfigurationRequest, + POSTransaction, + POSSyncLog, + POSAnalytics, + SyncRequest +} from '../services/pos.service'; +import { useTenantId } from './useTenant'; + +// ============================================================================ +// CONFIGURATION HOOKS +// ============================================================================ + +export const usePOSConfigurations = (params?: { + pos_system?: string; + is_active?: boolean; + limit?: number; + offset?: number; +}) => { + const tenantId = useTenantId(); + + return useQuery({ + queryKey: ['pos-configurations', tenantId, params], + queryFn: () => posService.getConfigurations(tenantId, params), + enabled: !!tenantId, + staleTime: 5 * 60 * 1000, // 5 minutes + }); +}; + +export const usePOSConfiguration = (configId?: string) => { + const tenantId = useTenantId(); + + return useQuery({ + queryKey: ['pos-configuration', tenantId, configId], + queryFn: () => posService.getConfiguration(tenantId, configId!), + enabled: !!tenantId && !!configId, + staleTime: 5 * 60 * 1000, + }); +}; + +export const useCreatePOSConfiguration = () => { + const queryClient = useQueryClient(); + const tenantId = useTenantId(); + + return useMutation({ + mutationFn: (data: CreatePOSConfigurationRequest) => + posService.createConfiguration(tenantId, data), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['pos-configurations', tenantId] }); + }, + }); +}; + +export const useUpdatePOSConfiguration = () => { + const queryClient = useQueryClient(); + const tenantId = useTenantId(); + + return useMutation({ + mutationFn: ({ configId, data }: { configId: string; data: UpdatePOSConfigurationRequest }) => + posService.updateConfiguration(tenantId, configId, data), + onSuccess: (_, { configId }) => { + queryClient.invalidateQueries({ queryKey: ['pos-configurations', tenantId] }); + queryClient.invalidateQueries({ queryKey: ['pos-configuration', tenantId, configId] }); + }, + }); +}; + +export const useDeletePOSConfiguration = () => { + const queryClient = useQueryClient(); + const tenantId = useTenantId(); + + return useMutation({ + mutationFn: (configId: string) => + posService.deleteConfiguration(tenantId, configId), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['pos-configurations', tenantId] }); + }, + }); +}; + +export const useTestPOSConnection = () => { + const tenantId = useTenantId(); + + return useMutation({ + mutationFn: (configId: string) => + posService.testConnection(tenantId, configId), + }); +}; + +// ============================================================================ +// SYNCHRONIZATION HOOKS +// ============================================================================ + +export const useTriggerPOSSync = () => { + const queryClient = useQueryClient(); + const tenantId = useTenantId(); + + return useMutation({ + mutationFn: ({ configId, syncRequest }: { configId: string; syncRequest: SyncRequest }) => + posService.triggerSync(tenantId, configId, syncRequest), + onSuccess: (_, { configId }) => { + queryClient.invalidateQueries({ queryKey: ['pos-sync-status', tenantId, configId] }); + queryClient.invalidateQueries({ queryKey: ['pos-sync-logs', tenantId, configId] }); + }, + }); +}; + +export const usePOSSyncStatus = (configId?: string, pollingInterval?: number) => { + const tenantId = useTenantId(); + + return useQuery({ + queryKey: ['pos-sync-status', tenantId, configId], + queryFn: () => posService.getSyncStatus(tenantId, configId!), + enabled: !!tenantId && !!configId, + refetchInterval: pollingInterval || 30000, // Poll every 30 seconds by default + staleTime: 10 * 1000, // 10 seconds + }); +}; + +export const usePOSSyncLogs = (configId?: string, params?: { + limit?: number; + offset?: number; + status?: string; + sync_type?: string; + data_type?: string; +}) => { + const tenantId = useTenantId(); + + return useQuery({ + queryKey: ['pos-sync-logs', tenantId, configId, params], + queryFn: () => posService.getSyncLogs(tenantId, configId!, params), + enabled: !!tenantId && !!configId, + staleTime: 2 * 60 * 1000, // 2 minutes + }); +}; + +// ============================================================================ +// TRANSACTION HOOKS +// ============================================================================ + +export const usePOSTransactions = (params?: { + pos_system?: string; + start_date?: string; + end_date?: string; + status?: string; + is_synced?: boolean; + limit?: number; + offset?: number; +}) => { + const tenantId = useTenantId(); + + return useQuery({ + queryKey: ['pos-transactions', tenantId, params], + queryFn: () => posService.getTransactions(tenantId, params), + enabled: !!tenantId, + staleTime: 2 * 60 * 1000, // 2 minutes + }); +}; + +export const useSyncSingleTransaction = () => { + const queryClient = useQueryClient(); + const tenantId = useTenantId(); + + return useMutation({ + mutationFn: ({ transactionId, force }: { transactionId: string; force?: boolean }) => + posService.syncSingleTransaction(tenantId, transactionId, force), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['pos-transactions', tenantId] }); + }, + }); +}; + +export const useResyncFailedTransactions = () => { + const queryClient = useQueryClient(); + const tenantId = useTenantId(); + + return useMutation({ + mutationFn: (daysBack: number) => + posService.resyncFailedTransactions(tenantId, daysBack), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['pos-transactions', tenantId] }); + }, + }); +}; + +// ============================================================================ +// ANALYTICS HOOKS +// ============================================================================ + +export const usePOSAnalytics = (days: number = 30) => { + const tenantId = useTenantId(); + + return useQuery({ + queryKey: ['pos-analytics', tenantId, days], + queryFn: () => posService.getSyncAnalytics(tenantId, days), + enabled: !!tenantId, + staleTime: 10 * 60 * 1000, // 10 minutes + }); +}; + +// ============================================================================ +// SYSTEM INFO HOOKS +// ============================================================================ + +export const useSupportedPOSSystems = () => { + return useQuery({ + queryKey: ['supported-pos-systems'], + queryFn: () => posService.getSupportedSystems(), + staleTime: 60 * 60 * 1000, // 1 hour + }); +}; + +export const useWebhookStatus = (posSystem?: string) => { + return useQuery({ + queryKey: ['webhook-status', posSystem], + queryFn: () => posService.getWebhookStatus(posSystem!), + enabled: !!posSystem, + staleTime: 5 * 60 * 1000, // 5 minutes + }); +}; + +// ============================================================================ +// COMPOSITE HOOKS +// ============================================================================ + +export const usePOSDashboard = () => { + const tenantId = useTenantId(); + + // Get configurations + const { data: configurationsData, isLoading: configurationsLoading } = usePOSConfigurations(); + + // Get recent transactions + const { data: transactionsData, isLoading: transactionsLoading } = usePOSTransactions({ + limit: 10 + }); + + // Get analytics for last 7 days + const { data: analyticsData, isLoading: analyticsLoading } = usePOSAnalytics(7); + + const isLoading = configurationsLoading || transactionsLoading || analyticsLoading; + + return { + configurations: configurationsData?.configurations || [], + transactions: transactionsData?.transactions || [], + analytics: analyticsData, + isLoading, + summary: { + total_configurations: configurationsData?.total || 0, + active_configurations: configurationsData?.configurations?.filter(c => c.is_active).length || 0, + connected_configurations: configurationsData?.configurations?.filter(c => c.is_connected).length || 0, + total_transactions: transactionsData?.total || 0, + total_revenue: transactionsData?.summary?.total_amount || 0, + sync_health: analyticsData?.success_rate || 0, + } + }; +}; + +export const usePOSConfigurationManagement = () => { + const createMutation = useCreatePOSConfiguration(); + const updateMutation = useUpdatePOSConfiguration(); + const deleteMutation = useDeletePOSConfiguration(); + const testConnectionMutation = useTestPOSConnection(); + + const [selectedConfiguration, setSelectedConfiguration] = useState(null); + const [isFormOpen, setIsFormOpen] = useState(false); + + const handleCreate = async (data: CreatePOSConfigurationRequest) => { + await createMutation.mutateAsync(data); + setIsFormOpen(false); + }; + + const handleUpdate = async (configId: string, data: UpdatePOSConfigurationRequest) => { + await updateMutation.mutateAsync({ configId, data }); + setIsFormOpen(false); + setSelectedConfiguration(null); + }; + + const handleDelete = async (configId: string) => { + await deleteMutation.mutateAsync(configId); + }; + + const handleTestConnection = async (configId: string) => { + return await testConnectionMutation.mutateAsync(configId); + }; + + const openCreateForm = () => { + setSelectedConfiguration(null); + setIsFormOpen(true); + }; + + const openEditForm = (configuration: POSConfiguration) => { + setSelectedConfiguration(configuration); + setIsFormOpen(true); + }; + + const closeForm = () => { + setIsFormOpen(false); + setSelectedConfiguration(null); + }; + + return { + // State + selectedConfiguration, + isFormOpen, + + // Actions + handleCreate, + handleUpdate, + handleDelete, + handleTestConnection, + openCreateForm, + openEditForm, + closeForm, + + // Loading states + isCreating: createMutation.isPending, + isUpdating: updateMutation.isPending, + isDeleting: deleteMutation.isPending, + isTesting: testConnectionMutation.isPending, + + // Errors + createError: createMutation.error, + updateError: updateMutation.error, + deleteError: deleteMutation.error, + testError: testConnectionMutation.error, + }; +}; \ No newline at end of file diff --git a/frontend/src/api/services/pos.service.ts b/frontend/src/api/services/pos.service.ts new file mode 100644 index 00000000..7fb7baec --- /dev/null +++ b/frontend/src/api/services/pos.service.ts @@ -0,0 +1,392 @@ +// frontend/src/api/services/pos.service.ts +/** + * POS Integration API Service + * Handles all communication with the POS service backend + */ + +import { apiClient } from '../client'; + +// ============================================================================ +// TYPES & INTERFACES +// ============================================================================ + +export interface POSConfiguration { + id: string; + tenant_id: string; + pos_system: 'square' | 'toast' | 'lightspeed'; + provider_name: string; + is_active: boolean; + is_connected: boolean; + environment: 'sandbox' | 'production'; + location_id?: string; + merchant_id?: string; + sync_enabled: boolean; + sync_interval_minutes: string; + auto_sync_products: boolean; + auto_sync_transactions: boolean; + webhook_url?: string; + last_sync_at?: string; + last_successful_sync_at?: string; + last_sync_status?: 'success' | 'failed' | 'partial'; + last_sync_message?: string; + provider_settings?: Record; + last_health_check_at?: string; + health_status: 'healthy' | 'unhealthy' | 'warning' | 'unknown'; + health_message?: string; + created_at: string; + updated_at: string; + created_by?: string; + notes?: string; +} + +export interface CreatePOSConfigurationRequest { + pos_system: 'square' | 'toast' | 'lightspeed'; + provider_name: string; + environment: 'sandbox' | 'production'; + location_id?: string; + merchant_id?: string; + sync_enabled?: boolean; + sync_interval_minutes?: string; + auto_sync_products?: boolean; + auto_sync_transactions?: boolean; + notes?: string; + // Credentials + api_key?: string; + api_secret?: string; + access_token?: string; + application_id?: string; + webhook_secret?: string; +} + +export interface UpdatePOSConfigurationRequest { + provider_name?: string; + is_active?: boolean; + environment?: 'sandbox' | 'production'; + location_id?: string; + merchant_id?: string; + sync_enabled?: boolean; + sync_interval_minutes?: string; + auto_sync_products?: boolean; + auto_sync_transactions?: boolean; + notes?: string; + // Credentials (only if updating) + api_key?: string; + api_secret?: string; + access_token?: string; + application_id?: string; + webhook_secret?: string; +} + +export interface POSTransaction { + id: string; + tenant_id: string; + pos_config_id: string; + pos_system: string; + external_transaction_id: string; + external_order_id?: string; + transaction_type: 'sale' | 'refund' | 'void' | 'exchange'; + status: 'completed' | 'pending' | 'failed' | 'refunded' | 'voided'; + subtotal: number; + tax_amount: number; + tip_amount: number; + discount_amount: number; + total_amount: number; + currency: string; + payment_method?: string; + payment_status?: string; + transaction_date: string; + pos_created_at: string; + pos_updated_at?: string; + location_id?: string; + location_name?: string; + staff_id?: string; + staff_name?: string; + customer_id?: string; + customer_email?: string; + customer_phone?: string; + order_type?: string; + table_number?: string; + receipt_number?: string; + is_synced_to_sales: boolean; + sales_record_id?: string; + sync_attempted_at?: string; + sync_completed_at?: string; + sync_error?: string; + sync_retry_count: number; + is_processed: boolean; + processing_error?: string; + is_duplicate: boolean; + duplicate_of?: string; + created_at: string; + updated_at: string; + items: POSTransactionItem[]; +} + +export interface POSTransactionItem { + id: string; + transaction_id: string; + external_item_id?: string; + sku?: string; + product_name: string; + product_category?: string; + product_subcategory?: string; + quantity: number; + unit_price: number; + total_price: number; + discount_amount: number; + tax_amount: number; + modifiers?: Record; + inventory_product_id?: string; + is_mapped_to_inventory: boolean; + is_synced_to_sales: boolean; + sync_error?: string; +} + +export interface POSSyncLog { + id: string; + tenant_id: string; + pos_config_id: string; + sync_type: 'full' | 'incremental' | 'manual' | 'webhook_triggered'; + sync_direction: 'inbound' | 'outbound' | 'bidirectional'; + data_type: 'transactions' | 'products' | 'customers' | 'orders'; + pos_system: string; + status: 'started' | 'in_progress' | 'completed' | 'failed' | 'cancelled'; + started_at: string; + completed_at?: string; + duration_seconds?: number; + sync_from_date?: string; + sync_to_date?: string; + records_requested: number; + records_processed: number; + records_created: number; + records_updated: number; + records_skipped: number; + records_failed: number; + api_calls_made: number; + error_message?: string; + error_code?: string; + retry_attempt: number; + max_retries: number; + progress_percentage?: number; + revenue_synced?: number; + transactions_synced: number; + triggered_by?: string; + triggered_by_user_id?: string; + created_at: string; + updated_at: string; +} + +export interface SyncRequest { + sync_type?: 'full' | 'incremental'; + data_types?: ('transactions' | 'products' | 'customers')[]; + from_date?: string; + to_date?: string; +} + +export interface SyncStatus { + current_sync?: POSSyncLog; + last_successful_sync?: POSSyncLog; + recent_syncs: POSSyncLog[]; + sync_health: { + status: 'healthy' | 'unhealthy' | 'warning'; + success_rate: number; + average_duration_minutes: number; + last_error?: string; + }; +} + +export interface SupportedPOSSystem { + id: string; + name: string; + description: string; + features: string[]; + supported_regions: string[]; +} + +export interface POSAnalytics { + period_days: number; + total_syncs: number; + successful_syncs: number; + failed_syncs: number; + success_rate: number; + average_duration_minutes: number; + total_transactions_synced: number; + total_revenue_synced: number; + sync_frequency: { + daily_average: number; + peak_day?: string; + peak_count: number; + }; + error_analysis: { + common_errors: Array<{ error: string; count: number }>; + error_trends: Array<{ date: string; count: number }>; + }; +} + +export interface ConnectionTestResult { + status: 'success' | 'error'; + message: string; + tested_at: string; +} + +// ============================================================================ +// API FUNCTIONS +// ============================================================================ + +export const posService = { + // Configuration Management + async getConfigurations(tenantId: string, params?: { + pos_system?: string; + is_active?: boolean; + limit?: number; + offset?: number; + }): Promise<{ configurations: POSConfiguration[]; total: number }> { + const response = await apiClient.get(`/pos-service/api/v1/tenants/${tenantId}/pos/configurations`, { + params + }); + return response.data; + }, + + async createConfiguration(tenantId: string, data: CreatePOSConfigurationRequest): Promise { + const response = await apiClient.post(`/pos-service/api/v1/tenants/${tenantId}/pos/configurations`, data); + return response.data; + }, + + async getConfiguration(tenantId: string, configId: string): Promise { + const response = await apiClient.get(`/pos-service/api/v1/tenants/${tenantId}/pos/configurations/${configId}`); + return response.data; + }, + + async updateConfiguration(tenantId: string, configId: string, data: UpdatePOSConfigurationRequest): Promise { + const response = await apiClient.put(`/pos-service/api/v1/tenants/${tenantId}/pos/configurations/${configId}`, data); + return response.data; + }, + + async deleteConfiguration(tenantId: string, configId: string): Promise { + await apiClient.delete(`/pos-service/api/v1/tenants/${tenantId}/pos/configurations/${configId}`); + }, + + async testConnection(tenantId: string, configId: string): Promise { + const response = await apiClient.post(`/pos-service/api/v1/tenants/${tenantId}/pos/configurations/${configId}/test-connection`); + return response.data; + }, + + // Synchronization + async triggerSync(tenantId: string, configId: string, syncRequest: SyncRequest): Promise<{ + message: string; + sync_id: string; + status: string; + sync_type: string; + data_types: string[]; + estimated_duration: string; + }> { + const response = await apiClient.post(`/pos-service/api/v1/tenants/${tenantId}/pos/configurations/${configId}/sync`, syncRequest); + return response.data; + }, + + async getSyncStatus(tenantId: string, configId: string, limit?: number): Promise { + const response = await apiClient.get(`/pos-service/api/v1/tenants/${tenantId}/pos/configurations/${configId}/sync/status`, { + params: { limit } + }); + return response.data; + }, + + async getSyncLogs(tenantId: string, configId: string, params?: { + limit?: number; + offset?: number; + status?: string; + sync_type?: string; + data_type?: string; + }): Promise<{ logs: POSSyncLog[]; total: number; has_more: boolean }> { + const response = await apiClient.get(`/pos-service/api/v1/tenants/${tenantId}/pos/configurations/${configId}/sync/logs`, { + params + }); + return response.data; + }, + + // Transaction Management + async getTransactions(tenantId: string, params?: { + pos_system?: string; + start_date?: string; + end_date?: string; + status?: string; + is_synced?: boolean; + limit?: number; + offset?: number; + }): Promise<{ + transactions: POSTransaction[]; + total: number; + has_more: boolean; + summary: { + total_amount: number; + transaction_count: number; + sync_status: { + synced: number; + pending: number; + failed: number; + }; + }; + }> { + const response = await apiClient.get(`/pos-service/api/v1/tenants/${tenantId}/pos/transactions`, { + params + }); + return response.data; + }, + + async syncSingleTransaction(tenantId: string, transactionId: string, force?: boolean): Promise<{ + message: string; + transaction_id: string; + sync_status: string; + sales_record_id?: string; + }> { + const response = await apiClient.post(`/pos-service/api/v1/tenants/${tenantId}/pos/transactions/${transactionId}/sync`, + {}, { params: { force } } + ); + return response.data; + }, + + async resyncFailedTransactions(tenantId: string, daysBack: number): Promise<{ + message: string; + job_id: string; + scope: string; + estimated_transactions: number; + }> { + const response = await apiClient.post(`/pos-service/api/v1/tenants/${tenantId}/pos/data/resync`, + {}, { params: { days_back: daysBack } } + ); + return response.data; + }, + + // Analytics + async getSyncAnalytics(tenantId: string, days: number = 30): Promise { + const response = await apiClient.get(`/pos-service/api/v1/tenants/${tenantId}/pos/analytics/sync-performance`, { + params: { days } + }); + return response.data; + }, + + // System Information + async getSupportedSystems(): Promise<{ systems: SupportedPOSSystem[] }> { + const response = await apiClient.get('/pos-service/api/v1/pos/supported-systems'); + return response.data; + }, + + // Webhook Status + async getWebhookStatus(posSystem: string): Promise<{ + pos_system: string; + status: string; + endpoint: string; + supported_events: { + events: string[]; + format: string; + authentication: string; + }; + last_received?: string; + total_received: number; + }> { + const response = await apiClient.get(`/pos-service/api/v1/webhooks/${posSystem}/status`); + return response.data; + } +}; + +export default posService; \ No newline at end of file diff --git a/frontend/src/components/pos/POSConfigurationCard.tsx b/frontend/src/components/pos/POSConfigurationCard.tsx new file mode 100644 index 00000000..bff5e64d --- /dev/null +++ b/frontend/src/components/pos/POSConfigurationCard.tsx @@ -0,0 +1,356 @@ +import React, { useState } from 'react'; +import { + Edit, + RefreshCw, + Globe, + Activity, + CheckCircle, + AlertTriangle, + Clock, + Settings, + MoreVertical, + Trash2, + Power, + Zap +} from 'lucide-react'; + +import Button from '../ui/Button'; +import Card from '../ui/Card'; +import LoadingSpinner from '../ui/LoadingSpinner'; + +interface POSConfiguration { + id: string; + pos_system: string; + provider_name: string; + is_active: boolean; + is_connected: boolean; + environment: string; + location_id?: string; + merchant_id?: string; + sync_enabled: boolean; + sync_interval_minutes: string; + auto_sync_products: boolean; + auto_sync_transactions: boolean; + last_sync_at?: string; + last_successful_sync_at?: string; + last_sync_status?: string; + health_status: string; + created_at: string; + updated_at: string; +} + +interface POSConfigurationCardProps { + configuration: POSConfiguration; + onEdit: (config: POSConfiguration) => void; + onTriggerSync: (configId: string) => Promise; + onTestConnection: (configId: string) => Promise; + onToggleActive?: (configId: string, active: boolean) => Promise; + onDelete?: (configId: string) => Promise; +} + +const POSConfigurationCard: React.FC = ({ + configuration, + onEdit, + onTriggerSync, + onTestConnection, + onToggleActive, + onDelete +}) => { + const [isMenuOpen, setIsMenuOpen] = useState(false); + const [isSyncing, setIsSyncing] = useState(false); + const [isTesting, setIsTesting] = useState(false); + const [isToggling, setIsToggling] = useState(false); + + const getPOSSystemIcon = (system: string) => { + switch (system) { + case 'square': return '⬜'; + case 'toast': return '🍞'; + case 'lightspeed': return '⚡'; + default: return '💳'; + } + }; + + const getStatusColor = (status: string) => { + switch (status) { + case 'healthy': return 'text-green-600'; + case 'unhealthy': return 'text-red-600'; + case 'warning': return 'text-yellow-600'; + default: return 'text-gray-600'; + } + }; + + const getStatusIcon = (status: string) => { + switch (status) { + case 'healthy': return CheckCircle; + case 'unhealthy': return AlertTriangle; + case 'warning': return Clock; + default: return Activity; + } + }; + + const getConnectionStatusColor = (isConnected: boolean) => { + return isConnected ? 'text-green-600' : 'text-red-600'; + }; + + const getSyncStatusColor = (status?: string) => { + switch (status) { + case 'success': return 'text-green-600'; + case 'failed': return 'text-red-600'; + case 'partial': return 'text-yellow-600'; + default: return 'text-gray-600'; + } + }; + + const formatLastSync = (timestamp?: string) => { + if (!timestamp) return 'Never'; + + const date = new Date(timestamp); + const now = new Date(); + const diffMs = now.getTime() - date.getTime(); + const diffMins = Math.floor(diffMs / (1000 * 60)); + + if (diffMins < 1) return 'Just now'; + if (diffMins < 60) return `${diffMins}m ago`; + if (diffMins < 1440) return `${Math.floor(diffMins / 60)}h ago`; + return `${Math.floor(diffMins / 1440)}d ago`; + }; + + const handleSync = async () => { + setIsSyncing(true); + try { + await onTriggerSync(configuration.id); + } finally { + setIsSyncing(false); + } + }; + + const handleTestConnection = async () => { + setIsTesting(true); + try { + await onTestConnection(configuration.id); + } finally { + setIsTesting(false); + } + }; + + const handleToggleActive = async () => { + if (!onToggleActive) return; + + setIsToggling(true); + try { + await onToggleActive(configuration.id, !configuration.is_active); + } finally { + setIsToggling(false); + } + }; + + const StatusIcon = getStatusIcon(configuration.health_status); + + return ( + + {/* Header */} +
+
+
+ {getPOSSystemIcon(configuration.pos_system)} +
+
+

+ {configuration.provider_name} +

+

+ {configuration.pos_system} • {configuration.environment} +

+
+
+ +
+ + + {isMenuOpen && ( +
+
+ + + {onToggleActive && ( + + )} + + {onDelete && ( + + )} +
+
+ )} +
+
+ + {/* Status Indicators */} +
+
+
+ + {configuration.health_status} +
+

Health

+
+ +
+
+ + + {configuration.is_connected ? 'Connected' : 'Disconnected'} + +
+

Connection

+
+ +
+
+ + + {configuration.last_sync_status || 'Unknown'} + +
+

Last Sync

+
+
+ + {/* Configuration Details */} +
+ {configuration.location_id && ( +
+ Location ID: + {configuration.location_id} +
+ )} + +
+ Sync Interval: + {configuration.sync_interval_minutes}m +
+ +
+ Last Sync: + {formatLastSync(configuration.last_sync_at)} +
+ +
+ Auto Sync: +
+ {configuration.auto_sync_transactions && ( + Transactions + )} + {configuration.auto_sync_products && ( + Products + )} +
+
+
+ + {/* Status Badge */} +
+
+ + {configuration.is_active ? 'Active' : 'Inactive'} + + + {configuration.sync_enabled && ( + + Sync Enabled + + )} +
+
+ + {/* Action Buttons */} +
+ + + + + +
+ + {/* Click overlay to close menu */} + {isMenuOpen && ( +
setIsMenuOpen(false)} + /> + )} + + ); +}; + +export default POSConfigurationCard; \ No newline at end of file diff --git a/frontend/src/components/pos/POSConfigurationForm.tsx b/frontend/src/components/pos/POSConfigurationForm.tsx new file mode 100644 index 00000000..95c1d1ad --- /dev/null +++ b/frontend/src/components/pos/POSConfigurationForm.tsx @@ -0,0 +1,595 @@ +import React, { useState, useEffect } from 'react'; +import { + X, + Zap, + Settings, + Globe, + Key, + Webhook, + Sync, + AlertTriangle, + CheckCircle, + Clock, + Database +} from 'lucide-react'; + +import Button from '../ui/Button'; +import LoadingSpinner from '../ui/LoadingSpinner'; + +interface POSConfiguration { + id?: string; + pos_system: string; + provider_name: string; + is_active: boolean; + is_connected: boolean; + environment: string; + location_id?: string; + merchant_id?: string; + sync_enabled: boolean; + sync_interval_minutes: string; + auto_sync_products: boolean; + auto_sync_transactions: boolean; + webhook_url?: string; + notes?: string; +} + +interface POSConfigurationFormProps { + configuration?: POSConfiguration | null; + isOpen: boolean; + isCreating?: boolean; + onSubmit: (data: POSConfiguration) => Promise; + onClose: () => void; +} + +interface FormData extends POSConfiguration { + // Credentials (these won't be in the existing config for security) + api_key?: string; + api_secret?: string; + access_token?: string; + application_id?: string; + webhook_secret?: string; +} + +const SUPPORTED_POS_SYSTEMS = [ + { + id: 'square', + name: 'Square POS', + description: 'Square Point of Sale system', + logo: '⬜', + fields: ['application_id', 'access_token', 'webhook_secret'] + }, + { + id: 'toast', + name: 'Toast POS', + description: 'Toast restaurant POS system', + logo: '🍞', + fields: ['api_key', 'api_secret', 'webhook_secret'] + }, + { + id: 'lightspeed', + name: 'Lightspeed Restaurant', + description: 'Lightspeed restaurant management system', + logo: '⚡', + fields: ['api_key', 'api_secret', 'cluster_id'] + } +]; + +const POSConfigurationForm: React.FC = ({ + configuration, + isOpen, + isCreating = false, + onSubmit, + onClose +}) => { + const [formData, setFormData] = useState({ + pos_system: '', + provider_name: '', + is_active: true, + is_connected: false, + environment: 'sandbox', + sync_enabled: true, + sync_interval_minutes: '5', + auto_sync_products: true, + auto_sync_transactions: true, + ...configuration + }); + + const [isLoading, setIsLoading] = useState(false); + const [errors, setErrors] = useState>({}); + const [testingConnection, setTestingConnection] = useState(false); + const [connectionStatus, setConnectionStatus] = useState<'idle' | 'success' | 'error'>('idle'); + + useEffect(() => { + if (configuration) { + setFormData({ + ...formData, + ...configuration + }); + } + }, [configuration]); + + const selectedPOSSystem = SUPPORTED_POS_SYSTEMS.find(sys => sys.id === formData.pos_system); + + const validateForm = (): boolean => { + const newErrors: Record = {}; + + if (!formData.pos_system) { + newErrors.pos_system = 'POS system is required'; + } + + if (!formData.provider_name.trim()) { + newErrors.provider_name = 'Provider name is required'; + } + + if (selectedPOSSystem?.fields.includes('api_key') && !formData.api_key?.trim()) { + newErrors.api_key = 'API Key is required'; + } + + if (selectedPOSSystem?.fields.includes('access_token') && !formData.access_token?.trim()) { + newErrors.access_token = 'Access Token is required'; + } + + if (selectedPOSSystem?.fields.includes('application_id') && !formData.application_id?.trim()) { + newErrors.application_id = 'Application ID is required'; + } + + const syncInterval = parseInt(formData.sync_interval_minutes); + if (isNaN(syncInterval) || syncInterval < 1 || syncInterval > 1440) { + newErrors.sync_interval_minutes = 'Sync interval must be between 1 and 1440 minutes'; + } + + setErrors(newErrors); + return Object.keys(newErrors).length === 0; + }; + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + + if (!validateForm()) { + return; + } + + setIsLoading(true); + try { + await onSubmit(formData); + onClose(); + } catch (error) { + console.error('Error submitting form:', error); + } finally { + setIsLoading(false); + } + }; + + const handleTestConnection = async () => { + if (!validateForm()) { + return; + } + + setTestingConnection(true); + setConnectionStatus('idle'); + + try { + // TODO: Implement connection test API call + await new Promise(resolve => setTimeout(resolve, 2000)); // Simulate API call + setConnectionStatus('success'); + } catch (error) { + setConnectionStatus('error'); + } finally { + setTestingConnection(false); + } + }; + + const handleInputChange = (field: keyof FormData, value: any) => { + setFormData(prev => ({ + ...prev, + [field]: value + })); + + // Clear error when user starts typing + if (errors[field]) { + setErrors(prev => ({ + ...prev, + [field]: '' + })); + } + }; + + const handlePOSSystemChange = (posSystem: string) => { + const system = SUPPORTED_POS_SYSTEMS.find(sys => sys.id === posSystem); + setFormData(prev => ({ + ...prev, + pos_system: posSystem, + provider_name: system?.name || '', + // Clear credentials when changing systems + api_key: '', + api_secret: '', + access_token: '', + application_id: '', + webhook_secret: '' + })); + setConnectionStatus('idle'); + }; + + if (!isOpen) return null; + + return ( +
+
+ {/* Header */} +
+
+ +

+ {isCreating ? 'Add POS Integration' : 'Edit POS Configuration'} +

+
+ +
+ +
+ {/* POS System Selection */} +
+

+ + POS System Configuration +

+ +
+ {SUPPORTED_POS_SYSTEMS.map((system) => ( + + ))} +
+ {errors.pos_system && ( +

{errors.pos_system}

+ )} +
+ + {/* Basic Configuration */} +
+
+ + handleInputChange('provider_name', e.target.value)} + className={`w-full px-3 py-2 border rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500 ${ + errors.provider_name ? 'border-red-500' : 'border-gray-300' + }`} + placeholder="e.g., Main Store Square POS" + /> + {errors.provider_name && ( +

{errors.provider_name}

+ )} +
+ +
+ + +
+ +
+ + handleInputChange('location_id', e.target.value)} + className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500" + placeholder="Optional location identifier" + /> +
+ +
+ + handleInputChange('merchant_id', e.target.value)} + className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500" + placeholder="Optional merchant identifier" + /> +
+
+ + {/* API Credentials */} + {selectedPOSSystem && ( +
+

+ + API Credentials +

+ +
+
+ +

+ Credentials are encrypted and stored securely. Never share your API keys. +

+
+
+ +
+ {selectedPOSSystem.fields.includes('application_id') && ( +
+ + handleInputChange('application_id', e.target.value)} + className={`w-full px-3 py-2 border rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500 ${ + errors.application_id ? 'border-red-500' : 'border-gray-300' + }`} + placeholder="Square Application ID" + /> + {errors.application_id && ( +

{errors.application_id}

+ )} +
+ )} + + {selectedPOSSystem.fields.includes('access_token') && ( +
+ + handleInputChange('access_token', e.target.value)} + className={`w-full px-3 py-2 border rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500 ${ + errors.access_token ? 'border-red-500' : 'border-gray-300' + }`} + placeholder="Square Access Token" + /> + {errors.access_token && ( +

{errors.access_token}

+ )} +
+ )} + + {selectedPOSSystem.fields.includes('api_key') && ( +
+ + handleInputChange('api_key', e.target.value)} + className={`w-full px-3 py-2 border rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500 ${ + errors.api_key ? 'border-red-500' : 'border-gray-300' + }`} + placeholder="API Key" + /> + {errors.api_key && ( +

{errors.api_key}

+ )} +
+ )} + + {selectedPOSSystem.fields.includes('api_secret') && ( +
+ + handleInputChange('api_secret', e.target.value)} + className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500" + placeholder="API Secret" + /> +
+ )} + + {selectedPOSSystem.fields.includes('webhook_secret') && ( +
+ + handleInputChange('webhook_secret', e.target.value)} + className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500" + placeholder="Webhook verification secret" + /> +
+ )} +
+ + {/* Test Connection */} +
+ + + {connectionStatus === 'success' && ( +
+ + Connection successful +
+ )} + + {connectionStatus === 'error' && ( +
+ + Connection failed +
+ )} +
+
+ )} + + {/* Sync Configuration */} +
+

+ + Synchronization Settings +

+ +
+
+ + handleInputChange('sync_interval_minutes', e.target.value)} + className={`w-full px-3 py-2 border rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500 ${ + errors.sync_interval_minutes ? 'border-red-500' : 'border-gray-300' + }`} + /> + {errors.sync_interval_minutes && ( +

{errors.sync_interval_minutes}

+ )} +
+ +
+ + + + + + + +
+
+
+ + {/* Notes */} +
+ +