Add POS service
This commit is contained in:
356
frontend/src/components/pos/POSConfigurationCard.tsx
Normal file
356
frontend/src/components/pos/POSConfigurationCard.tsx
Normal 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;
|
||||
595
frontend/src/components/pos/POSConfigurationForm.tsx
Normal file
595
frontend/src/components/pos/POSConfigurationForm.tsx
Normal 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;
|
||||
394
frontend/src/components/pos/POSManagementPage.tsx
Normal file
394
frontend/src/components/pos/POSManagementPage.tsx
Normal 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;
|
||||
341
frontend/src/components/pos/POSSyncStatus.tsx
Normal file
341
frontend/src/components/pos/POSSyncStatus.tsx
Normal 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;
|
||||
4
frontend/src/components/pos/index.ts
Normal file
4
frontend/src/components/pos/index.ts
Normal 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';
|
||||
Reference in New Issue
Block a user