Add POS service

This commit is contained in:
Urtzi Alfaro
2025-08-16 15:00:36 +02:00
parent 995a51e285
commit 23c5f50111
34 changed files with 6086 additions and 0 deletions

View File

@@ -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<POSConfiguration | null>(null);
const [isFormOpen, setIsFormOpen] = useState(false);
const handleCreate = async (data: CreatePOSConfigurationRequest) => {
await createMutation.mutateAsync(data);
setIsFormOpen(false);
};
const handleUpdate = async (configId: string, data: UpdatePOSConfigurationRequest) => {
await updateMutation.mutateAsync({ configId, data });
setIsFormOpen(false);
setSelectedConfiguration(null);
};
const handleDelete = async (configId: string) => {
await deleteMutation.mutateAsync(configId);
};
const handleTestConnection = async (configId: string) => {
return await testConnectionMutation.mutateAsync(configId);
};
const openCreateForm = () => {
setSelectedConfiguration(null);
setIsFormOpen(true);
};
const openEditForm = (configuration: POSConfiguration) => {
setSelectedConfiguration(configuration);
setIsFormOpen(true);
};
const closeForm = () => {
setIsFormOpen(false);
setSelectedConfiguration(null);
};
return {
// State
selectedConfiguration,
isFormOpen,
// Actions
handleCreate,
handleUpdate,
handleDelete,
handleTestConnection,
openCreateForm,
openEditForm,
closeForm,
// Loading states
isCreating: createMutation.isPending,
isUpdating: updateMutation.isPending,
isDeleting: deleteMutation.isPending,
isTesting: testConnectionMutation.isPending,
// Errors
createError: createMutation.error,
updateError: updateMutation.error,
deleteError: deleteMutation.error,
testError: testConnectionMutation.error,
};
};

View File

