Files
bakery-ia/frontend/src/pages/app/operations/pos/POSPage.tsx

621 lines
22 KiB
TypeScript
Raw Normal View History

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;