2025-09-21 22:56:55 +02:00
|
|
|
import React, { useState, useMemo } from 'react';
|
2025-10-21 19:50:07 +02:00
|
|
|
import { Plus, Minus, ShoppingCart, CreditCard, Banknote, Calculator, User, Receipt, Package, Euro, TrendingUp, Clock, ToggleLeft, ToggleRight, Settings, Zap, Wifi, WifiOff, AlertCircle, CheckCircle, Loader, Trash2, ChevronDown, ChevronUp } from 'lucide-react';
|
|
|
|
|
import { Button, Card, StatsGrid, StatusCard, getStatusColor } from '../../../../components/ui';
|
2025-08-28 10:41:04 +02:00
|
|
|
import { PageHeader } from '../../../../components/layout';
|
2025-09-26 07:46:25 +02:00
|
|
|
import { LoadingSpinner } from '../../../../components/ui';
|
2025-09-21 22:56:55 +02:00
|
|
|
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';
|
2025-09-24 22:22:01 +02:00
|
|
|
import { useToast } from '../../../../hooks/ui/useToast';
|
|
|
|
|
import { usePOSConfigurationData, usePOSConfigurationManager } from '../../../../api/hooks/pos';
|
2025-10-21 19:50:07 +02:00
|
|
|
import { POSConfiguration } from '../../../../api/types/pos';
|
2025-09-24 22:22:01 +02:00
|
|
|
import { posService } from '../../../../api/services/pos';
|
2025-10-21 19:50:07 +02:00
|
|
|
import { bakeryColors } from '../../../../styles/colors';
|
|
|
|
|
|
|
|
|
|
// Import new POS components
|
|
|
|
|
import { POSProductCard } from '../../../../components/domain/pos/POSProductCard';
|
|
|
|
|
import { POSCart } from '../../../../components/domain/pos/POSCart';
|
|
|
|
|
import { POSPayment } from '../../../../components/domain/pos/POSPayment';
|
|
|
|
|
import { CreatePOSConfigModal } from '../../../../components/domain/pos/CreatePOSConfigModal';
|
2025-09-21 22:56:55 +02:00
|
|
|
|
|
|
|
|
interface CartItem {
|
|
|
|
|
id: string;
|
|
|
|
|
name: string;
|
|
|
|
|
price: number;
|
|
|
|
|
quantity: number;
|
|
|
|
|
category: string;
|
|
|
|
|
stock: number;
|
|
|
|
|
}
|
2025-08-28 10:41:04 +02:00
|
|
|
|
|
|
|
|
const POSPage: React.FC = () => {
|
2025-09-21 22:56:55 +02:00
|
|
|
const [cart, setCart] = useState<CartItem[]>([]);
|
2025-08-28 10:41:04 +02:00
|
|
|
const [selectedCategory, setSelectedCategory] = useState('all');
|
2025-09-24 22:22:01 +02:00
|
|
|
const [posMode, setPosMode] = useState<'manual' | 'automatic'>('manual');
|
|
|
|
|
const [showPOSConfig, setShowPOSConfig] = useState(false);
|
2025-10-21 19:50:07 +02:00
|
|
|
const [showStats, setShowStats] = useState(false);
|
2025-09-24 22:22:01 +02:00
|
|
|
|
|
|
|
|
// POS Configuration State
|
2025-10-21 19:50:07 +02:00
|
|
|
const [showPosConfigModal, setShowPosConfigModal] = useState(false);
|
2025-09-24 22:22:01 +02:00
|
|
|
const [selectedPosConfig, setSelectedPosConfig] = useState<POSConfiguration | null>(null);
|
2025-10-21 19:50:07 +02:00
|
|
|
const [posConfigMode, setPosConfigMode] = useState<'create' | 'edit'>('create');
|
2025-09-24 22:22:01 +02:00
|
|
|
const [testingConnection, setTestingConnection] = useState<string | null>(null);
|
2025-08-28 10:41:04 +02:00
|
|
|
|
2025-09-21 22:56:55 +02:00
|
|
|
const tenantId = useTenantId();
|
2025-09-24 22:22:01 +02:00
|
|
|
const { addToast } = useToast();
|
|
|
|
|
|
|
|
|
|
// POS Configuration hooks
|
|
|
|
|
const posData = usePOSConfigurationData(tenantId);
|
|
|
|
|
const posManager = usePOSConfigurationManager(tenantId);
|
2025-08-28 10:41:04 +02:00
|
|
|
|
2025-09-21 22:56:55 +02:00
|
|
|
// Fetch finished products from API
|
|
|
|
|
const {
|
|
|
|
|
data: ingredientsData,
|
|
|
|
|
isLoading: productsLoading,
|
|
|
|
|
error: productsError
|
|
|
|
|
} = useIngredients(tenantId, {
|
2025-10-21 19:50:07 +02:00
|
|
|
category: undefined,
|
2025-09-21 22:56:55 +02:00
|
|
|
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
|
|
|
|
|
}))
|
2025-10-21 19:50:07 +02:00
|
|
|
.filter(product => product.stock > 0);
|
2025-09-21 22:56:55 +02:00
|
|
|
}, [ingredientsData]);
|
|
|
|
|
|
2025-10-21 19:50:07 +02:00
|
|
|
// Generate categories from actual product data with bakery colors
|
2025-09-21 22:56:55 +02:00
|
|
|
const categories = useMemo(() => {
|
|
|
|
|
const categoryMap = new Map();
|
2025-10-21 19:50:07 +02:00
|
|
|
categoryMap.set('all', {
|
|
|
|
|
id: 'all',
|
|
|
|
|
name: 'Todos',
|
|
|
|
|
color: bakeryColors.flour,
|
|
|
|
|
icon: '🛍️'
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
// Define category colors from bakery palette
|
|
|
|
|
const categoryColors: Record<string, string> = {
|
|
|
|
|
bread: bakeryColors.sourdough,
|
|
|
|
|
pastry: bakeryColors.brioche,
|
|
|
|
|
cake: bakeryColors.strawberry,
|
|
|
|
|
cookie: bakeryColors.caramel,
|
|
|
|
|
beverage: bakeryColors.espresso,
|
|
|
|
|
default: bakeryColors.wheat
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const categoryIcons: Record<string, string> = {
|
|
|
|
|
bread: '🍞',
|
|
|
|
|
pastry: '🥐',
|
|
|
|
|
cake: '🎂',
|
|
|
|
|
cookie: '🍪',
|
|
|
|
|
beverage: '☕',
|
|
|
|
|
default: '📦'
|
|
|
|
|
};
|
2025-09-21 22:56:55 +02:00
|
|
|
|
|
|
|
|
products.forEach(product => {
|
|
|
|
|
if (!categoryMap.has(product.category)) {
|
|
|
|
|
const categoryName = product.category.charAt(0).toUpperCase() + product.category.slice(1);
|
2025-10-21 19:50:07 +02:00
|
|
|
categoryMap.set(product.category, {
|
|
|
|
|
id: product.category,
|
|
|
|
|
name: categoryName,
|
|
|
|
|
color: categoryColors[product.category] || categoryColors.default,
|
|
|
|
|
icon: categoryIcons[product.category] || categoryIcons.default
|
|
|
|
|
});
|
2025-09-21 22:56:55 +02:00
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
return Array.from(categoryMap.values());
|
|
|
|
|
}, [products]);
|
2025-08-28 10:41:04 +02:00
|
|
|
|
2025-10-21 19:50:07 +02:00
|
|
|
// Load POS configurations function for refetching after updates
|
|
|
|
|
const loadPosConfigurations = () => {
|
|
|
|
|
console.log('POS configurations updated, consider implementing refetch if needed');
|
|
|
|
|
};
|
2025-09-24 22:22:01 +02:00
|
|
|
|
2025-09-21 22:56:55 +02:00
|
|
|
const filteredProducts = useMemo(() => {
|
|
|
|
|
return products.filter(product =>
|
|
|
|
|
selectedCategory === 'all' || product.category === selectedCategory
|
|
|
|
|
);
|
|
|
|
|
}, [products, selectedCategory]);
|
2025-08-28 10:41:04 +02:00
|
|
|
|
2025-09-24 22:22:01 +02:00
|
|
|
// POS Configuration Handlers
|
|
|
|
|
const handleAddPosConfiguration = () => {
|
2025-10-21 19:50:07 +02:00
|
|
|
setSelectedPosConfig(null);
|
|
|
|
|
setPosConfigMode('create');
|
|
|
|
|
setShowPosConfigModal(true);
|
2025-09-24 22:22:01 +02:00
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const handleEditPosConfiguration = (config: POSConfiguration) => {
|
|
|
|
|
setSelectedPosConfig(config);
|
2025-10-21 19:50:07 +02:00
|
|
|
setPosConfigMode('edit');
|
|
|
|
|
setShowPosConfigModal(true);
|
2025-09-24 22:22:01 +02:00
|
|
|
};
|
|
|
|
|
|
2025-10-21 19:50:07 +02:00
|
|
|
const handlePosConfigSuccess = () => {
|
|
|
|
|
loadPosConfigurations();
|
|
|
|
|
setShowPosConfigModal(false);
|
2025-09-24 22:22:01 +02:00
|
|
|
};
|
|
|
|
|
|
|
|
|
|
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' });
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
|
2025-08-28 10:41:04 +02:00
|
|
|
const addToCart = (product: typeof products[0]) => {
|
|
|
|
|
setCart(prevCart => {
|
|
|
|
|
const existingItem = prevCart.find(item => item.id === product.id);
|
|
|
|
|
if (existingItem) {
|
2025-09-21 22:56:55 +02:00
|
|
|
if (existingItem.quantity >= product.stock) {
|
2025-10-21 19:50:07 +02:00
|
|
|
return prevCart;
|
2025-09-21 22:56:55 +02:00
|
|
|
}
|
2025-08-28 10:41:04 +02:00
|
|
|
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,
|
2025-09-21 22:56:55 +02:00
|
|
|
stock: product.stock
|
2025-08-28 10:41:04 +02:00
|
|
|
}];
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const updateQuantity = (id: string, quantity: number) => {
|
|
|
|
|
if (quantity <= 0) {
|
|
|
|
|
setCart(prevCart => prevCart.filter(item => item.id !== id));
|
|
|
|
|
} else {
|
|
|
|
|
setCart(prevCart =>
|
2025-09-21 22:56:55 +02:00
|
|
|
prevCart.map(item => {
|
|
|
|
|
if (item.id === id) {
|
|
|
|
|
const maxQuantity = Math.min(quantity, item.stock);
|
|
|
|
|
return { ...item, quantity: maxQuantity };
|
|
|
|
|
}
|
|
|
|
|
return item;
|
|
|
|
|
})
|
2025-08-28 10:41:04 +02:00
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const clearCart = () => {
|
|
|
|
|
setCart([]);
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const subtotal = cart.reduce((sum, item) => sum + (item.price * item.quantity), 0);
|
2025-10-21 19:50:07 +02:00
|
|
|
const taxRate = 0.21;
|
2025-08-28 10:41:04 +02:00
|
|
|
const tax = subtotal * taxRate;
|
|
|
|
|
const total = subtotal + tax;
|
|
|
|
|
|
2025-10-21 19:50:07 +02:00
|
|
|
const processPayment = (paymentData: any) => {
|
2025-08-28 10:41:04 +02:00
|
|
|
if (cart.length === 0) return;
|
2025-09-21 22:56:55 +02:00
|
|
|
|
2025-08-28 10:41:04 +02:00
|
|
|
console.log('Processing payment:', {
|
|
|
|
|
cart,
|
2025-10-21 19:50:07 +02:00
|
|
|
...paymentData,
|
2025-08-28 10:41:04 +02:00
|
|
|
total,
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
setCart([]);
|
2025-10-21 19:50:07 +02:00
|
|
|
addToast('Venta procesada exitosamente', { type: 'success' });
|
2025-08-28 10:41:04 +02:00
|
|
|
};
|
|
|
|
|
|
2025-09-21 22:56:55 +02:00
|
|
|
// 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>
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
2025-09-24 22:22:01 +02:00
|
|
|
|
2025-08-28 10:41:04 +02:00
|
|
|
return (
|
2025-09-21 22:56:55 +02:00
|
|
|
<div className="space-y-6">
|
2025-08-28 10:41:04 +02:00
|
|
|
<PageHeader
|
|
|
|
|
title="Punto de Venta"
|
2025-09-21 22:56:55 +02:00
|
|
|
description="Sistema de ventas para productos terminados"
|
|
|
|
|
/>
|
|
|
|
|
|
2025-09-24 22:22:01 +02:00
|
|
|
{/* POS Mode Toggle */}
|
|
|
|
|
<Card className="p-6">
|
2025-10-21 19:50:07 +02:00
|
|
|
<div className="flex flex-col sm:flex-row items-start sm:items-center justify-between gap-4">
|
2025-09-24 22:22:01 +02:00
|
|
|
<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' ? (
|
|
|
|
|
<>
|
2025-10-21 19:50:07 +02:00
|
|
|
{/* Collapsible Stats Grid */}
|
2025-09-21 22:56:55 +02:00
|
|
|
<Card className="p-4">
|
2025-10-21 19:50:07 +02:00
|
|
|
<button
|
|
|
|
|
onClick={() => setShowStats(!showStats)}
|
|
|
|
|
className="w-full flex items-center justify-between text-left"
|
|
|
|
|
>
|
|
|
|
|
<div className="flex items-center gap-2">
|
|
|
|
|
<TrendingUp className="w-5 h-5 text-[var(--color-primary)]" />
|
|
|
|
|
<span className="font-semibold text-[var(--text-primary)]">
|
|
|
|
|
Estadísticas del POS
|
|
|
|
|
</span>
|
|
|
|
|
</div>
|
|
|
|
|
{showStats ? (
|
|
|
|
|
<ChevronUp className="w-5 h-5 text-[var(--text-tertiary)]" />
|
2025-08-28 10:41:04 +02:00
|
|
|
) : (
|
2025-10-21 19:50:07 +02:00
|
|
|
<ChevronDown className="w-5 h-5 text-[var(--text-tertiary)]" />
|
2025-08-28 10:41:04 +02:00
|
|
|
)}
|
2025-10-21 19:50:07 +02:00
|
|
|
</button>
|
|
|
|
|
{showStats && (
|
|
|
|
|
<div className="mt-4 pt-4 border-t border-[var(--border-primary)]">
|
|
|
|
|
<StatsGrid stats={stats} columns={3} />
|
2025-08-28 10:41:04 +02:00
|
|
|
</div>
|
|
|
|
|
)}
|
|
|
|
|
</Card>
|
|
|
|
|
|
2025-10-21 19:50:07 +02:00
|
|
|
{/* Main 2-Column Layout */}
|
|
|
|
|
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
|
|
|
|
|
{/* Left Column: Products (2/3 width on desktop) */}
|
|
|
|
|
<div className="lg:col-span-2 space-y-6">
|
|
|
|
|
{/* Category Pills with Bakery Colors */}
|
|
|
|
|
<Card className="p-4">
|
|
|
|
|
<div className="flex gap-2 overflow-x-auto pb-2 scrollbar-thin">
|
|
|
|
|
{categories.map(category => (
|
|
|
|
|
<button
|
|
|
|
|
key={category.id}
|
|
|
|
|
onClick={() => setSelectedCategory(category.id)}
|
|
|
|
|
className={`
|
|
|
|
|
flex items-center gap-2 px-4 py-3 rounded-xl font-semibold text-sm whitespace-nowrap
|
|
|
|
|
transition-all duration-200 hover:scale-105 active:scale-95
|
|
|
|
|
${selectedCategory === category.id
|
|
|
|
|
? 'shadow-lg ring-2'
|
|
|
|
|
: 'shadow hover:shadow-md'
|
|
|
|
|
}
|
|
|
|
|
`}
|
|
|
|
|
style={{
|
|
|
|
|
backgroundColor: selectedCategory === category.id
|
|
|
|
|
? category.color
|
|
|
|
|
: 'var(--bg-secondary)',
|
|
|
|
|
color: selectedCategory === category.id
|
|
|
|
|
? '#ffffff'
|
|
|
|
|
: 'var(--text-primary)',
|
|
|
|
|
borderColor: category.color,
|
|
|
|
|
...(selectedCategory === category.id && {
|
|
|
|
|
ringColor: category.color,
|
|
|
|
|
}),
|
|
|
|
|
}}
|
|
|
|
|
>
|
|
|
|
|
<span className="text-lg">{category.icon}</span>
|
|
|
|
|
<span>{category.name}</span>
|
|
|
|
|
</button>
|
|
|
|
|
))}
|
|
|
|
|
</div>
|
|
|
|
|
</Card>
|
2025-08-28 10:41:04 +02:00
|
|
|
|
2025-10-21 19:50:07 +02:00
|
|
|
{/* Products Grid - Large Touch-Friendly Cards */}
|
|
|
|
|
<div className="grid grid-cols-1 sm:grid-cols-2 xl:grid-cols-3 gap-4">
|
|
|
|
|
{filteredProducts.map(product => {
|
|
|
|
|
const cartItem = cart.find(item => item.id === product.id);
|
|
|
|
|
const cartQuantity = cartItem?.quantity || 0;
|
|
|
|
|
|
|
|
|
|
return (
|
|
|
|
|
<POSProductCard
|
|
|
|
|
key={product.id}
|
|
|
|
|
id={product.id}
|
|
|
|
|
name={product.name}
|
|
|
|
|
price={product.price}
|
|
|
|
|
category={product.category}
|
|
|
|
|
stock={product.stock}
|
|
|
|
|
cartQuantity={cartQuantity}
|
|
|
|
|
onAddToCart={() => addToCart(product)}
|
|
|
|
|
/>
|
|
|
|
|
);
|
|
|
|
|
})}
|
2025-08-28 10:41:04 +02:00
|
|
|
</div>
|
|
|
|
|
|
2025-10-21 19:50:07 +02:00
|
|
|
{/* Empty State */}
|
|
|
|
|
{filteredProducts.length === 0 && (
|
|
|
|
|
<div className="text-center py-12">
|
|
|
|
|
<Package className="mx-auto h-16 w-16 text-[var(--text-tertiary)] mb-4 opacity-30" />
|
|
|
|
|
<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>
|
2025-08-28 10:41:04 +02:00
|
|
|
</div>
|
|
|
|
|
)}
|
2025-10-21 19:50:07 +02:00
|
|
|
</div>
|
2025-08-28 10:41:04 +02:00
|
|
|
|
2025-10-21 19:50:07 +02:00
|
|
|
{/* Right Column: Cart & Payment (1/3 width on desktop) */}
|
|
|
|
|
<div className="lg:sticky lg:top-6 lg:self-start space-y-6">
|
|
|
|
|
{/* Cart Component */}
|
|
|
|
|
<Card className="p-6 min-h-[400px]">
|
|
|
|
|
<POSCart
|
|
|
|
|
cart={cart}
|
|
|
|
|
onUpdateQuantity={updateQuantity}
|
|
|
|
|
onClearCart={clearCart}
|
|
|
|
|
taxRate={taxRate}
|
|
|
|
|
/>
|
|
|
|
|
</Card>
|
|
|
|
|
|
|
|
|
|
{/* Payment Component */}
|
|
|
|
|
<POSPayment
|
|
|
|
|
total={total}
|
|
|
|
|
onProcessPayment={processPayment}
|
|
|
|
|
disabled={cart.length === 0}
|
|
|
|
|
/>
|
2025-08-28 10:41:04 +02:00
|
|
|
</div>
|
2025-10-21 19:50:07 +02:00
|
|
|
</div>
|
2025-09-24 22:22:01 +02:00
|
|
|
</>
|
|
|
|
|
) : (
|
2025-10-21 19:50:07 +02:00
|
|
|
/* Automatic POS Integration Section with StatusCard */
|
2025-09-24 22:22:01 +02:00
|
|
|
<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 ? (
|
2025-10-21 19:50:07 +02:00
|
|
|
<div className="text-center py-12">
|
2025-09-24 22:22:01 +02:00
|
|
|
<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);
|
2025-10-21 19:50:07 +02:00
|
|
|
const isConnected = config.is_connected && config.is_active;
|
2025-09-24 22:22:01 +02:00
|
|
|
|
2025-10-21 19:50:07 +02:00
|
|
|
return (
|
|
|
|
|
<StatusCard
|
|
|
|
|
key={config.id}
|
|
|
|
|
id={config.id}
|
|
|
|
|
statusIndicator={{
|
|
|
|
|
color: isConnected ? getStatusColor('completed') : getStatusColor('cancelled'),
|
|
|
|
|
text: isConnected ? 'Conectado' : 'Desconectado',
|
|
|
|
|
icon: isConnected ? CheckCircle : WifiOff,
|
|
|
|
|
isCritical: !isConnected,
|
|
|
|
|
}}
|
|
|
|
|
title={config.provider_name}
|
|
|
|
|
subtitle={provider?.name || config.pos_system}
|
|
|
|
|
primaryValue={config.last_sync_at
|
|
|
|
|
? new Date(config.last_sync_at).toLocaleString('es-ES', { month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit' })
|
|
|
|
|
: 'Sin sincronizar'
|
|
|
|
|
}
|
|
|
|
|
primaryValueLabel="Última sincronización"
|
|
|
|
|
actions={[
|
|
|
|
|
{
|
|
|
|
|
label: 'Probar',
|
|
|
|
|
icon: Wifi,
|
|
|
|
|
onClick: () => handleTestPosConnection(config.id),
|
|
|
|
|
priority: 'secondary' as const,
|
|
|
|
|
disabled: testingConnection === config.id,
|
|
|
|
|
},
|
|
|
|
|
{
|
|
|
|
|
label: 'Editar',
|
|
|
|
|
icon: Settings,
|
|
|
|
|
onClick: () => handleEditPosConfiguration(config),
|
|
|
|
|
priority: 'secondary' as const,
|
|
|
|
|
},
|
|
|
|
|
{
|
|
|
|
|
label: 'Eliminar',
|
|
|
|
|
icon: Trash2,
|
|
|
|
|
onClick: () => handleDeletePosConfiguration(config.id),
|
|
|
|
|
priority: 'tertiary' as const,
|
|
|
|
|
destructive: true,
|
|
|
|
|
},
|
|
|
|
|
]}
|
|
|
|
|
/>
|
2025-09-24 22:22:01 +02:00
|
|
|
);
|
|
|
|
|
})}
|
|
|
|
|
</div>
|
|
|
|
|
)}
|
|
|
|
|
</Card>
|
|
|
|
|
</div>
|
|
|
|
|
)}
|
|
|
|
|
|
2025-10-21 19:50:07 +02:00
|
|
|
{/* POS Configuration Modal */}
|
|
|
|
|
<CreatePOSConfigModal
|
|
|
|
|
isOpen={showPosConfigModal}
|
|
|
|
|
onClose={() => setShowPosConfigModal(false)}
|
|
|
|
|
tenantId={tenantId}
|
|
|
|
|
onSuccess={handlePosConfigSuccess}
|
|
|
|
|
existingConfig={selectedPosConfig}
|
|
|
|
|
mode={posConfigMode}
|
|
|
|
|
/>
|
2025-08-28 10:41:04 +02:00
|
|
|
</div>
|
|
|
|
|
);
|
|
|
|
|
};
|
|
|
|
|
|
2025-10-21 19:50:07 +02:00
|
|
|
export default POSPage;
|