Add frontend pages imporvements
This commit is contained in:
@@ -15,7 +15,7 @@ import {
|
||||
// Import AddStockModal separately since we need it for adding batches
|
||||
import AddStockModal from '../../../../components/domain/inventory/AddStockModal';
|
||||
import { useIngredients, useStockAnalytics, useStockMovements, useStockByIngredient, useCreateIngredient, useSoftDeleteIngredient, useHardDeleteIngredient, useAddStock, useConsumeStock, useUpdateIngredient, useTransformationsByIngredient } from '../../../../api/hooks/inventory';
|
||||
import { useCurrentTenant } from '../../../../stores/tenant.store';
|
||||
import { useTenantId } from '../../../../hooks/useTenantId';
|
||||
import { IngredientResponse, StockCreate, StockMovementCreate, IngredientCreate } from '../../../../api/types/inventory';
|
||||
|
||||
const InventoryPage: React.FC = () => {
|
||||
@@ -30,8 +30,7 @@ const InventoryPage: React.FC = () => {
|
||||
const [showDeleteModal, setShowDeleteModal] = useState(false);
|
||||
const [showAddBatch, setShowAddBatch] = useState(false);
|
||||
|
||||
const currentTenant = useCurrentTenant();
|
||||
const tenantId = currentTenant?.id || '';
|
||||
const tenantId = useTenantId();
|
||||
|
||||
// Mutations
|
||||
const createIngredientMutation = useCreateIngredient();
|
||||
|
||||
@@ -1,16 +1,24 @@
|
||||
import React, { useState } from 'react';
|
||||
import { Plus, Minus, ShoppingCart, CreditCard, Banknote, Calculator, User, Receipt } from 'lucide-react';
|
||||
import { Button, Input, Card, Badge } from '../../../../components/ui';
|
||||
import React, { useState, useMemo } from 'react';
|
||||
import { Plus, Minus, ShoppingCart, CreditCard, Banknote, Calculator, User, Receipt, Package, Euro, TrendingUp, Clock } from 'lucide-react';
|
||||
import { Button, Input, Card, Badge, StatsGrid, StatusCard, getStatusColor } from '../../../../components/ui';
|
||||
import { 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';
|
||||
|
||||
interface CartItem {
|
||||
id: string;
|
||||
name: string;
|
||||
price: number;
|
||||
quantity: number;
|
||||
category: string;
|
||||
stock: number;
|
||||
}
|
||||
|
||||
const POSPage: React.FC = () => {
|
||||
const [cart, setCart] = useState<Array<{
|
||||
id: string;
|
||||
name: string;
|
||||
price: number;
|
||||
quantity: number;
|
||||
category: string;
|
||||
}>>([]);
|
||||
const [cart, setCart] = useState<CartItem[]>([]);
|
||||
const [selectedCategory, setSelectedCategory] = useState('all');
|
||||
const [customerInfo, setCustomerInfo] = useState({
|
||||
name: '',
|
||||
@@ -20,73 +28,65 @@ const POSPage: React.FC = () => {
|
||||
const [paymentMethod, setPaymentMethod] = useState<'cash' | 'card' | 'transfer'>('cash');
|
||||
const [cashReceived, setCashReceived] = useState('');
|
||||
|
||||
const products = [
|
||||
{
|
||||
id: '1',
|
||||
name: 'Pan de Molde Integral',
|
||||
price: 4.50,
|
||||
category: 'bread',
|
||||
stock: 25,
|
||||
image: '/api/placeholder/100/100',
|
||||
},
|
||||
{
|
||||
id: '2',
|
||||
name: 'Croissants de Mantequilla',
|
||||
price: 1.50,
|
||||
category: 'pastry',
|
||||
stock: 32,
|
||||
image: '/api/placeholder/100/100',
|
||||
},
|
||||
{
|
||||
id: '3',
|
||||
name: 'Baguette Francesa',
|
||||
price: 2.80,
|
||||
category: 'bread',
|
||||
stock: 18,
|
||||
image: '/api/placeholder/100/100',
|
||||
},
|
||||
{
|
||||
id: '4',
|
||||
name: 'Tarta de Chocolate',
|
||||
price: 25.00,
|
||||
category: 'cake',
|
||||
stock: 8,
|
||||
image: '/api/placeholder/100/100',
|
||||
},
|
||||
{
|
||||
id: '5',
|
||||
name: 'Magdalenas',
|
||||
price: 0.75,
|
||||
category: 'pastry',
|
||||
stock: 48,
|
||||
image: '/api/placeholder/100/100',
|
||||
},
|
||||
{
|
||||
id: '6',
|
||||
name: 'Empanadas',
|
||||
price: 2.50,
|
||||
category: 'other',
|
||||
stock: 24,
|
||||
image: '/api/placeholder/100/100',
|
||||
},
|
||||
];
|
||||
const tenantId = useTenantId();
|
||||
|
||||
const categories = [
|
||||
{ id: 'all', name: 'Todos' },
|
||||
{ id: 'bread', name: 'Panes' },
|
||||
{ id: 'pastry', name: 'Bollería' },
|
||||
{ id: 'cake', name: 'Tartas' },
|
||||
{ id: 'other', name: 'Otros' },
|
||||
];
|
||||
// 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
|
||||
});
|
||||
|
||||
const filteredProducts = products.filter(product =>
|
||||
selectedCategory === 'all' || product.category === selectedCategory
|
||||
);
|
||||
// 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]);
|
||||
|
||||
const filteredProducts = useMemo(() => {
|
||||
return products.filter(product =>
|
||||
selectedCategory === 'all' || product.category === selectedCategory
|
||||
);
|
||||
}, [products, selectedCategory]);
|
||||
|
||||
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 }
|
||||
@@ -99,6 +99,7 @@ const POSPage: React.FC = () => {
|
||||
price: product.price,
|
||||
quantity: 1,
|
||||
category: product.category,
|
||||
stock: product.stock
|
||||
}];
|
||||
}
|
||||
});
|
||||
@@ -109,9 +110,14 @@ const POSPage: React.FC = () => {
|
||||
setCart(prevCart => prevCart.filter(item => item.id !== id));
|
||||
} else {
|
||||
setCart(prevCart =>
|
||||
prevCart.map(item =>
|
||||
item.id === id ? { ...item, quantity } : item
|
||||
)
|
||||
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;
|
||||
})
|
||||
);
|
||||
}
|
||||
};
|
||||
@@ -128,8 +134,8 @@ const POSPage: React.FC = () => {
|
||||
|
||||
const processPayment = () => {
|
||||
if (cart.length === 0) return;
|
||||
|
||||
// Process payment logic here
|
||||
|
||||
// TODO: Integrate with real POS API endpoint
|
||||
console.log('Processing payment:', {
|
||||
cart,
|
||||
customerInfo,
|
||||
@@ -143,53 +149,205 @@ const POSPage: React.FC = () => {
|
||||
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>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="p-6 h-screen flex flex-col">
|
||||
<div className="space-y-6">
|
||||
<PageHeader
|
||||
title="Punto de Venta"
|
||||
description="Sistema de ventas integrado"
|
||||
description="Sistema de ventas para productos terminados"
|
||||
/>
|
||||
|
||||
<div className="flex-1 grid grid-cols-1 lg:grid-cols-3 gap-6 mt-6">
|
||||
{/* 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 */}
|
||||
<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 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-2 md:grid-cols-3 lg:grid-cols-4 gap-4">
|
||||
{filteredProducts.map(product => (
|
||||
<Card
|
||||
key={product.id}
|
||||
className="p-4 cursor-pointer hover:shadow-md transition-shadow"
|
||||
onClick={() => addToCart(product)}
|
||||
>
|
||||
<img
|
||||
src={product.image}
|
||||
alt={product.name}
|
||||
className="w-full h-20 object-cover rounded mb-3"
|
||||
<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)
|
||||
}
|
||||
]}
|
||||
/>
|
||||
<h3 className="font-medium text-sm mb-1 line-clamp-2">{product.name}</h3>
|
||||
<p className="text-lg font-bold text-[var(--color-success)]">€{product.price.toFixed(2)}</p>
|
||||
<p className="text-xs text-[var(--text-tertiary)]">Stock: {product.stock}</p>
|
||||
</Card>
|
||||
))}
|
||||
);
|
||||
})}
|
||||
</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 */}
|
||||
@@ -212,40 +370,47 @@ const POSPage: React.FC = () => {
|
||||
{cart.length === 0 ? (
|
||||
<p className="text-[var(--text-tertiary)] text-center py-8">Carrito vacío</p>
|
||||
) : (
|
||||
cart.map(item => (
|
||||
<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>
|
||||
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 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"
|
||||
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>
|
||||
|
||||
@@ -305,7 +470,7 @@ const POSPage: React.FC = () => {
|
||||
<div className="space-y-3">
|
||||
<div className="grid grid-cols-3 gap-2">
|
||||
<Button
|
||||
variant={paymentMethod === 'cash' ? 'default' : 'outline'}
|
||||
variant={paymentMethod === 'cash' ? 'primary' : 'outline'}
|
||||
onClick={() => setPaymentMethod('cash')}
|
||||
className="flex items-center justify-center"
|
||||
>
|
||||
@@ -313,7 +478,7 @@ const POSPage: React.FC = () => {
|
||||
Efectivo
|
||||
</Button>
|
||||
<Button
|
||||
variant={paymentMethod === 'card' ? 'default' : 'outline'}
|
||||
variant={paymentMethod === 'card' ? 'primary' : 'outline'}
|
||||
onClick={() => setPaymentMethod('card')}
|
||||
className="flex items-center justify-center"
|
||||
>
|
||||
@@ -321,7 +486,7 @@ const POSPage: React.FC = () => {
|
||||
Tarjeta
|
||||
</Button>
|
||||
<Button
|
||||
variant={paymentMethod === 'transfer' ? 'default' : 'outline'}
|
||||
variant={paymentMethod === 'transfer' ? 'primary' : 'outline'}
|
||||
onClick={() => setPaymentMethod('transfer')}
|
||||
className="flex items-center justify-center"
|
||||
>
|
||||
|
||||
Reference in New Issue
Block a user