Reorganize teh menus

This commit is contained in:
Urtzi Alfaro
2025-09-24 22:22:01 +02:00
parent dc6c6f213f
commit 6d4090f825
9 changed files with 2251 additions and 128 deletions

View File

@@ -1,12 +1,16 @@
import React, { useState, useMemo } from 'react';
import { Plus, Minus, ShoppingCart, CreditCard, Banknote, Calculator, User, Receipt, Package, Euro, TrendingUp, Clock } from 'lucide-react';
import { Button, Input, Card, Badge, StatsGrid, StatusCard, getStatusColor } from '../../../../components/ui';
import { Plus, Minus, ShoppingCart, CreditCard, Banknote, Calculator, User, Receipt, Package, Euro, TrendingUp, Clock, ToggleLeft, ToggleRight, Settings, Zap, Wifi, WifiOff, AlertCircle, CheckCircle, Loader, Eye, EyeOff, Info, Trash2 } from 'lucide-react';
import { Button, Input, Card, Badge, StatsGrid, StatusCard, getStatusColor, Tabs, Modal, Select } from '../../../../components/ui';
import { PageHeader } from '../../../../components/layout';
import { LoadingSpinner } from '../../../../components/shared';
import { formatters } from '../../../../components/ui/Stats/StatsPresets';
import { useIngredients } from '../../../../api/hooks/inventory';
import { useTenantId } from '../../../../hooks/useTenantId';
import { ProductType, ProductCategory, IngredientResponse } from '../../../../api/types/inventory';
import { useToast } from '../../../../hooks/ui/useToast';
import { usePOSConfigurationData, usePOSConfigurationManager } from '../../../../api/hooks/pos';
import { POSConfiguration, POSProviderConfig } from '../../../../api/types/pos';
import { posService } from '../../../../api/services/pos';
interface CartItem {
id: string;
@@ -27,8 +31,23 @@ const POSPage: React.FC = () => {
});
const [paymentMethod, setPaymentMethod] = useState<'cash' | 'card' | 'transfer'>('cash');
const [cashReceived, setCashReceived] = useState('');
const [posMode, setPosMode] = useState<'manual' | 'automatic'>('manual');
const [showPOSConfig, setShowPOSConfig] = useState(false);
// POS Configuration State
const [showAddPosModal, setShowAddPosModal] = useState(false);
const [showEditPosModal, setShowEditPosModal] = useState(false);
const [selectedPosConfig, setSelectedPosConfig] = useState<POSConfiguration | null>(null);
const [posFormData, setPosFormData] = useState<any>({});
const [testingConnection, setTestingConnection] = useState<string | null>(null);
const [showCredentials, setShowCredentials] = useState<Record<string, boolean>>({});
const tenantId = useTenantId();
const { addToast } = useToast();
// POS Configuration hooks
const posData = usePOSConfigurationData(tenantId);
const posManager = usePOSConfigurationManager(tenantId);
// Fetch finished products from API
const {
@@ -73,12 +92,185 @@ const POSPage: React.FC = () => {
return Array.from(categoryMap.values());
}, [products]);
// POS Providers Configuration
const supportedProviders: POSProviderConfig[] = [
{
id: 'toast',
name: 'Toast POS',
logo: '🍞',
description: 'Sistema POS líder para restaurantes y panaderías. Muy popular en España.',
features: ['Gestión de pedidos', 'Sincronización de inventario', 'Pagos integrados', 'Reportes en tiempo real'],
required_fields: [
{ field: 'api_key', label: 'API Key', type: 'password', required: true, help_text: 'Obten tu API key desde Toast Dashboard > Settings > Integrations' },
{ field: 'restaurant_guid', label: 'Restaurant GUID', type: 'text', required: true, help_text: 'ID único del restaurante en Toast' },
{ field: 'location_id', label: 'Location ID', type: 'text', required: true, help_text: 'ID de la ubicación específica' },
{ field: 'environment', label: 'Entorno', type: 'select', required: true, options: [
{ value: 'sandbox', label: 'Sandbox (Pruebas)' },
{ value: 'production', label: 'Producción' }
]},
],
},
{
id: 'square',
name: 'Square POS',
logo: '⬜',
description: 'Solución POS completa con tarifas transparentes. Ampliamente utilizada por pequeñas empresas.',
features: ['Procesamiento de pagos', 'Gestión de inventario', 'Análisis de ventas', 'Integración con e-commerce'],
required_fields: [
{ field: 'application_id', label: 'Application ID', type: 'text', required: true, help_text: 'ID de aplicación de Square Developer Dashboard' },
{ field: 'access_token', label: 'Access Token', type: 'password', required: true, help_text: 'Token de acceso para la API de Square' },
{ field: 'location_id', label: 'Location ID', type: 'text', required: true, help_text: 'ID de la ubicación de Square' },
{ field: 'webhook_signature_key', label: 'Webhook Signature Key', type: 'password', required: false, help_text: 'Clave para verificar webhooks (opcional)' },
{ field: 'environment', label: 'Entorno', type: 'select', required: true, options: [
{ value: 'sandbox', label: 'Sandbox (Pruebas)' },
{ value: 'production', label: 'Producción' }
]},
],
},
{
id: 'lightspeed',
name: 'Lightspeed POS',
logo: '⚡',
description: 'Sistema POS empresarial con API abierta e integración con múltiples herramientas.',
features: ['API REST completa', 'Gestión multi-ubicación', 'Reportes avanzados', 'Integración con contabilidad'],
required_fields: [
{ field: 'api_key', label: 'API Key', type: 'password', required: true, help_text: 'Clave API de Lightspeed Retail' },
{ field: 'api_secret', label: 'API Secret', type: 'password', required: true, help_text: 'Secreto API de Lightspeed Retail' },
{ field: 'account_id', label: 'Account ID', type: 'text', required: true, help_text: 'ID de cuenta de Lightspeed' },
{ field: 'shop_id', label: 'Shop ID', type: 'text', required: true, help_text: 'ID de la tienda específica' },
{ field: 'server_region', label: 'Región del Servidor', type: 'select', required: true, options: [
{ value: 'eu', label: 'Europa' },
{ value: 'us', label: 'Estados Unidos' },
{ value: 'ca', label: 'Canadá' }
]},
],
},
];
const filteredProducts = useMemo(() => {
return products.filter(product =>
selectedCategory === 'all' || product.category === selectedCategory
);
}, [products, selectedCategory]);
// Load POS configurations function for refetching after updates
const loadPosConfigurations = () => {
// This will trigger a refetch of POS configurations
// Note: posManager may not have refetch method available
console.log('POS configurations updated, consider implementing refetch if needed');
};
// POS Configuration Handlers
const handleAddPosConfiguration = () => {
setPosFormData({
provider: '',
config_name: '',
credentials: {},
sync_settings: {
auto_sync_enabled: true,
sync_interval_minutes: 5,
sync_sales: true,
sync_inventory: true,
sync_customers: true,
},
});
setShowAddPosModal(true);
};
const handleEditPosConfiguration = (config: POSConfiguration) => {
setSelectedPosConfig(config);
setPosFormData({
provider: (config as any).provider || (config as any).pos_system || '',
config_name: (config as any).config_name || (config as any).provider_name || '',
credentials: (config as any).credentials || {},
sync_settings: (config as any).sync_settings || {
auto_sync_enabled: true,
sync_interval_minutes: 5,
sync_sales: true,
sync_inventory: true,
sync_customers: true,
},
});
setShowEditPosModal(true);
};
const handleSavePosConfiguration = async () => {
try {
const provider = supportedProviders.find(p => p.id === posFormData.provider);
if (!provider) return;
// Validate required fields
const missingFields = provider.required_fields
.filter(field => field.required && !posFormData.credentials[field.field])
.map(field => field.label);
if (missingFields.length > 0) {
addToast(`Campos requeridos: ${missingFields.join(', ')}`, { type: 'error' });
return;
}
if (selectedPosConfig) {
// Update existing
await posService.updatePOSConfiguration({
tenant_id: tenantId,
config_id: selectedPosConfig.id,
...posFormData,
});
addToast('Configuración actualizada correctamente', { type: 'success' });
setShowEditPosModal(false);
loadPosConfigurations();
} else {
// Create new
await posService.createPOSConfiguration({
tenant_id: tenantId,
...posFormData,
});
addToast('Configuración creada correctamente', { type: 'success' });
setShowAddPosModal(false);
loadPosConfigurations();
}
} catch (error) {
addToast('Error al guardar la configuración', { type: 'error' });
}
};
const handleTestPosConnection = async (configId: string) => {
try {
setTestingConnection(configId);
const response = await posService.testPOSConnection({
tenant_id: tenantId,
config_id: configId,
});
if (response.success) {
addToast('Conexión exitosa', { type: 'success' });
} else {
addToast(`Error en la conexión: ${response.message || 'Error desconocido'}`, { type: 'error' });
}
} catch (error) {
addToast('Error al probar la conexión', { type: 'error' });
} finally {
setTestingConnection(null);
}
};
const handleDeletePosConfiguration = async (configId: string) => {
if (!window.confirm('¿Estás seguro de que deseas eliminar esta configuración?')) {
return;
}
try {
await posService.deletePOSConfiguration({
tenant_id: tenantId,
config_id: configId,
});
addToast('Configuración eliminada correctamente', { type: 'success' });
loadPosConfigurations();
} catch (error) {
addToast('Error al eliminar la configuración', { type: 'error' });
}
};
const addToCart = (product: typeof products[0]) => {
setCart(prevCart => {
const existingItem = prevCart.find(item => item.id === product.id);
@@ -237,6 +429,173 @@ const POSPage: React.FC = () => {
);
}
// POS Configuration Form Renderer
const renderPosConfigurationForm = () => {
const selectedProvider = supportedProviders.find(p => p.id === posFormData.provider);
return (
<div className="space-y-6">
<div>
<label className="block text-sm font-medium mb-2">Sistema POS</label>
<Select
value={posFormData.provider}
onChange={(value) => setPosFormData((prev: any) => ({ ...prev, provider: value as string, credentials: {} }))}
placeholder="Selecciona un sistema POS"
options={supportedProviders.map(provider => ({
value: provider.id,
label: `${provider.logo} ${provider.name}`,
description: provider.description
}))}
/>
</div>
{selectedProvider && (
<>
<div className="p-4 bg-blue-50 rounded-lg">
<h4 className="font-medium text-blue-900 mb-2">{selectedProvider.name}</h4>
<p className="text-sm text-blue-700 mb-3">{selectedProvider.description}</p>
<div className="flex flex-wrap gap-2">
{selectedProvider.features.map((feature, idx) => (
<Badge key={idx} variant="secondary" className="text-xs">
{feature}
</Badge>
))}
</div>
</div>
<div>
<label className="block text-sm font-medium mb-2">Nombre de la Configuración</label>
<Input
value={posFormData.config_name}
onChange={(e) => setPosFormData((prev: any) => ({ ...prev, config_name: e.target.value }))}
placeholder={`Mi ${selectedProvider.name} ${new Date().getFullYear()}`}
/>
</div>
<div className="space-y-4">
<h4 className="font-medium text-sm">Credenciales de API</h4>
{selectedProvider.required_fields.map(field => (
<div key={field.field}>
<div className="flex items-center justify-between mb-2">
<label className="block text-sm font-medium">
{field.label} {field.required && <span className="text-red-500">*</span>}
</label>
{field.type === 'password' && (
<Button
type="button"
variant="ghost"
size="sm"
onClick={() => setShowCredentials(prev => ({
...prev,
[field.field]: !prev[field.field]
}))}
>
{showCredentials[field.field] ? <EyeOff className="w-4 h-4" /> : <Eye className="w-4 h-4" />}
</Button>
)}
</div>
{field.type === 'select' ? (
<Select
value={posFormData.credentials[field.field] || ''}
onChange={(value) => setPosFormData(prev => ({
...prev,
credentials: { ...prev.credentials, [field.field]: value }
}))}
placeholder={`Selecciona ${field.label.toLowerCase()}`}
options={field.options?.map(option => ({
value: option.value,
label: option.label
})) || []}
/>
) : (
<Input
type={field.type === 'password' && !showCredentials[field.field] ? 'password' : 'text'}
value={posFormData.credentials[field.field] || ''}
onChange={(e) => setPosFormData(prev => ({
...prev,
credentials: { ...prev.credentials, [field.field]: e.target.value }
}))}
placeholder={field.placeholder}
/>
)}
{field.help_text && (
<p className="text-xs text-gray-500 mt-1 flex items-start">
<Info className="w-3 h-3 mt-0.5 mr-1 flex-shrink-0" />
{field.help_text}
</p>
)}
</div>
))}
</div>
<div className="space-y-4">
<h4 className="font-medium text-sm">Configuración de Sincronización</h4>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label className="flex items-center space-x-2">
<input
type="checkbox"
checked={posFormData.sync_settings?.auto_sync_enabled || false}
onChange={(e) => setPosFormData(prev => ({
...prev,
sync_settings: { ...prev.sync_settings, auto_sync_enabled: e.target.checked }
}))}
/>
<span className="text-sm">Sincronización automática</span>
</label>
</div>
<div>
<label className="block text-sm font-medium mb-1">Intervalo (minutos)</label>
<Select
value={posFormData.sync_settings?.sync_interval_minutes?.toString() || '5'}
onChange={(value) => setPosFormData(prev => ({
...prev,
sync_settings: { ...prev.sync_settings, sync_interval_minutes: parseInt(value as string) }
}))}
options={[
{ value: '5', label: '5 minutos' },
{ value: '15', label: '15 minutos' },
{ value: '30', label: '30 minutos' },
{ value: '60', label: '1 hora' }
]}
/>
</div>
<div>
<label className="flex items-center space-x-2">
<input
type="checkbox"
checked={posFormData.sync_settings?.sync_sales || false}
onChange={(e) => setPosFormData((prev: any) => ({
...prev,
sync_settings: { ...prev.sync_settings, sync_sales: e.target.checked }
}))}
/>
<span className="text-sm">Sincronizar ventas</span>
</label>
</div>
<div>
<label className="flex items-center space-x-2">
<input
type="checkbox"
checked={posFormData.sync_settings?.sync_inventory || false}
onChange={(e) => setPosFormData((prev: any) => ({
...prev,
sync_settings: { ...prev.sync_settings, sync_inventory: e.target.checked }
}))}
/>
<span className="text-sm">Sincronizar inventario</span>
</label>
</div>
</div>
</div>
</>
)}
</div>
);
};
return (
<div className="space-y-6">
<PageHeader
@@ -244,11 +603,58 @@ const POSPage: React.FC = () => {
description="Sistema de ventas para productos terminados"
/>
{/* Stats Grid */}
<StatsGrid
stats={stats}
columns={3}
/>
{/* POS Mode Toggle */}
<Card className="p-6">
<div className="flex items-center justify-between">
<div>
<h3 className="text-lg font-semibold text-[var(--text-primary)] mb-2">Modo de Operación</h3>
<p className="text-sm text-[var(--text-secondary)]">
{posMode === 'manual'
? 'Sistema POS manual interno - gestiona las ventas directamente desde la aplicación'
: 'Integración automática con sistemas POS externos - sincroniza datos automáticamente'
}
</p>
</div>
<div className="flex items-center gap-4">
<div className="flex items-center gap-3">
<span className={`text-sm ${posMode === 'manual' ? 'text-[var(--color-primary)] font-medium' : 'text-[var(--text-tertiary)]'}`}>
Manual
</span>
<button
onClick={() => setPosMode(posMode === 'manual' ? 'automatic' : 'manual')}
className="relative inline-flex items-center"
>
{posMode === 'manual' ? (
<ToggleLeft className="w-8 h-8 text-[var(--text-tertiary)] hover:text-[var(--color-primary)] transition-colors" />
) : (
<ToggleRight className="w-8 h-8 text-[var(--color-primary)] hover:text-[var(--color-primary-dark)] transition-colors" />
)}
</button>
<span className={`text-sm ${posMode === 'automatic' ? 'text-[var(--color-primary)] font-medium' : 'text-[var(--text-tertiary)]'}`}>
Automático
</span>
</div>
{posMode === 'automatic' && (
<Button
variant="outline"
onClick={() => setShowPOSConfig(!showPOSConfig)}
className="flex items-center gap-2"
>
<Settings className="w-4 h-4" />
Configurar POS
</Button>
)}
</div>
</div>
</Card>
{posMode === 'manual' ? (
<>
{/* Stats Grid */}
<StatsGrid
stats={stats}
columns={3}
/>
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
{/* Products Section */}
@@ -526,6 +932,181 @@ const POSPage: React.FC = () => {
</Card>
</div>
</div>
</>
) : (
/* Automatic POS Integration Section */
<div className="space-y-6">
<Card className="p-6">
<div className="flex items-center justify-between mb-6">
<div>
<h3 className="text-lg font-semibold text-[var(--text-primary)] flex items-center">
<Zap className="w-5 h-5 mr-2 text-yellow-500" />
Sistemas POS Integrados
</h3>
<p className="text-sm text-[var(--text-secondary)]">
Gestiona tus sistemas POS externos y configuraciones de integración
</p>
</div>
<Button
onClick={handleAddPosConfiguration}
className="flex items-center gap-2"
>
<Plus className="w-4 h-4" />
Agregar Sistema POS
</Button>
</div>
{posData.isLoading ? (
<div className="flex items-center justify-center h-32">
<Loader className="w-8 h-8 animate-spin" />
</div>
) : posData.configurations.length === 0 ? (
<div className="text-center py-8">
<div className="w-16 h-16 bg-gray-100 rounded-full flex items-center justify-center mx-auto mb-4">
<Zap className="w-8 h-8 text-gray-400" />
</div>
<h3 className="text-lg font-medium mb-2">No hay sistemas POS configurados</h3>
<p className="text-gray-500 mb-4">
Configura tu primer sistema POS para comenzar a sincronizar datos de ventas.
</p>
<Button onClick={handleAddPosConfiguration}>
Agregar Sistema POS
</Button>
</div>
) : (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
{posData.configurations.map(config => {
const provider = posData.supportedSystems.find(p => p.id === config.pos_system);
return (
<Card key={config.id} className="p-6">
<div className="flex items-start justify-between mb-4">
<div className="flex items-center">
<div className="text-2xl mr-3">📊</div>
<div>
<h3 className="font-medium">{config.provider_name}</h3>
<p className="text-sm text-gray-500">{provider?.name || config.pos_system}</p>
</div>
</div>
<div className="flex items-center space-x-1">
{config.is_connected ? (
<Wifi className="w-4 h-4 text-green-500" />
) : (
<WifiOff className="w-4 h-4 text-red-500" />
)}
<Badge variant={config.is_active ? 'success' : 'error'} size="sm">
{config.is_active ? 'Activo' : 'Inactivo'}
</Badge>
</div>
</div>
<div className="space-y-2 mb-4">
<div className="flex items-center justify-between text-sm">
<span>Estado de conexión:</span>
<div className="flex items-center">
{config.is_active ? (
<>
<CheckCircle className="w-4 h-4 text-green-500 mr-1" />
<span className="text-green-600">Conectado</span>
</>
) : (
<>
<AlertCircle className="w-4 h-4 text-yellow-500 mr-1" />
<span className="text-yellow-600">Desconectado</span>
</>
)}
</div>
</div>
{config.last_sync_at && (
<div className="flex justify-between text-sm text-gray-500">
<span>Última sincronización:</span>
<span>{new Date(config.last_sync_at).toLocaleString('es-ES')}</span>
</div>
)}
</div>
<div className="flex space-x-2">
<Button
variant="outline"
size="sm"
onClick={() => handleTestPosConnection(config.id)}
disabled={testingConnection === config.id}
className="flex-1"
>
{testingConnection === config.id ? (
<Loader className="w-4 h-4 animate-spin" />
) : (
<Wifi className="w-4 h-4" />
)}
Probar
</Button>
<Button
variant="outline"
size="sm"
onClick={() => handleEditPosConfiguration(config)}
className="flex-1"
>
<Settings className="w-4 h-4" />
Editar
</Button>
<Button
variant="outline"
size="sm"
onClick={() => handleDeletePosConfiguration(config.id)}
className="text-red-600 hover:text-red-700 border-red-200 hover:border-red-300"
>
<Trash2 className="w-4 h-4" />
</Button>
</div>
</Card>
);
})}
</div>
)}
</Card>
</div>
)}
{/* POS Configuration Modals */}
{/* Add Configuration Modal */}
<Modal
isOpen={showAddPosModal}
onClose={() => setShowAddPosModal(false)}
title="Agregar Sistema POS"
size="lg"
>
<div className="space-y-4">
{renderPosConfigurationForm()}
<div className="flex justify-end space-x-3 pt-4 border-t">
<Button variant="outline" onClick={() => setShowAddPosModal(false)}>
Cancelar
</Button>
<Button onClick={handleSavePosConfiguration}>
Guardar Configuración
</Button>
</div>
</div>
</Modal>
{/* Edit Configuration Modal */}
<Modal
isOpen={showEditPosModal}
onClose={() => setShowEditPosModal(false)}
title="Editar Sistema POS"
size="lg"
>
<div className="space-y-4">
{renderPosConfigurationForm()}
<div className="flex justify-end space-x-3 pt-4 border-t">
<Button variant="outline" onClick={() => setShowEditPosModal(false)}>
Cancelar
</Button>
<Button onClick={handleSavePosConfiguration}>
Actualizar Configuración
</Button>
</div>
</div>
</Modal>
</div>
);
};