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

533 lines
18 KiB
TypeScript
Raw Normal View History

2025-09-21 22:56:55 +02:00
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';
2025-08-28 10:41:04 +02:00
import { PageHeader } from '../../../../components/layout';
2025-09-21 22:56:55 +02:00
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';
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');
const [customerInfo, setCustomerInfo] = useState({
name: '',
email: '',
phone: '',
});
const [paymentMethod, setPaymentMethod] = useState<'cash' | 'card' | 'transfer'>('cash');
const [cashReceived, setCashReceived] = useState('');
2025-09-21 22:56:55 +02:00
const tenantId = useTenantId();
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, {
// 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]);
2025-08-28 10:41:04 +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
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
// Check if we have enough stock
if (existingItem.quantity >= product.stock) {
return prevCart; // Don't add if no stock available
}
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) {
// Don't allow quantity to exceed stock
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);
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;
2025-09-21 22:56:55 +02:00
// TODO: Integrate with real POS API endpoint
2025-08-28 10:41:04 +02:00
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('');
2025-09-21 22:56:55 +02:00
2025-08-28 10:41:04 +02:00
alert('Venta procesada exitosamente');
};
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-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"
/>
{/* Stats Grid */}
<StatsGrid
stats={stats}
columns={3}
2025-08-28 10:41:04 +02:00
/>
2025-09-21 22:56:55 +02:00
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
2025-08-28 10:41:04 +02:00
{/* Products Section */}
<div className="lg:col-span-2 space-y-6">
{/* Categories */}
2025-09-21 22:56:55 +02:00
<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>
2025-08-28 10:41:04 +02:00
{/* Products Grid */}
2025-09-21 22:56:55 +02:00
<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)
}
]}
2025-08-28 10:41:04 +02:00
/>
2025-09-21 22:56:55 +02:00
);
})}
2025-08-28 10:41:04 +02:00
</div>
2025-09-21 22:56:55 +02:00
{/* 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>
)}
2025-08-28 10:41:04 +02:00
</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>
) : (
2025-09-21 22:56:55 +02:00
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>
2025-08-28 10:41:04 +02:00
</div>
2025-09-21 22:56:55 +02:00
);
})
2025-08-28 10:41:04 +02:00
)}
</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
2025-09-21 22:56:55 +02:00
variant={paymentMethod === 'cash' ? 'primary' : 'outline'}
2025-08-28 10:41:04 +02:00
onClick={() => setPaymentMethod('cash')}
className="flex items-center justify-center"
>
<Banknote className="w-4 h-4 mr-1" />
Efectivo
</Button>
<Button
2025-09-21 22:56:55 +02:00
variant={paymentMethod === 'card' ? 'primary' : 'outline'}
2025-08-28 10:41:04 +02:00
onClick={() => setPaymentMethod('card')}
className="flex items-center justify-center"
>
<CreditCard className="w-4 h-4 mr-1" />
Tarjeta
</Button>
<Button
2025-09-21 22:56:55 +02:00
variant={paymentMethod === 'transfer' ? 'primary' : 'outline'}
2025-08-28 10:41:04 +02:00
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>
</div>
);
};
export default POSPage;