- 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>
271 lines
7.7 KiB
TypeScript
271 lines
7.7 KiB
TypeScript
"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>
|
|
);
|
|
}
|