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:
260
apps/web/components/pos/product-grid.tsx
Normal file
260
apps/web/components/pos/product-grid.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user