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,270 @@
"use client";
import { useState, useEffect, useCallback } from "react";
import { useSession } from "next-auth/react";
import { ProductGrid } from "@/components/pos/product-grid";
import { Cart, CartItem } from "@/components/pos/cart";
import { PaymentDialog } from "@/components/pos/payment-dialog";
import { CashRegisterStatus } from "@/components/pos/cash-register-status";
import { Card, CardContent } from "@/components/ui/card";
interface Site {
id: string;
name: string;
}
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;
};
}
export default function POSPage() {
const { data: session } = useSession();
const [sites, setSites] = useState<Site[]>([]);
const [selectedSiteId, setSelectedSiteId] = useState<string | null>(null);
const [cartItems, setCartItems] = useState<CartItem[]>([]);
const [showPayment, setShowPayment] = useState(false);
const [registerOpen, setRegisterOpen] = useState(false);
const [registerId, setRegisterId] = useState<string | undefined>();
const [siteLoading, setSiteLoading] = useState(true);
const [successMessage, setSuccessMessage] = useState<string | null>(null);
// Fetch sites on mount
useEffect(() => {
async function fetchSites() {
try {
const response = await fetch("/api/sites");
if (response.ok) {
const data = await response.json();
const siteList = data.data || [];
setSites(siteList);
// Auto-select first site or user's site
if (siteList.length > 0) {
setSelectedSiteId(siteList[0].id);
}
}
} catch (error) {
console.error("Failed to fetch sites:", error);
} finally {
setSiteLoading(false);
}
}
fetchSites();
}, []);
// Handle register status change
const handleRegisterStatusChange = useCallback(
(isOpen: boolean, regId?: string) => {
setRegisterOpen(isOpen);
setRegisterId(regId);
},
[]
);
// Add product to cart
const handleAddToCart = useCallback((product: Product) => {
setCartItems((prev) => {
const existingItem = prev.find((item) => item.id === product.id);
if (existingItem) {
// Check stock before incrementing
if (product.trackStock && existingItem.quantity >= product.stock) {
return prev;
}
return prev.map((item) =>
item.id === product.id
? { ...item, quantity: item.quantity + 1 }
: item
);
}
return [
...prev,
{
id: product.id,
name: product.name,
price: product.price,
quantity: 1,
stock: product.stock,
trackStock: product.trackStock,
},
];
});
}, []);
// Update item quantity
const handleUpdateQuantity = useCallback(
(productId: string, quantity: number) => {
setCartItems((prev) =>
prev.map((item) =>
item.id === productId ? { ...item, quantity } : item
)
);
},
[]
);
// Remove item from cart
const handleRemoveItem = useCallback((productId: string) => {
setCartItems((prev) => prev.filter((item) => item.id !== productId));
}, []);
// Clear cart
const handleClearCart = useCallback(() => {
setCartItems([]);
}, []);
// Show payment dialog
const handleCheckout = useCallback(() => {
if (!registerOpen) {
return;
}
setShowPayment(true);
}, [registerOpen]);
// Handle payment completion
const handlePaymentComplete = useCallback((saleId: string) => {
setShowPayment(false);
setCartItems([]);
setSuccessMessage("Venta completada exitosamente");
setTimeout(() => setSuccessMessage(null), 3000);
}, []);
// Calculate total
const total = cartItems.reduce(
(sum, item) => sum + item.price * item.quantity,
0
);
// Loading state
if (siteLoading) {
return (
<div className="flex items-center justify-center h-[calc(100vh-8rem)]">
<div className="text-center">
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-primary mx-auto mb-4"></div>
<p className="text-primary-500">Cargando punto de venta...</p>
</div>
</div>
);
}
// No sites available
if (sites.length === 0) {
return (
<div className="flex items-center justify-center h-[calc(100vh-8rem)]">
<Card>
<CardContent className="p-8 text-center">
<div className="text-4xl mb-4">🏢</div>
<h2 className="text-xl font-semibold text-primary-800 mb-2">
No hay sedes disponibles
</h2>
<p className="text-primary-500">
Contacta al administrador para configurar una sede.
</p>
</CardContent>
</Card>
</div>
);
}
return (
<div className="space-y-4">
{/* Page Header */}
<div>
<h1 className="text-2xl font-bold text-primary-800">Punto de Venta</h1>
<p className="text-primary-600">
Registra ventas de productos y servicios
</p>
</div>
{/* Success Message */}
{successMessage && (
<div className="p-4 rounded-lg bg-green-50 border border-green-200 text-green-700 flex items-center gap-2">
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 20 20"
fill="currentColor"
className="w-5 h-5"
>
<path
fillRule="evenodd"
d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.857-9.809a.75.75 0 00-1.214-.882l-3.483 4.79-1.88-1.88a.75.75 0 10-1.06 1.061l2.5 2.5a.75.75 0 001.137-.089l4-5.5z"
clipRule="evenodd"
/>
</svg>
{successMessage}
</div>
)}
{/* Cash Register Status */}
{selectedSiteId && (
<CashRegisterStatus
siteId={selectedSiteId}
onRegisterStatusChange={handleRegisterStatusChange}
/>
)}
{/* Main Layout */}
<div className="flex gap-4 h-[calc(100vh-16rem)]">
{/* Products Grid - 70% */}
<div className="w-[70%]">
<Card className="h-full">
<CardContent className="p-4 h-full">
{selectedSiteId ? (
<ProductGrid
siteId={selectedSiteId}
onAddToCart={handleAddToCart}
/>
) : (
<div className="flex items-center justify-center h-full text-primary-400">
Selecciona una sede para ver productos
</div>
)}
</CardContent>
</Card>
</div>
{/* Cart - 30% */}
<div className="w-[30%]">
<Cart
items={cartItems}
onUpdateQuantity={handleUpdateQuantity}
onRemoveItem={handleRemoveItem}
onClearCart={handleClearCart}
onCheckout={handleCheckout}
disabled={!registerOpen}
/>
</div>
</div>
{/* Payment Dialog */}
{showPayment && selectedSiteId && (
<PaymentDialog
items={cartItems}
total={total}
siteId={selectedSiteId}
onClose={() => setShowPayment(false)}
onPaymentComplete={handlePaymentComplete}
/>
)}
{/* Register Closed Warning */}
{!registerOpen && cartItems.length > 0 && (
<div className="fixed bottom-4 left-1/2 transform -translate-x-1/2 px-4 py-2 bg-amber-500 text-white rounded-lg shadow-lg">
Abre la caja para poder cobrar
</div>
)}
</div>
);
}