- 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>
261 lines
8.0 KiB
TypeScript
261 lines
8.0 KiB
TypeScript
"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>
|
|
);
|
|
}
|