feat(pos): add point of sale UI

- Add ProductGrid component with category filters and search
- Add Cart component with quantity controls and totals
- Add PaymentDialog with cash/card/transfer payment methods
- Add CashRegisterStatus for opening/closing register
- Add POS page with 70/30 layout (products/cart)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Ivan
2026-02-01 07:03:59 +00:00
parent cca3b50a6d
commit 422f5c4f29
6 changed files with 1442 additions and 0 deletions

View File

@@ -0,0 +1,260 @@
"use client";
import { useState, useEffect, useMemo } from "react";
import { Card } from "@/components/ui/card";
import { Input } from "@/components/ui/input";
import { Button } from "@/components/ui/button";
import { formatCurrency, cn } from "@/lib/utils";
interface Category {
id: string;
name: string;
description: string | null;
_count: {
products: number;
};
}
interface Product {
id: string;
name: string;
description: string | null;
sku: string | null;
price: number;
stock: number;
minStock: number;
trackStock: boolean;
image: string | null;
lowStock: boolean;
category: {
id: string;
name: string;
};
}
interface ProductGridProps {
siteId: string;
onAddToCart: (product: Product) => void;
}
export function ProductGrid({ siteId, onAddToCart }: ProductGridProps) {
const [products, setProducts] = useState<Product[]>([]);
const [categories, setCategories] = useState<Category[]>([]);
const [selectedCategory, setSelectedCategory] = useState<string | null>(null);
const [searchQuery, setSearchQuery] = useState("");
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
// Fetch categories
useEffect(() => {
async function fetchCategories() {
try {
const response = await fetch("/api/products/categories");
if (!response.ok) {
throw new Error("Error al cargar categorias");
}
const data = await response.json();
setCategories(data);
} catch (err) {
console.error("Error fetching categories:", err);
}
}
fetchCategories();
}, []);
// Fetch products
useEffect(() => {
async function fetchProducts() {
setLoading(true);
setError(null);
try {
const params = new URLSearchParams({ siteId, isActive: "true" });
if (selectedCategory) {
params.append("categoryId", selectedCategory);
}
if (searchQuery) {
params.append("search", searchQuery);
}
const response = await fetch(`/api/products?${params.toString()}`);
if (!response.ok) {
throw new Error("Error al cargar productos");
}
const data = await response.json();
setProducts(data);
} catch (err) {
setError(err instanceof Error ? err.message : "Error desconocido");
} finally {
setLoading(false);
}
}
// Debounce search
const timeoutId = setTimeout(fetchProducts, searchQuery ? 300 : 0);
return () => clearTimeout(timeoutId);
}, [siteId, selectedCategory, searchQuery]);
// Filter products based on category and search
const filteredProducts = useMemo(() => {
return products;
}, [products]);
const handleProductClick = (product: Product) => {
if (product.trackStock && product.stock <= 0) {
return; // Cannot add out of stock products
}
onAddToCart(product);
};
return (
<div className="flex flex-col h-full">
{/* Search Input */}
<div className="mb-4">
<Input
type="text"
placeholder="Buscar productos..."
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="w-full"
/>
</div>
{/* Category Tabs */}
<div className="flex gap-2 mb-4 overflow-x-auto pb-2">
<Button
variant={selectedCategory === null ? "default" : "outline"}
size="sm"
onClick={() => setSelectedCategory(null)}
>
Todos
</Button>
{categories.map((category) => (
<Button
key={category.id}
variant={selectedCategory === category.id ? "default" : "outline"}
size="sm"
onClick={() => setSelectedCategory(category.id)}
className="whitespace-nowrap"
>
{category.name}
</Button>
))}
</div>
{/* Loading State */}
{loading && (
<div className="flex-1 flex items-center justify-center">
<div className="text-center">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary mx-auto mb-2"></div>
<p className="text-primary-500">Cargando productos...</p>
</div>
</div>
)}
{/* Error State */}
{error && !loading && (
<div className="flex-1 flex items-center justify-center">
<div className="text-center text-red-500">
<p>{error}</p>
<Button
variant="outline"
size="sm"
className="mt-2"
onClick={() => setSearchQuery("")}
>
Reintentar
</Button>
</div>
</div>
)}
{/* Empty State */}
{!loading && !error && filteredProducts.length === 0 && (
<div className="flex-1 flex items-center justify-center">
<div className="text-center text-primary-500">
<p>No se encontraron productos</p>
{searchQuery && (
<Button
variant="outline"
size="sm"
className="mt-2"
onClick={() => setSearchQuery("")}
>
Limpiar busqueda
</Button>
)}
</div>
</div>
)}
{/* Product Grid */}
{!loading && !error && filteredProducts.length > 0 && (
<div className="flex-1 overflow-y-auto">
<div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-3">
{filteredProducts.map((product) => {
const isOutOfStock = product.trackStock && product.stock <= 0;
const isLowStock = product.lowStock;
return (
<Card
key={product.id}
className={cn(
"p-4 cursor-pointer transition-all hover:shadow-md",
isOutOfStock && "opacity-50 cursor-not-allowed bg-gray-100",
isLowStock && !isOutOfStock && "border-amber-400 border-2"
)}
onClick={() => handleProductClick(product)}
>
{/* Product Image Placeholder */}
{product.image ? (
<div className="w-full h-24 mb-3 rounded-md overflow-hidden">
<img
src={product.image}
alt={product.name}
className="w-full h-full object-cover"
/>
</div>
) : (
<div className="w-full h-24 mb-3 bg-primary-100 rounded-md flex items-center justify-center">
<span className="text-3xl text-primary-300">
{product.name.charAt(0).toUpperCase()}
</span>
</div>
)}
{/* Product Info */}
<h3 className="font-medium text-primary-800 text-sm truncate">
{product.name}
</h3>
<p className="text-primary-600 font-bold mt-1">
{formatCurrency(product.price)}
</p>
{/* Stock Indicator */}
{product.trackStock && (
<div className="mt-2">
{isOutOfStock ? (
<span className="text-xs text-red-500 font-medium">
Agotado
</span>
) : isLowStock ? (
<span className="text-xs text-amber-600 font-medium">
Stock bajo: {product.stock}
</span>
) : (
<span className="text-xs text-primary-400">
Stock: {product.stock}
</span>
)}
</div>
)}
</Card>
);
})}
</div>
</div>
)}
</div>
);
}