@@ -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<string, any>;
last_health_check_at?: string;
health_status: 'healthy' | 'unhealthy' | 'warning' | 'unknown';
health_message?: string;
created_at: string;
updated_at: string;
created_by?: string;
notes?: string;
}
export interface CreatePOSConfigurationRequest {
pos_system: 'square' | 'toast' | 'lightspeed';
provider_name: string;
environment: 'sandbox' | 'production';
location_id?: string;
merchant_id?: string;
sync_enabled?: boolean;
sync_interval_minutes?: string;
auto_sync_products?: boolean;
auto_sync_transactions?: boolean;
notes?: string;
// Credentials
api_key?: string;
api_secret?: string;
access_token?: string;
application_id?: string;
webhook_secret?: string;
}
export interface UpdatePOSConfigurationRequest {
provider_name?: string;
is_active?: boolean;
environment?: 'sandbox' | 'production';
location_id?: string;
merchant_id?: string;
sync_enabled?: boolean;
sync_interval_minutes?: string;
auto_sync_products?: boolean;
auto_sync_transactions?: boolean;
notes?: string;
// Credentials (only if updating)
api_key?: string;
api_secret?: string;
access_token?: string;
application_id?: string;
webhook_secret?: string;
}
export interface POSTransaction {
id: string;
tenant_id: string;
pos_config_id: string;
pos_system: string;
external_transaction_id: string;
external_order_id?: string;
transaction_type: 'sale' | 'refund' | 'void' | 'exchange';
status: 'completed' | 'pending' | 'failed' | 'refunded' | 'voided';
subtotal: number;
tax_amount: number;
tip_amount: number;
discount_amount: number;
total_amount: number;
currency: string;
payment_method?: string;
payment_status?: string;
transaction_date: string;
pos_created_at: string;
pos_updated_at?: string;
location_id?: string;
location_name?: string;
staff_id?: string;
staff_name?: string;
customer_id?: string;
customer_email?: string;
customer_phone?: string;
order_type?: string;
table_number?: string;
receipt_number?: string;
is_synced_to_sales: boolean;
sales_record_id?: string;
sync_attempted_at?: string;
sync_completed_at?: string;
sync_error?: string;
sync_retry_count: number;
is_processed: boolean;
processing_error?: string;
is_duplicate: boolean;
duplicate_of?: string;
created_at: string;
updated_at: string;
items: POSTransactionItem[];
}
export interface POSTransactionItem {
id: string;
transaction_id: string;
external_item_id?: string;
sku?: string;
product_name: string;
product_category?: string;
product_subcategory?: string;
quantity: number;
unit_price: number;
total_price: number;
discount_amount: number;
tax_amount: number;
modifiers?: Record<string, any>;
inventory_product_id?: string;
is_mapped_to_inventory: boolean;
is_synced_to_sales: boolean;
sync_error?: string;
}
export interface POSSyncLog {
id: string;
tenant_id: string;
pos_config_id: string;
sync_type: 'full' | 'incremental' | 'manual' | 'webhook_triggered';
sync_direction: 'inbound' | 'outbound' | 'bidirectional';
data_type: 'transactions' | 'products' | 'customers' | 'orders';
pos_system: string;
status: 'started' | 'in_progress' | 'completed' | 'failed' | 'cancelled';
started_at: string;
completed_at?: string;
duration_seconds?: number;
sync_from_date?: string;
sync_to_date?: string;
records_requested: number;
records_processed: number;
records_created: number;
records_updated: number;
records_skipped: number;
records_failed: number;
api_calls_made: number;
error_message?: string;
error_code?: string;
retry_attempt: number;
max_retries: number;
progress_percentage?: number;
revenue_synced?: number;
transactions_synced: number;
triggered_by?: string;
triggered_by_user_id?: string;
created_at: string;
updated_at: string;
}
export interface SyncRequest {
sync_type?: 'full' | 'incremental';
data_types?: ('transactions' | 'products' | 'customers')[];
from_date?: string;
to_date?: string;
}
export interface SyncStatus {
current_sync?: POSSyncLog;
last_successful_sync?: POSSyncLog;
recent_syncs: POSSyncLog[];
sync_health: {
status: 'healthy' | 'unhealthy' | 'warning';
success_rate: number;
average_duration_minutes: number;
last_error?: string;
};
}
export interface SupportedPOSSystem {
id: string;
name: string;
description: string;
features: string[];
supported_regions: string[];
}
export interface POSAnalytics {
period_days: number;
total_syncs: number;
successful_syncs: number;
failed_syncs: number;
success_rate: number;
average_duration_minutes: number;
total_transactions_synced: number;
total_revenue_synced: number;
sync_frequency: {
daily_average: number;
peak_day?: string;
peak_count: number;
};
error_analysis: {
common_errors: Array<{ error: string; count: number }>;
error_trends: Array<{ date: string; count: number }>;
};
}
export interface ConnectionTestResult {
status: 'success' | 'error';
message: string;
tested_at: string;
}
// ============================================================================
// API FUNCTIONS
// ============================================================================
export const posService = {
// Configuration Management
async getConfigurations(tenantId: string, params?: {
pos_system?: string;
is_active?: boolean;
limit?: number;
offset?: number;
}): Promise<{ configurations: POSConfiguration[]; total: number }> {
const response = await apiClient.get(`/pos-service/api/v1/tenants/${tenantId}/pos/configurations`, {
params
});
return response.data;
},
async createConfiguration(tenantId: string, data: CreatePOSConfigurationRequest): Promise<POSConfiguration> {
const response = await apiClient.post(`/pos-service/api/v1/tenants/${tenantId}/pos/configurations`, data);
return response.data;
},
async getConfiguration(tenantId: string, configId: string): Promise<POSConfiguration> {
const response = await apiClient.get(`/pos-service/api/v1/tenants/${tenantId}/pos/configurations/${configId}`);
return response.data;
},
async updateConfiguration(tenantId: string, configId: string, data: UpdatePOSConfigurationRequest): Promise<POSConfiguration> {
const response = await apiClient.put(`/pos-service/api/v1/tenants/${tenantId}/pos/configurations/${configId}`, data);
return response.data;
},
async deleteConfiguration(tenantId: string, configId: string): Promise<void> {
await apiClient.delete(`/pos-service/api/v1/tenants/${tenantId}/pos/configurations/${configId}`);
},
async testConnection(tenantId: string, configId: string): Promise<ConnectionTestResult> {
const response = await apiClient.post(`/pos-service/api/v1/tenants/${tenantId}/pos/configurations/${configId}/test-connection`);
return response.data;
},
// Synchronization
async triggerSync(tenantId: string, configId: string, syncRequest: SyncRequest): Promise<{
message: string;
sync_id: string;
status: string;
sync_type: string;
data_types: string[];
estimated_duration: string;
}> {
const response = await apiClient.post(`/pos-service/api/v1/tenants/${tenantId}/pos/configurations/${configId}/sync`, syncRequest);
return response.data;
},
async getSyncStatus(tenantId: string, configId: string, limit?: number): Promise<SyncStatus> {
const response = await apiClient.get(`/pos-service/api/v1/tenants/${tenantId}/pos/configurations/${configId}/sync/status`, {
params: { limit }
});
return response.data;
},
async getSyncLogs(tenantId: string, configId: string, params?: {
limit?: number;
offset?: number;
status?: string;
sync_type?: string;
data_type?: string;
}): Promise<{ logs: POSSyncLog[]; total: number; has_more: boolean }> {
const response = await apiClient.get(`/pos-service/api/v1/tenants/${tenantId}/pos/configurations/${configId}/sync/logs`, {
params
});
return response.data;
},
// Transaction Management
async getTransactions(tenantId: string, params?: {
pos_system?: string;
start_date?: string;
end_date?: string;
status?: string;
is_synced?: boolean;
limit?: number;
offset?: number;
}): Promise<{
transactions: POSTransaction[];
total: number;
has_more: boolean;
summary: {
total_amount: number;
transaction_count: number;
sync_status: {
synced: number;
pending: number;
failed: number;
};
};
}> {
const response = await apiClient.get(`/pos-service/api/v1/tenants/${tenantId}/pos/transactions`, {
params
});
return response.data;
},
async syncSingleTransaction(tenantId: string, transactionId: string, force?: boolean): Promise<{
message: string;
transaction_id: string;
sync_status: string;
sales_record_id?: string;
}> {
const response = await apiClient.post(`/pos-service/api/v1/tenants/${tenantId}/pos/transactions/${transactionId}/sync`,
{}, { params: { force } }
);
return response.data;
},
async resyncFailedTransactions(tenantId: string, daysBack: number): Promise<{
message: string;
job_id: string;
scope: string;
estimated_transactions: number;
}> {
const response = await apiClient.post(`/pos-service/api/v1/tenants/${tenantId}/pos/data/resync`,
{}, { params: { days_back: daysBack } }
);
return response.data;
},
// Analytics
async getSyncAnalytics(tenantId: string, days: number = 30): Promise<POSAnalytics> {
const response = await apiClient.get(`/pos-service/api/v1/tenants/${tenantId}/pos/analytics/sync-performance`, {
params: { days }
});
return response.data;
},
// System Information
async getSupportedSystems(): Promise<{ systems: SupportedPOSSystem[] }> {
const response = await apiClient.get('/pos-service/api/v1/pos/supported-systems');
return response.data;
},
// Webhook Status
async getWebhookStatus(posSystem: string): Promise<{
pos_system: string;
status: string;
endpoint: string;
supported_events: {
events: string[];
format: string;
authentication: string;
};
last_received?: string;
total_received: number;
}> {
const response = await apiClient.get(`/pos-service/api/v1/webhooks/${posSystem}/status`);
return response.data;
}
};
export default posService;

