1114 lines
43 KiB
TypeScript
1114 lines
43 KiB
TypeScript
import React, { useState, useMemo } from 'react';
|
|
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;
|
|
name: string;
|
|
price: number;
|
|
quantity: number;
|
|
category: string;
|
|
stock: number;
|
|
}
|
|
|
|
const POSPage: React.FC = () => {
|
|
const [cart, setCart] = useState<CartItem[]>([]);
|
|
const [selectedCategory, setSelectedCategory] = useState('all');
|
|
const [customerInfo, setCustomerInfo] = useState({
|
|
name: '',
|
|
email: '',
|
|
phone: '',
|
|
});
|
|
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 {
|
|
data: ingredientsData,
|
|
isLoading: productsLoading,
|
|
error: productsError
|
|
} = useIngredients(tenantId, {
|
|
// Filter for finished products only
|
|
category: undefined, // We'll filter client-side for now
|
|
search: undefined
|
|
});
|
|
|
|
// Filter for finished products and convert to POS format
|
|
const products = useMemo(() => {
|
|
if (!ingredientsData) return [];
|
|
|
|
return ingredientsData
|
|
.filter(ingredient => ingredient.product_type === ProductType.FINISHED_PRODUCT)
|
|
.map(ingredient => ({
|
|
id: ingredient.id,
|
|
name: ingredient.name,
|
|
price: Number(ingredient.average_cost) || 0,
|
|
category: ingredient.category.toLowerCase(),
|
|
stock: Number(ingredient.current_stock) || 0,
|
|
ingredient: ingredient
|
|
}))
|
|
.filter(product => product.stock > 0); // Only show products in stock
|
|
}, [ingredientsData]);
|
|
|
|
// Generate categories from actual product data
|
|
const categories = useMemo(() => {
|
|
const categoryMap = new Map();
|
|
categoryMap.set('all', { id: 'all', name: 'Todos' });
|
|
|
|
products.forEach(product => {
|
|
if (!categoryMap.has(product.category)) {
|
|
const categoryName = product.category.charAt(0).toUpperCase() + product.category.slice(1);
|
|
categoryMap.set(product.category, { id: product.category, name: categoryName });
|
|
}
|
|
});
|
|
|
|
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);
|
|
if (existingItem) {
|
|
// Check if we have enough stock
|
|
if (existingItem.quantity >= product.stock) {
|
|
return prevCart; // Don't add if no stock available
|
|
}
|
|
return prevCart.map(item =>
|
|
item.id === product.id
|
|
? { ...item, quantity: item.quantity + 1 }
|
|
: item
|
|
);
|
|
} else {
|
|
return [...prevCart, {
|
|
id: product.id,
|
|
name: product.name,
|
|
price: product.price,
|
|
quantity: 1,
|
|
category: product.category,
|
|
stock: product.stock
|
|
}];
|
|
}
|
|
});
|
|
};
|
|
|
|
const updateQuantity = (id: string, quantity: number) => {
|
|
if (quantity <= 0) {
|
|
setCart(prevCart => prevCart.filter(item => item.id !== id));
|
|
} else {
|
|
setCart(prevCart =>
|
|
prevCart.map(item => {
|
|
if (item.id === id) {
|
|
// Don't allow quantity to exceed stock
|
|
const maxQuantity = Math.min(quantity, item.stock);
|
|
return { ...item, quantity: maxQuantity };
|
|
}
|
|
return item;
|
|
})
|
|
);
|
|
}
|
|
};
|
|
|
|
const clearCart = () => {
|
|
setCart([]);
|
|
};
|
|
|
|
const subtotal = cart.reduce((sum, item) => sum + (item.price * item.quantity), 0);
|
|
const taxRate = 0.21; // 21% IVA
|
|
const tax = subtotal * taxRate;
|
|
const total = subtotal + tax;
|
|
const change = cashReceived ? Math.max(0, parseFloat(cashReceived) - total) : 0;
|
|
|
|
const processPayment = () => {
|
|
if (cart.length === 0) return;
|
|
|
|
// TODO: Integrate with real POS API endpoint
|
|
console.log('Processing payment:', {
|
|
cart,
|
|
customerInfo,
|
|
paymentMethod,
|
|
total,
|
|
cashReceived: paymentMethod === 'cash' ? parseFloat(cashReceived) : undefined,
|
|
change: paymentMethod === 'cash' ? change : undefined,
|
|
});
|
|
|
|
// Clear cart after successful payment
|
|
setCart([]);
|
|
setCustomerInfo({ name: '', email: '', phone: '' });
|
|
setCashReceived('');
|
|
|
|
alert('Venta procesada exitosamente');
|
|
};
|
|
|
|
// Calculate stats for the POS dashboard
|
|
const posStats = useMemo(() => {
|
|
const totalProducts = products.length;
|
|
const totalStock = products.reduce((sum, product) => sum + product.stock, 0);
|
|
const cartValue = cart.reduce((sum, item) => sum + (item.price * item.quantity), 0);
|
|
const cartItems = cart.reduce((sum, item) => sum + item.quantity, 0);
|
|
const lowStockProducts = products.filter(product => product.stock <= 5).length;
|
|
const avgProductPrice = totalProducts > 0 ? products.reduce((sum, product) => sum + product.price, 0) / totalProducts : 0;
|
|
|
|
return {
|
|
totalProducts,
|
|
totalStock,
|
|
cartValue,
|
|
cartItems,
|
|
lowStockProducts,
|
|
avgProductPrice
|
|
};
|
|
}, [products, cart]);
|
|
|
|
const stats = [
|
|
{
|
|
title: 'Productos Disponibles',
|
|
value: posStats.totalProducts,
|
|
variant: 'default' as const,
|
|
icon: Package,
|
|
},
|
|
{
|
|
title: 'Stock Total',
|
|
value: posStats.totalStock,
|
|
variant: 'info' as const,
|
|
icon: Package,
|
|
},
|
|
{
|
|
title: 'Artículos en Carrito',
|
|
value: posStats.cartItems,
|
|
variant: 'success' as const,
|
|
icon: ShoppingCart,
|
|
},
|
|
{
|
|
title: 'Valor del Carrito',
|
|
value: formatters.currency(posStats.cartValue),
|
|
variant: 'success' as const,
|
|
icon: Euro,
|
|
},
|
|
{
|
|
title: 'Stock Bajo',
|
|
value: posStats.lowStockProducts,
|
|
variant: 'warning' as const,
|
|
icon: Clock,
|
|
},
|
|
{
|
|
title: 'Precio Promedio',
|
|
value: formatters.currency(posStats.avgProductPrice),
|
|
variant: 'info' as const,
|
|
icon: TrendingUp,
|
|
},
|
|
];
|
|
|
|
// Loading and error states
|
|
if (productsLoading || !tenantId) {
|
|
return (
|
|
<div className="flex items-center justify-center min-h-64">
|
|
<LoadingSpinner text="Cargando productos..." />
|
|
</div>
|
|
);
|
|
}
|
|
|
|
if (productsError) {
|
|
return (
|
|
<div className="text-center py-12">
|
|
<Package className="mx-auto h-12 w-12 text-red-500 mb-4" />
|
|
<h3 className="text-lg font-medium text-[var(--text-primary)] mb-2">
|
|
Error al cargar productos
|
|
</h3>
|
|
<p className="text-[var(--text-secondary)] mb-4">
|
|
{productsError.message || 'Ha ocurrido un error inesperado'}
|
|
</p>
|
|
<Button onClick={() => window.location.reload()}>
|
|
Reintentar
|
|
</Button>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// 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
|
|
title="Punto de Venta"
|
|
description="Sistema de ventas para productos terminados"
|
|
/>
|
|
|
|
{/* 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 */}
|
|
<div className="lg:col-span-2 space-y-6">
|
|
{/* Categories */}
|
|
<Card className="p-4">
|
|
<div className="flex space-x-2 overflow-x-auto">
|
|
{categories.map(category => (
|
|
<Button
|
|
key={category.id}
|
|
variant={selectedCategory === category.id ? 'default' : 'outline'}
|
|
onClick={() => setSelectedCategory(category.id)}
|
|
className="whitespace-nowrap"
|
|
>
|
|
{category.name}
|
|
</Button>
|
|
))}
|
|
</div>
|
|
</Card>
|
|
|
|
{/* Products Grid */}
|
|
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
|
{filteredProducts.map(product => {
|
|
const cartItem = cart.find(item => item.id === product.id);
|
|
const inCart = !!cartItem;
|
|
const cartQuantity = cartItem?.quantity || 0;
|
|
const remainingStock = product.stock - cartQuantity;
|
|
|
|
const getStockStatusConfig = () => {
|
|
if (remainingStock <= 0) {
|
|
return {
|
|
color: getStatusColor('cancelled'),
|
|
text: 'Sin Stock',
|
|
icon: Package,
|
|
isCritical: true,
|
|
isHighlight: false
|
|
};
|
|
} else if (remainingStock <= 5) {
|
|
return {
|
|
color: getStatusColor('pending'),
|
|
text: `${remainingStock} disponibles`,
|
|
icon: Package,
|
|
isCritical: false,
|
|
isHighlight: true
|
|
};
|
|
} else {
|
|
return {
|
|
color: getStatusColor('completed'),
|
|
text: `${remainingStock} disponibles`,
|
|
icon: Package,
|
|
isCritical: false,
|
|
isHighlight: false
|
|
};
|
|
}
|
|
};
|
|
|
|
return (
|
|
<StatusCard
|
|
key={product.id}
|
|
id={product.id}
|
|
statusIndicator={getStockStatusConfig()}
|
|
title={product.name}
|
|
subtitle={product.category.charAt(0).toUpperCase() + product.category.slice(1)}
|
|
primaryValue={formatters.currency(product.price)}
|
|
primaryValueLabel="precio"
|
|
secondaryInfo={inCart ? {
|
|
label: 'En carrito',
|
|
value: cartQuantity.toString()
|
|
} : undefined}
|
|
actions={[
|
|
{
|
|
label: 'Agregar al Carrito',
|
|
icon: Plus,
|
|
variant: 'primary',
|
|
priority: 'primary',
|
|
disabled: remainingStock <= 0,
|
|
onClick: () => addToCart(product)
|
|
}
|
|
]}
|
|
/>
|
|
);
|
|
})}
|
|
</div>
|
|
|
|
{/* Empty State */}
|
|
{filteredProducts.length === 0 && (
|
|
<div className="text-center py-12">
|
|
<Package className="mx-auto h-12 w-12 text-[var(--text-tertiary)] mb-4" />
|
|
<h3 className="text-lg font-medium text-[var(--text-primary)] mb-2">
|
|
No hay productos disponibles
|
|
</h3>
|
|
<p className="text-[var(--text-secondary)] mb-4">
|
|
{selectedCategory === 'all'
|
|
? 'No hay productos en stock en este momento'
|
|
: `No hay productos en la categoría "${categories.find(c => c.id === selectedCategory)?.name}"`
|
|
}
|
|
</p>
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
{/* Cart and Checkout Section */}
|
|
<div className="space-y-6">
|
|
{/* Cart */}
|
|
<Card className="p-6">
|
|
<div className="flex items-center justify-between mb-4">
|
|
<h3 className="text-lg font-semibold flex items-center">
|
|
<ShoppingCart className="w-5 h-5 mr-2" />
|
|
Carrito ({cart.length})
|
|
</h3>
|
|
{cart.length > 0 && (
|
|
<Button variant="outline" size="sm" onClick={clearCart}>
|
|
Limpiar
|
|
</Button>
|
|
)}
|
|
</div>
|
|
|
|
<div className="space-y-3 max-h-64 overflow-y-auto">
|
|
{cart.length === 0 ? (
|
|
<p className="text-[var(--text-tertiary)] text-center py-8">Carrito vacío</p>
|
|
) : (
|
|
cart.map(item => {
|
|
const product = products.find(p => p.id === item.id);
|
|
const maxQuantity = product?.stock || item.stock;
|
|
|
|
return (
|
|
<div key={item.id} className="flex items-center justify-between p-3 bg-[var(--bg-secondary)] rounded">
|
|
<div className="flex-1">
|
|
<h4 className="text-sm font-medium">{item.name}</h4>
|
|
<p className="text-xs text-[var(--text-tertiary)]">€{item.price.toFixed(2)} c/u</p>
|
|
<p className="text-xs text-[var(--text-tertiary)]">Stock: {maxQuantity}</p>
|
|
</div>
|
|
<div className="flex items-center space-x-2">
|
|
<Button
|
|
size="sm"
|
|
variant="outline"
|
|
onClick={(e) => {
|
|
e.stopPropagation();
|
|
updateQuantity(item.id, item.quantity - 1);
|
|
}}
|
|
>
|
|
<Minus className="w-3 h-3" />
|
|
</Button>
|
|
<span className="w-8 text-center text-sm">{item.quantity}</span>
|
|
<Button
|
|
size="sm"
|
|
variant="outline"
|
|
disabled={item.quantity >= maxQuantity}
|
|
onClick={(e) => {
|
|
e.stopPropagation();
|
|
updateQuantity(item.id, item.quantity + 1);
|
|
}}
|
|
>
|
|
<Plus className="w-3 h-3" />
|
|
</Button>
|
|
</div>
|
|
<div className="ml-4 text-right">
|
|
<p className="text-sm font-medium">€{(item.price * item.quantity).toFixed(2)}</p>
|
|
</div>
|
|
</div>
|
|
);
|
|
})
|
|
)}
|
|
</div>
|
|
|
|
{cart.length > 0 && (
|
|
<div className="mt-4 pt-4 border-t">
|
|
<div className="space-y-2">
|
|
<div className="flex justify-between">
|
|
<span>Subtotal:</span>
|
|
<span>€{subtotal.toFixed(2)}</span>
|
|
</div>
|
|
<div className="flex justify-between">
|
|
<span>IVA (21%):</span>
|
|
<span>€{tax.toFixed(2)}</span>
|
|
</div>
|
|
<div className="flex justify-between text-lg font-bold border-t pt-2">
|
|
<span>Total:</span>
|
|
<span>€{total.toFixed(2)}</span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</Card>
|
|
|
|
{/* Customer Info */}
|
|
<Card className="p-6">
|
|
<h3 className="text-lg font-semibold mb-4 flex items-center">
|
|
<User className="w-5 h-5 mr-2" />
|
|
Cliente (Opcional)
|
|
</h3>
|
|
<div className="space-y-3">
|
|
<Input
|
|
placeholder="Nombre"
|
|
value={customerInfo.name}
|
|
onChange={(e) => setCustomerInfo(prev => ({ ...prev, name: e.target.value }))}
|
|
/>
|
|
<Input
|
|
placeholder="Email"
|
|
type="email"
|
|
value={customerInfo.email}
|
|
onChange={(e) => setCustomerInfo(prev => ({ ...prev, email: e.target.value }))}
|
|
/>
|
|
<Input
|
|
placeholder="Teléfono"
|
|
value={customerInfo.phone}
|
|
onChange={(e) => setCustomerInfo(prev => ({ ...prev, phone: e.target.value }))}
|
|
/>
|
|
</div>
|
|
</Card>
|
|
|
|
{/* Payment */}
|
|
<Card className="p-6">
|
|
<h3 className="text-lg font-semibold mb-4 flex items-center">
|
|
<Calculator className="w-5 h-5 mr-2" />
|
|
Método de Pago
|
|
</h3>
|
|
|
|
<div className="space-y-3">
|
|
<div className="grid grid-cols-3 gap-2">
|
|
<Button
|
|
variant={paymentMethod === 'cash' ? 'primary' : 'outline'}
|
|
onClick={() => setPaymentMethod('cash')}
|
|
className="flex items-center justify-center"
|
|
>
|
|
<Banknote className="w-4 h-4 mr-1" />
|
|
Efectivo
|
|
</Button>
|
|
<Button
|
|
variant={paymentMethod === 'card' ? 'primary' : 'outline'}
|
|
onClick={() => setPaymentMethod('card')}
|
|
className="flex items-center justify-center"
|
|
>
|
|
<CreditCard className="w-4 h-4 mr-1" />
|
|
Tarjeta
|
|
</Button>
|
|
<Button
|
|
variant={paymentMethod === 'transfer' ? 'primary' : 'outline'}
|
|
onClick={() => setPaymentMethod('transfer')}
|
|
className="flex items-center justify-center"
|
|
>
|
|
Transferencia
|
|
</Button>
|
|
</div>
|
|
|
|
{paymentMethod === 'cash' && (
|
|
<div className="space-y-2">
|
|
<Input
|
|
placeholder="Efectivo recibido"
|
|
type="number"
|
|
step="0.01"
|
|
value={cashReceived}
|
|
onChange={(e) => setCashReceived(e.target.value)}
|
|
/>
|
|
{cashReceived && parseFloat(cashReceived) >= total && (
|
|
<div className="p-2 bg-green-50 rounded text-center">
|
|
<p className="text-sm text-[var(--color-success)]">
|
|
Cambio: <span className="font-bold">€{change.toFixed(2)}</span>
|
|
</p>
|
|
</div>
|
|
)}
|
|
</div>
|
|
)}
|
|
|
|
<Button
|
|
onClick={processPayment}
|
|
disabled={cart.length === 0 || (paymentMethod === 'cash' && (!cashReceived || parseFloat(cashReceived) < total))}
|
|
className="w-full"
|
|
size="lg"
|
|
>
|
|
<Receipt className="w-5 h-5 mr-2" />
|
|
Procesar Venta - €{total.toFixed(2)}
|
|
</Button>
|
|
</div>
|
|
</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>
|
|
);
|
|
};
|
|
|
|
export default POSPage; |