View File

@@ -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<void>;
onTestConnection: (configId: string) => Promise<void>;
onToggleActive?: (configId: string, active: boolean) => Promise<void>;
onDelete?: (configId: string) => Promise<void>;
}
const POSConfigurationCard: React.FC<POSConfigurationCardProps> = ({
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 (
<Card className="p-6 hover:shadow-lg transition-shadow">
{/* Header */}
<div className="flex items-start justify-between mb-4">
<div className="flex items-center space-x-3">
<div className="text-2xl">
{getPOSSystemIcon(configuration.pos_system)}
</div>
<div>
<h3 className="font-semibold text-gray-900">
{configuration.provider_name}
</h3>
<p className="text-sm text-gray-600 capitalize">
{configuration.pos_system} {configuration.environment}
</p>
</div>
</div>
<div className="relative">
<button
onClick={() => setIsMenuOpen(!isMenuOpen)}
className="p-1 text-gray-400 hover:text-gray-600 transition-colors"
>
<MoreVertical className="h-5 w-5" />
</button>
{isMenuOpen && (
<div className="absolute right-0 top-8 w-48 bg-white rounded-md shadow-lg border z-10">
<div className="py-1">
<button
onClick={() => {
onEdit(configuration);
setIsMenuOpen(false);
}}
className="w-full px-4 py-2 text-left text-sm text-gray-700 hover:bg-gray-100 flex items-center space-x-2"
>
<Edit className="h-4 w-4" />
<span>Edit Configuration</span>
</button>
{onToggleActive && (
<button
onClick={() => {
handleToggleActive();
setIsMenuOpen(false);
}}
disabled={isToggling}
className="w-full px-4 py-2 text-left text-sm text-gray-700 hover:bg-gray-100 flex items-center space-x-2"
>
<Power className="h-4 w-4" />
<span>{configuration.is_active ? 'Deactivate' : 'Activate'}</span>
</button>
)}
{onDelete && (
<button
onClick={() => {
onDelete(configuration.id);
setIsMenuOpen(false);
}}
className="w-full px-4 py-2 text-left text-sm text-red-600 hover:bg-red-50 flex items-center space-x-2"
>
<Trash2 className="h-4 w-4" />
<span>Delete</span>
</button>
)}
</div>
</div>
)}
</div>
</div>
{/* Status Indicators */}
<div className="grid grid-cols-3 gap-4 mb-4">
<div className="text-center">
<div className={`flex items-center justify-center space-x-1 ${getStatusColor(configuration.health_status)}`}>
<StatusIcon className="h-4 w-4" />
<span className="text-sm font-medium capitalize">{configuration.health_status}</span>
</div>
<p className="text-xs text-gray-500">Health</p>
</div>
<div className="text-center">
<div className={`flex items-center justify-center space-x-1 ${getConnectionStatusColor(configuration.is_connected)}`}>
<Globe className="h-4 w-4" />
<span className="text-sm font-medium">
{configuration.is_connected ? 'Connected' : 'Disconnected'}
</span>
</div>
<p className="text-xs text-gray-500">Connection</p>
</div>
<div className="text-center">
<div className={`flex items-center justify-center space-x-1 ${getSyncStatusColor(configuration.last_sync_status)}`}>
<RefreshCw className="h-4 w-4" />
<span className="text-sm font-medium capitalize">
{configuration.last_sync_status || 'Unknown'}
</span>
</div>
<p className="text-xs text-gray-500">Last Sync</p>
</div>
</div>
{/* Configuration Details */}
<div className="space-y-2 mb-4">
{configuration.location_id && (
<div className="flex justify-between text-sm">
<span className="text-gray-600">Location ID:</span>
<span className="font-medium">{configuration.location_id}</span>
</div>
)}
<div className="flex justify-between text-sm">
<span className="text-gray-600">Sync Interval:</span>
<span className="font-medium">{configuration.sync_interval_minutes}m</span>
</div>
<div className="flex justify-between text-sm">
<span className="text-gray-600">Last Sync:</span>
<span className="font-medium">{formatLastSync(configuration.last_sync_at)}</span>
</div>
<div className="flex justify-between text-sm">
<span className="text-gray-600">Auto Sync:</span>
<div className="flex space-x-1">
{configuration.auto_sync_transactions && (
<span className="px-2 py-1 bg-blue-100 text-blue-800 text-xs rounded">Transactions</span>
)}
{configuration.auto_sync_products && (
<span className="px-2 py-1 bg-green-100 text-green-800 text-xs rounded">Products</span>
)}
</div>
</div>
</div>
{/* Status Badge */}
<div className="flex items-center justify-between mb-4">
<div className="flex space-x-2">
<span className={`px-2 py-1 text-xs rounded-full ${
configuration.is_active
? 'bg-green-100 text-green-800'
: 'bg-gray-100 text-gray-800'
}`}>
{configuration.is_active ? 'Active' : 'Inactive'}
</span>
{configuration.sync_enabled && (
<span className="px-2 py-1 bg-blue-100 text-blue-800 text-xs rounded-full">
Sync Enabled
</span>
)}
</div>
</div>
{/* Action Buttons */}
<div className="flex space-x-2">
<Button
variant="outline"
size="sm"
onClick={handleTestConnection}
disabled={isTesting}
className="flex-1 flex items-center justify-center space-x-1"
>
{isTesting ? (
<LoadingSpinner size="sm" />
) : (
<Globe className="h-4 w-4" />
)}
<span>Test</span>
</Button>
<Button
variant="outline"
size="sm"
onClick={handleSync}
disabled={isSyncing || !configuration.is_connected}
className="flex-1 flex items-center justify-center space-x-1"
>
{isSyncing ? (
<LoadingSpinner size="sm" />
) : (
<RefreshCw className="h-4 w-4" />
)}
<span>Sync</span>
</Button>
<Button
size="sm"
onClick={() => onEdit(configuration)}
className="flex-1 flex items-center justify-center space-x-1"
>
<Settings className="h-4 w-4" />
<span>Configure</span>
</Button>
</div>
{/* Click overlay to close menu */}
{isMenuOpen && (
<div
className="fixed inset-0 z-0"
onClick={() => setIsMenuOpen(false)}
/>
)}
</Card>
);
};
export default POSConfigurationCard;

View File

@@ -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<void>;
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<POSConfigurationFormProps> = ({
configuration,
isOpen,
isCreating = false,
onSubmit,
onClose
}) => {
const [formData, setFormData] = useState<FormData>({
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<Record<string, string>>({});
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<string, string> = {};
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 (
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50 p-4">
<div className="bg-white rounded-lg shadow-xl w-full max-w-4xl max-h-[90vh] overflow-y-auto">
{/* Header */}
<div className="flex items-center justify-between p-6 border-b">
<div className="flex items-center space-x-3">
<Zap className="h-6 w-6 text-blue-600" />
<h2 className="text-xl font-semibold text-gray-900">
{isCreating ? 'Add POS Integration' : 'Edit POS Configuration'}
</h2>
</div>
<button
onClick={onClose}
className="text-gray-400 hover:text-gray-600 transition-colors"
>
<X className="h-6 w-6" />
</button>
</div>
<form onSubmit={handleSubmit} className="p-6 space-y-8">
{/* POS System Selection */}
<div className="space-y-4">
<h3 className="text-lg font-medium text-gray-900 flex items-center">
<Settings className="h-5 w-5 mr-2" />
POS System Configuration
</h3>
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
{SUPPORTED_POS_SYSTEMS.map((system) => (
<label
key={system.id}
className={`relative cursor-pointer rounded-lg border p-4 hover:bg-gray-50 transition-colors ${
formData.pos_system === system.id
? 'border-blue-500 bg-blue-50'
: 'border-gray-300'
}`}
>
<input
type="radio"
value={system.id}
checked={formData.pos_system === system.id}
onChange={(e) => handlePOSSystemChange(e.target.value)}
className="sr-only"
/>
<div className="flex items-center space-x-3">
<span className="text-2xl">{system.logo}</span>
<div>
<p className="font-medium text-gray-900">{system.name}</p>
<p className="text-sm text-gray-500">{system.description}</p>
</div>
</div>
{formData.pos_system === system.id && (
<CheckCircle className="absolute top-2 right-2 h-5 w-5 text-blue-600" />
)}
</label>
))}
</div>
{errors.pos_system && (
<p className="text-red-600 text-sm">{errors.pos_system}</p>
)}
</div>
{/* Basic Configuration */}
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
Provider Name *
</label>
<input
type="text"
value={formData.provider_name}
onChange={(e) => 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 && (
<p className="text-red-600 text-sm mt-1">{errors.provider_name}</p>
)}
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
Environment
</label>
<select
value={formData.environment}
onChange={(e) => handleInputChange('environment', 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"
>
<option value="sandbox">Sandbox (Testing)</option>
<option value="production">Production (Live)</option>
</select>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
Location ID
</label>
<input
type="text"
value={formData.location_id || ''}
onChange={(e) => 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"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
Merchant ID
</label>
<input
type="text"
value={formData.merchant_id || ''}
onChange={(e) => 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"
/>
</div>
</div>
{/* API Credentials */}
{selectedPOSSystem && (
<div className="space-y-4">
<h3 className="text-lg font-medium text-gray-900 flex items-center">
<Key className="h-5 w-5 mr-2" />
API Credentials
</h3>
<div className="bg-yellow-50 border border-yellow-200 rounded-md p-4">
<div className="flex">
<AlertTriangle className="h-5 w-5 text-yellow-400 mr-2" />
<p className="text-sm text-yellow-800">
Credentials are encrypted and stored securely. Never share your API keys.
</p>
</div>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
{selectedPOSSystem.fields.includes('application_id') && (
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
Application ID *
</label>
<input
type="text"
value={formData.application_id || ''}
onChange={(e) => 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 && (
<p className="text-red-600 text-sm mt-1">{errors.application_id}</p>
)}
</div>
)}
{selectedPOSSystem.fields.includes('access_token') && (
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
Access Token *
</label>
<input
type="password"
value={formData.access_token || ''}
onChange={(e) => 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 && (
<p className="text-red-600 text-sm mt-1">{errors.access_token}</p>
)}
</div>
)}
{selectedPOSSystem.fields.includes('api_key') && (
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
API Key *
</label>
<input
type="password"
value={formData.api_key || ''}
onChange={(e) => 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 && (
<p className="text-red-600 text-sm mt-1">{errors.api_key}</p>
)}
</div>
)}
{selectedPOSSystem.fields.includes('api_secret') && (
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
API Secret
</label>
<input
type="password"
value={formData.api_secret || ''}
onChange={(e) => 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"
/>
</div>
)}
{selectedPOSSystem.fields.includes('webhook_secret') && (
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
Webhook Secret
</label>
<input
type="password"
value={formData.webhook_secret || ''}
onChange={(e) => 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"
/>
</div>
)}
</div>
{/* Test Connection */}
<div className="flex items-center space-x-4">
<Button
type="button"
variant="outline"
onClick={handleTestConnection}
disabled={testingConnection}
className="flex items-center space-x-2"
>
{testingConnection ? (
<LoadingSpinner size="sm" />
) : (
<Globe className="h-4 w-4" />
)}
<span>Test Connection</span>
</Button>
{connectionStatus === 'success' && (
<div className="flex items-center text-green-600">
<CheckCircle className="h-5 w-5 mr-2" />
<span className="text-sm">Connection successful</span>
</div>
)}
{connectionStatus === 'error' && (
<div className="flex items-center text-red-600">
<X className="h-5 w-5 mr-2" />
<span className="text-sm">Connection failed</span>
</div>
)}
</div>
</div>
)}
{/* Sync Configuration */}
<div className="space-y-4">
<h3 className="text-lg font-medium text-gray-900 flex items-center">
<Sync className="h-5 w-5 mr-2" />
Synchronization Settings
</h3>
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
Sync Interval (minutes)
</label>
<input
type="number"
min="1"
max="1440"
value={formData.sync_interval_minutes}
onChange={(e) => 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 && (
<p className="text-red-600 text-sm mt-1">{errors.sync_interval_minutes}</p>
)}
</div>
<div className="space-y-4">
<label className="flex items-center space-x-2">
<input
type="checkbox"
checked={formData.sync_enabled}
onChange={(e) => handleInputChange('sync_enabled', e.target.checked)}
className="rounded border-gray-300 text-blue-600 focus:ring-blue-500"
/>
<span className="text-sm font-medium text-gray-700">Enable automatic sync</span>
</label>
<label className="flex items-center space-x-2">
<input
type="checkbox"
checked={formData.auto_sync_transactions}
onChange={(e) => handleInputChange('auto_sync_transactions', e.target.checked)}
className="rounded border-gray-300 text-blue-600 focus:ring-blue-500"
/>
<span className="text-sm font-medium text-gray-700">Auto-sync transactions</span>
</label>
<label className="flex items-center space-x-2">
<input
type="checkbox"
checked={formData.auto_sync_products}
onChange={(e) => handleInputChange('auto_sync_products', e.target.checked)}
className="rounded border-gray-300 text-blue-600 focus:ring-blue-500"
/>
<span className="text-sm font-medium text-gray-700">Auto-sync products</span>
</label>
<label className="flex items-center space-x-2">
<input
type="checkbox"
checked={formData.is_active}
onChange={(e) => handleInputChange('is_active', e.target.checked)}
className="rounded border-gray-300 text-blue-600 focus:ring-blue-500"
/>
<span className="text-sm font-medium text-gray-700">Configuration active</span>
</label>
</div>
</div>
</div>
{/* Notes */}
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
Notes
</label>
<textarea
value={formData.notes || ''}
onChange={(e) => handleInputChange('notes', e.target.value)}
rows={3}
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 notes about this POS configuration..."
/>
</div>
{/* Form Actions */}
<div className="flex items-center justify-end space-x-4 pt-6 border-t">
<Button
type="button"
variant="outline"
onClick={onClose}
disabled={isLoading}
>
Cancel
</Button>
<Button
type="submit"
disabled={isLoading}
className="flex items-center space-x-2"
>
{isLoading ? (
<LoadingSpinner size="sm" />
) : (
<Database className="h-4 w-4" />
)}
<span>{isCreating ? 'Create Configuration' : 'Update Configuration'}</span>
</Button>
</div>
</form>
</div>
</div>
);
};
export default POSConfigurationForm;

View File

@@ -0,0 +1,394 @@
import React, { useState, useEffect } from 'react';
import {
Plus,
Settings,
Activity,
Zap,
RefreshCw,
AlertTriangle,
CheckCircle,
Clock,
Globe,
Database,
BarChart3,
Filter,
Search
} from 'lucide-react';
import Button from '../ui/Button';
import Card from '../ui/Card';
import LoadingSpinner from '../ui/LoadingSpinner';
import POSConfigurationForm from './POSConfigurationForm';
import POSConfigurationCard from './POSConfigurationCard';
import POSSyncStatus from './POSSyncStatus';
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 SyncSummary {
total_configurations: number;
active_configurations: number;
connected_configurations: number;
sync_enabled_configurations: number;
last_24h_syncs: number;
failed_syncs_24h: number;
total_transactions_today: number;
total_revenue_today: number;
}
const POSManagementPage: React.FC = () => {
const [configurations, setConfigurations] = useState<POSConfiguration[]>([]);
const [syncSummary, setSyncSummary] = useState<SyncSummary | null>(null);
const [isLoading, setIsLoading] = useState(true);
const [isFormOpen, setIsFormOpen] = useState(false);
const [selectedConfig, setSelectedConfig] = useState<POSConfiguration | null>(null);
const [searchTerm, setSearchTerm] = useState('');
const [filterStatus, setFilterStatus] = useState<'all' | 'active' | 'inactive' | 'connected' | 'disconnected'>('all');
const [filterPOSSystem, setFilterPOSSystem] = useState<'all' | 'square' | 'toast' | 'lightspeed'>('all');
useEffect(() => {
loadConfigurations();
loadSyncSummary();
}, []);
const loadConfigurations = async () => {
try {
setIsLoading(true);
// TODO: Replace with actual API call
await new Promise(resolve => setTimeout(resolve, 1000));
// Mock data
const mockConfigurations: POSConfiguration[] = [
{
id: '1',
pos_system: 'square',
provider_name: 'Main Store Square POS',
is_active: true,
is_connected: true,
environment: 'production',
location_id: 'L123456789',
sync_enabled: true,
sync_interval_minutes: '5',
auto_sync_products: true,
auto_sync_transactions: true,
last_sync_at: '2024-01-15T10:30:00Z',
last_successful_sync_at: '2024-01-15T10:30:00Z',
last_sync_status: 'success',
health_status: 'healthy',
created_at: '2024-01-01T00:00:00Z',
updated_at: '2024-01-15T10:30:00Z'
},
{
id: '2',
pos_system: 'toast',
provider_name: 'Bakery Toast System',
is_active: true,
is_connected: false,
environment: 'sandbox',
sync_enabled: false,
sync_interval_minutes: '10',
auto_sync_products: true,
auto_sync_transactions: true,
last_sync_status: 'failed',
health_status: 'unhealthy',
created_at: '2024-01-10T00:00:00Z',
updated_at: '2024-01-15T09:00:00Z'
}
];
setConfigurations(mockConfigurations);
} catch (error) {
console.error('Error loading configurations:', error);
} finally {
setIsLoading(false);
}
};
const loadSyncSummary = async () => {
try {
// TODO: Replace with actual API call
const mockSummary: SyncSummary = {
total_configurations: 2,
active_configurations: 2,
connected_configurations: 1,
sync_enabled_configurations: 1,
last_24h_syncs: 45,
failed_syncs_24h: 2,
total_transactions_today: 156,
total_revenue_today: 2847.50
};
setSyncSummary(mockSummary);
} catch (error) {
console.error('Error loading sync summary:', error);
}
};
const handleCreateConfiguration = () => {
setSelectedConfig(null);
setIsFormOpen(true);
};
const handleEditConfiguration = (config: POSConfiguration) => {
setSelectedConfig(config);
setIsFormOpen(true);
};
const handleFormSubmit = async (data: POSConfiguration) => {
try {
// TODO: Replace with actual API call
console.log('Submitting configuration:', data);
await new Promise(resolve => setTimeout(resolve, 1000));
setIsFormOpen(false);
loadConfigurations();
loadSyncSummary();
} catch (error) {
console.error('Error submitting configuration:', error);
throw error;
}
};
const handleTriggerSync = async (configId: string) => {
try {
// TODO: Replace with actual API call
console.log('Triggering sync for configuration:', configId);
await new Promise(resolve => setTimeout(resolve, 1000));
loadConfigurations();
} catch (error) {
console.error('Error triggering sync:', error);
}
};
const handleTestConnection = async (configId: string) => {
try {
// TODO: Replace with actual API call
console.log('Testing connection for configuration:', configId);
await new Promise(resolve => setTimeout(resolve, 2000));
loadConfigurations();
} catch (error) {
console.error('Error testing connection:', error);
}
};
const filteredConfigurations = configurations.filter(config => {
const matchesSearch = config.provider_name.toLowerCase().includes(searchTerm.toLowerCase()) ||
config.pos_system.toLowerCase().includes(searchTerm.toLowerCase());
const matchesStatus = filterStatus === 'all' ||
(filterStatus === 'active' && config.is_active) ||
(filterStatus === 'inactive' && !config.is_active) ||
(filterStatus === 'connected' && config.is_connected) ||
(filterStatus === 'disconnected' && !config.is_connected);
const matchesPOSSystem = filterPOSSystem === 'all' || config.pos_system === filterPOSSystem;
return matchesSearch && matchesStatus && matchesPOSSystem;
});
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;
}
};
if (isLoading && configurations.length === 0) {
return (
<div className="flex items-center justify-center h-64">
<LoadingSpinner size="lg" />
</div>
);
}
return (
<div className="space-y-6">
{/* Header */}
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold text-gray-900">POS Integrations</h1>
<p className="text-gray-600">Manage your Point of Sale system integrations</p>
</div>
<Button
onClick={handleCreateConfiguration}
className="flex items-center space-x-2"
>
<Plus className="h-4 w-4" />
<span>Add POS Integration</span>
</Button>
</div>
{/* Summary Cards */}
{syncSummary && (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6">
<Card className="p-6">
<div className="flex items-center justify-between">
<div>
<p className="text-sm font-medium text-gray-600">Total Integrations</p>
<p className="text-2xl font-bold text-gray-900">{syncSummary.total_configurations}</p>
</div>
<Settings className="h-8 w-8 text-blue-600" />
</div>
<div className="mt-2 text-sm text-gray-600">
{syncSummary.active_configurations} active, {syncSummary.connected_configurations} connected
</div>
</Card>
<Card className="p-6">
<div className="flex items-center justify-between">
<div>
<p className="text-sm font-medium text-gray-600">Today's Transactions</p>
<p className="text-2xl font-bold text-gray-900">{syncSummary.total_transactions_today}</p>
</div>
<BarChart3 className="h-8 w-8 text-green-600" />
</div>
<div className="mt-2 text-sm text-gray-600">
{syncSummary.total_revenue_today.toLocaleString()} revenue
</div>
</Card>
<Card className="p-6">
<div className="flex items-center justify-between">
<div>
<p className="text-sm font-medium text-gray-600">24h Syncs</p>
<p className="text-2xl font-bold text-gray-900">{syncSummary.last_24h_syncs}</p>
</div>
<RefreshCw className="h-8 w-8 text-purple-600" />
</div>
<div className="mt-2 text-sm text-gray-600">
{syncSummary.failed_syncs_24h} failed
</div>
</Card>
<Card className="p-6">
<div className="flex items-center justify-between">
<div>
<p className="text-sm font-medium text-gray-600">System Health</p>
<p className="text-2xl font-bold text-gray-900">
{Math.round((syncSummary.connected_configurations / syncSummary.total_configurations) * 100)}%
</p>
</div>
<Activity className="h-8 w-8 text-orange-600" />
</div>
<div className="mt-2 text-sm text-gray-600">
Overall connection rate
</div>
</Card>
</div>
)}
{/* Filters */}
<div className="flex flex-col sm:flex-row gap-4 items-start sm:items-center">
<div className="relative flex-1 max-w-md">
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 h-4 w-4 text-gray-400" />
<input
type="text"
placeholder="Search configurations..."
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
className="pl-10 pr-4 py-2 w-full border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
/>
</div>
<div className="flex gap-2">
<select
value={filterStatus}
onChange={(e) => setFilterStatus(e.target.value as any)}
className="px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
>
<option value="all">All Status</option>
<option value="active">Active</option>
<option value="inactive">Inactive</option>
<option value="connected">Connected</option>
<option value="disconnected">Disconnected</option>
</select>
<select
value={filterPOSSystem}
onChange={(e) => setFilterPOSSystem(e.target.value as any)}
className="px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
>
<option value="all">All Systems</option>
<option value="square">Square</option>
<option value="toast">Toast</option>
<option value="lightspeed">Lightspeed</option>
</select>
</div>
</div>
{/* Configurations Grid */}
{filteredConfigurations.length === 0 ? (
<Card className="p-12 text-center">
<Zap className="h-12 w-12 text-gray-400 mx-auto mb-4" />
<h3 className="text-lg font-medium text-gray-900 mb-2">No POS integrations found</h3>
<p className="text-gray-600 mb-4">
{configurations.length === 0
? "Get started by adding your first POS integration."
: "Try adjusting your search or filter criteria."
}
</p>
{configurations.length === 0 && (
<Button onClick={handleCreateConfiguration}>
Add Your First Integration
</Button>
)}
</Card>
) : (
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
{filteredConfigurations.map((config) => (
<POSConfigurationCard
key={config.id}
configuration={config}
onEdit={handleEditConfiguration}
onTriggerSync={handleTriggerSync}
onTestConnection={handleTestConnection}
/>
))}
</div>
)}
{/* Sync Status Panel */}
<POSSyncStatus configurations={configurations} />
{/* Configuration Form Modal */}
<POSConfigurationForm
configuration={selectedConfig}
isOpen={isFormOpen}
isCreating={!selectedConfig}
onSubmit={handleFormSubmit}
onClose={() => setIsFormOpen(false)}
/>
</div>
);
};
export default POSManagementPage;

View File

@@ -0,0 +1,341 @@
import React, { useState, useEffect } from 'react';
import {
RefreshCw,
Clock,
CheckCircle,
AlertTriangle,
BarChart3,
TrendingUp,
Activity,
Database
} from 'lucide-react';
import Card from '../ui/Card';
import LoadingSpinner from '../ui/LoadingSpinner';
interface POSConfiguration {
id: string;
pos_system: string;
provider_name: string;
last_sync_at?: string;
last_sync_status?: string;
sync_enabled: boolean;
is_connected: boolean;
}
interface SyncLogEntry {
id: string;
config_id: string;
sync_type: string;
status: string;
started_at: string;
completed_at?: string;
records_processed: number;
records_created: number;
records_updated: number;
records_failed: number;
error_message?: string;
}
interface POSSyncStatusProps {
configurations: POSConfiguration[];
}
const POSSyncStatus: React.FC<POSSyncStatusProps> = ({ configurations }) => {
const [syncLogs, setSyncLogs] = useState<SyncLogEntry[]>([]);
const [isLoading, setIsLoading] = useState(true);
const [selectedTimeframe, setSelectedTimeframe] = useState<'24h' | '7d' | '30d'>('24h');
useEffect(() => {
loadSyncLogs();
}, [selectedTimeframe]);
const loadSyncLogs = async () => {
try {
setIsLoading(true);
// TODO: Replace with actual API call
await new Promise(resolve => setTimeout(resolve, 1000));
// Mock data
const mockLogs: SyncLogEntry[] = [
{
id: '1',
config_id: '1',
sync_type: 'incremental',
status: 'completed',
started_at: '2024-01-15T10:30:00Z',
completed_at: '2024-01-15T10:32:15Z',
records_processed: 45,
records_created: 38,
records_updated: 7,
records_failed: 0
},
{
id: '2',
config_id: '1',
sync_type: 'incremental',
status: 'completed',
started_at: '2024-01-15T10:25:00Z',
completed_at: '2024-01-15T10:26:30Z',
records_processed: 23,
records_created: 20,
records_updated: 3,
records_failed: 0
},
{
id: '3',
config_id: '2',
sync_type: 'manual',
status: 'failed',
started_at: '2024-01-15T09:15:00Z',
records_processed: 0,
records_created: 0,
records_updated: 0,
records_failed: 0,
error_message: 'Authentication failed - invalid API key'
}
];
setSyncLogs(mockLogs);
} catch (error) {
console.error('Error loading sync logs:', error);
} finally {
setIsLoading(false);
}
};
const getStatusColor = (status: string) => {
switch (status) {
case 'completed': return 'text-green-600';
case 'failed': return 'text-red-600';
case 'in_progress': return 'text-blue-600';
case 'cancelled': return 'text-gray-600';
default: return 'text-gray-600';
}
};
const getStatusIcon = (status: string) => {
switch (status) {
case 'completed': return CheckCircle;
case 'failed': return AlertTriangle;
case 'in_progress': return RefreshCw;
default: return Clock;
}
};
const formatDuration = (startTime: string, endTime?: string) => {
if (!endTime) return 'In progress...';
const start = new Date(startTime);
const end = new Date(endTime);
const diffMs = end.getTime() - start.getTime();
const diffSecs = Math.floor(diffMs / 1000);
if (diffSecs < 60) return `${diffSecs}s`;
const diffMins = Math.floor(diffSecs / 60);
const remainingSecs = diffSecs % 60;
return `${diffMins}m ${remainingSecs}s`;
};
const formatTime = (timestamp: string) => {
const date = new Date(timestamp);
return date.toLocaleTimeString('en-US', {
hour: '2-digit',
minute: '2-digit',
hour12: false
});
};
const getConfigurationName = (configId: string) => {
const config = configurations.find(c => c.id === configId);
return config?.provider_name || 'Unknown Configuration';
};
const calculateSyncStats = () => {
const stats = {
total: syncLogs.length,
completed: syncLogs.filter(log => log.status === 'completed').length,
failed: syncLogs.filter(log => log.status === 'failed').length,
totalRecords: syncLogs.reduce((sum, log) => sum + log.records_processed, 0),
totalCreated: syncLogs.reduce((sum, log) => sum + log.records_created, 0),
totalUpdated: syncLogs.reduce((sum, log) => sum + log.records_updated, 0),
avgDuration: 0
};
const completedLogs = syncLogs.filter(log => log.status === 'completed' && log.completed_at);
if (completedLogs.length > 0) {
const totalDuration = completedLogs.reduce((sum, log) => {
const start = new Date(log.started_at);
const end = new Date(log.completed_at!);
return sum + (end.getTime() - start.getTime());
}, 0);
stats.avgDuration = Math.floor(totalDuration / completedLogs.length / 1000);
}
return stats;
};
const stats = calculateSyncStats();
const successRate = stats.total > 0 ? Math.round((stats.completed / stats.total) * 100) : 0;
if (isLoading) {
return (
<Card className="p-6">
<div className="flex items-center justify-center h-32">
<LoadingSpinner size="lg" />
</div>
</Card>
);
}
return (
<div className="space-y-6">
{/* Sync Statistics */}
<Card className="p-6">
<div className="flex items-center justify-between mb-6">
<h3 className="text-lg font-semibold text-gray-900 flex items-center">
<BarChart3 className="h-5 w-5 mr-2" />
Sync Performance
</h3>
<div className="flex space-x-2">
{(['24h', '7d', '30d'] as const).map((timeframe) => (
<button
key={timeframe}
onClick={() => setSelectedTimeframe(timeframe)}
className={`px-3 py-1 text-sm rounded-md transition-colors ${
selectedTimeframe === timeframe
? 'bg-blue-100 text-blue-700'
: 'text-gray-600 hover:text-gray-900'
}`}
>
{timeframe}
</button>
))}
</div>
</div>
<div className="grid grid-cols-2 md:grid-cols-4 gap-6">
<div className="text-center">
<div className="text-2xl font-bold text-gray-900">{stats.total}</div>
<div className="text-sm text-gray-600">Total Syncs</div>
</div>
<div className="text-center">
<div className="text-2xl font-bold text-green-600">{successRate}%</div>
<div className="text-sm text-gray-600">Success Rate</div>
</div>
<div className="text-center">
<div className="text-2xl font-bold text-blue-600">{stats.totalRecords}</div>
<div className="text-sm text-gray-600">Records Synced</div>
</div>
<div className="text-center">
<div className="text-2xl font-bold text-purple-600">{stats.avgDuration}s</div>
<div className="text-sm text-gray-600">Avg Duration</div>
</div>
</div>
</Card>
{/* Recent Sync Logs */}
<Card className="p-6">
<div className="flex items-center justify-between mb-6">
<h3 className="text-lg font-semibold text-gray-900 flex items-center">
<Activity className="h-5 w-5 mr-2" />
Recent Sync Activity
</h3>
<button
onClick={loadSyncLogs}
className="flex items-center space-x-1 text-blue-600 hover:text-blue-700 transition-colors"
>
<RefreshCw className="h-4 w-4" />
<span className="text-sm">Refresh</span>
</button>
</div>
{syncLogs.length === 0 ? (
<div className="text-center py-8 text-gray-500">
<Database className="h-12 w-12 mx-auto mb-4 text-gray-400" />
<p>No sync activity found for the selected timeframe.</p>
</div>
) : (
<div className="space-y-4">
{syncLogs.slice(0, 10).map((log) => {
const StatusIcon = getStatusIcon(log.status);
return (
<div
key={log.id}
className="flex items-center justify-between p-4 bg-gray-50 rounded-lg"
>
<div className="flex items-center space-x-4">
<div className={`${getStatusColor(log.status)}`}>
<StatusIcon className="h-5 w-5" />
</div>
<div>
<div className="font-medium text-gray-900">
{getConfigurationName(log.config_id)}
</div>
<div className="text-sm text-gray-600">
{log.sync_type} sync Started at {formatTime(log.started_at)}
</div>
</div>
</div>
<div className="flex items-center space-x-6 text-sm">
{log.status === 'completed' && (
<>
<div className="text-center">
<div className="font-medium text-gray-900">{log.records_processed}</div>
<div className="text-gray-600">Processed</div>
</div>
<div className="text-center">
<div className="font-medium text-green-600">{log.records_created}</div>
<div className="text-gray-600">Created</div>
</div>
<div className="text-center">
<div className="font-medium text-blue-600">{log.records_updated}</div>
<div className="text-gray-600">Updated</div>
</div>
<div className="text-center">
<div className="font-medium text-gray-900">
{formatDuration(log.started_at, log.completed_at)}
</div>
<div className="text-gray-600">Duration</div>
</div>
</>
)}
{log.status === 'failed' && log.error_message && (
<div className="max-w-xs">
<div className="font-medium text-red-600">Failed</div>
<div className="text-gray-600 text-xs truncate">
{log.error_message}
</div>
</div>
)}
{log.status === 'in_progress' && (
<div className="text-center">
<LoadingSpinner size="sm" />
<div className="text-gray-600">Running</div>
</div>
)}
</div>
</div>
);
})}
</div>
)}
</Card>
</div>
);
};
export default POSSyncStatus;

View File

@@ -0,0 +1,4 @@
export { default as POSConfigurationForm } from './POSConfigurationForm';
export { default as POSConfigurationCard } from './POSConfigurationCard';
export { default as POSManagementPage } from './POSManagementPage';
export { default as POSSyncStatus } from './POSSyncStatus';