diff --git a/apps/web/app/(admin)/pos/page.tsx b/apps/web/app/(admin)/pos/page.tsx new file mode 100644 index 0000000..832d202 --- /dev/null +++ b/apps/web/app/(admin)/pos/page.tsx @@ -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([]); + const [selectedSiteId, setSelectedSiteId] = useState(null); + const [cartItems, setCartItems] = useState([]); + const [showPayment, setShowPayment] = useState(false); + const [registerOpen, setRegisterOpen] = useState(false); + const [registerId, setRegisterId] = useState(); + const [siteLoading, setSiteLoading] = useState(true); + const [successMessage, setSuccessMessage] = useState(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 ( +
+
+
+

Cargando punto de venta...

+
+
+ ); + } + + // No sites available + if (sites.length === 0) { + return ( +
+ + +
🏢
+

+ No hay sedes disponibles +

+

+ Contacta al administrador para configurar una sede. +

+
+
+
+ ); + } + + return ( +
+ {/* Page Header */} +
+

Punto de Venta

+

+ Registra ventas de productos y servicios +

+
+ + {/* Success Message */} + {successMessage && ( +
+ + + + {successMessage} +
+ )} + + {/* Cash Register Status */} + {selectedSiteId && ( + + )} + + {/* Main Layout */} +
+ {/* Products Grid - 70% */} +
+ + + {selectedSiteId ? ( + + ) : ( +
+ Selecciona una sede para ver productos +
+ )} +
+
+
+ + {/* Cart - 30% */} +
+ +
+
+ + {/* Payment Dialog */} + {showPayment && selectedSiteId && ( + setShowPayment(false)} + onPaymentComplete={handlePaymentComplete} + /> + )} + + {/* Register Closed Warning */} + {!registerOpen && cartItems.length > 0 && ( +
+ Abre la caja para poder cobrar +
+ )} +
+ ); +} diff --git a/apps/web/components/pos/cart.tsx b/apps/web/components/pos/cart.tsx new file mode 100644 index 0000000..9065422 --- /dev/null +++ b/apps/web/components/pos/cart.tsx @@ -0,0 +1,199 @@ +"use client"; + +import { Button } from "@/components/ui/button"; +import { Card, CardContent, CardFooter, CardHeader, CardTitle } from "@/components/ui/card"; +import { formatCurrency } from "@/lib/utils"; + +export interface CartItem { + id: string; + name: string; + price: number; + quantity: number; + stock: number; + trackStock: boolean; +} + +interface CartProps { + items: CartItem[]; + onUpdateQuantity: (productId: string, quantity: number) => void; + onRemoveItem: (productId: string) => void; + onClearCart: () => void; + onCheckout: () => void; + disabled?: boolean; +} + +export function Cart({ + items, + onUpdateQuantity, + onRemoveItem, + onClearCart, + onCheckout, + disabled = false, +}: CartProps) { + const subtotal = items.reduce( + (sum, item) => sum + item.price * item.quantity, + 0 + ); + + const handleIncrement = (item: CartItem) => { + // Check stock if tracking + if (item.trackStock && item.quantity >= item.stock) { + return; + } + onUpdateQuantity(item.id, item.quantity + 1); + }; + + const handleDecrement = (item: CartItem) => { + if (item.quantity > 1) { + onUpdateQuantity(item.id, item.quantity - 1); + } else { + onRemoveItem(item.id); + } + }; + + return ( + + +
+ Carrito + {items.length > 0 && ( + + )} +
+
+ + + {items.length === 0 ? ( +
+
+
🛒
+

El carrito esta vacio

+

Selecciona productos para agregar

+
+
+ ) : ( +
    + {items.map((item) => ( +
  • +
    +
    +

    + {item.name} +

    +

    + {formatCurrency(item.price)} c/u +

    +
    + +
    + +
    + {/* Quantity Controls */} +
    + + + {item.quantity} + + +
    + + {/* Subtotal */} + + {formatCurrency(item.price * item.quantity)} + +
    + + {/* Stock Warning */} + {item.trackStock && item.quantity >= item.stock && ( +

    + Stock maximo alcanzado +

    + )} +
  • + ))} +
+ )} +
+ + + {/* Total */} +
+ Total: + + {formatCurrency(subtotal)} + +
+ + {/* Checkout Button */} + +
+
+ ); +} diff --git a/apps/web/components/pos/cash-register-status.tsx b/apps/web/components/pos/cash-register-status.tsx new file mode 100644 index 0000000..8a97da8 --- /dev/null +++ b/apps/web/components/pos/cash-register-status.tsx @@ -0,0 +1,431 @@ +"use client"; + +import { useState, useEffect, useCallback } from "react"; +import { Button } from "@/components/ui/button"; +import { Card, CardContent } from "@/components/ui/card"; +import { Input } from "@/components/ui/input"; +import { formatCurrency, cn } from "@/lib/utils"; + +interface CashRegister { + id: string; + openingAmount: number; + closedAt: string | null; + openedAt: string; + site: { + id: string; + name: string; + }; + user: { + id: string; + firstName: string; + lastName: string; + }; + _count: { + sales: number; + payments: number; + }; + paymentBreakdown?: Record; + totalCashSales?: number; + expectedAmount?: number; +} + +interface CashRegisterStatusProps { + siteId: string; + onRegisterStatusChange: (isOpen: boolean, registerId?: string) => void; +} + +export function CashRegisterStatus({ + siteId, + onRegisterStatusChange, +}: CashRegisterStatusProps) { + const [register, setRegister] = useState(null); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + const [showOpenDialog, setShowOpenDialog] = useState(false); + const [showCloseDialog, setShowCloseDialog] = useState(false); + const [openingAmount, setOpeningAmount] = useState("0"); + const [closingAmount, setClosingAmount] = useState(""); + const [closingNotes, setClosingNotes] = useState(""); + const [actionLoading, setActionLoading] = useState(false); + + // Fetch current register status + const fetchRegisterStatus = useCallback(async () => { + setLoading(true); + setError(null); + try { + const response = await fetch( + `/api/cash-register?siteId=${siteId}&status=open` + ); + if (!response.ok) { + throw new Error("Error al verificar estado de caja"); + } + const data = await response.json(); + setRegister(data); + onRegisterStatusChange(!!data, data?.id); + } catch (err) { + setError(err instanceof Error ? err.message : "Error desconocido"); + onRegisterStatusChange(false); + } finally { + setLoading(false); + } + }, [siteId, onRegisterStatusChange]); + + useEffect(() => { + fetchRegisterStatus(); + }, [fetchRegisterStatus]); + + const handleOpenRegister = async () => { + setActionLoading(true); + setError(null); + try { + const response = await fetch("/api/cash-register", { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ + siteId, + openingAmount: parseFloat(openingAmount) || 0, + }), + }); + + if (!response.ok) { + const errorData = await response.json(); + throw new Error(errorData.error || "Error al abrir caja"); + } + + setShowOpenDialog(false); + setOpeningAmount("0"); + await fetchRegisterStatus(); + } catch (err) { + setError(err instanceof Error ? err.message : "Error desconocido"); + } finally { + setActionLoading(false); + } + }; + + const handleCloseRegister = async () => { + if (!register) return; + + setActionLoading(true); + setError(null); + try { + const response = await fetch(`/api/cash-register/${register.id}`, { + method: "PUT", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ + closingAmount: parseFloat(closingAmount) || 0, + notes: closingNotes || undefined, + }), + }); + + if (!response.ok) { + const errorData = await response.json(); + throw new Error(errorData.error || "Error al cerrar caja"); + } + + setShowCloseDialog(false); + setClosingAmount(""); + setClosingNotes(""); + await fetchRegisterStatus(); + } catch (err) { + setError(err instanceof Error ? err.message : "Error desconocido"); + } finally { + setActionLoading(false); + } + }; + + // Loading state + if (loading) { + return ( + + +
+
+ Verificando estado de caja... +
+
+
+ ); + } + + // Open Register Dialog + if (showOpenDialog) { + return ( + + +

+ Abrir Caja +

+
+
+ + setOpeningAmount(e.target.value)} + min={0} + step={0.01} + disabled={actionLoading} + /> +
+ {error && ( +
+ {error} +
+ )} +
+ + +
+
+
+
+ ); + } + + // Close Register Dialog + if (showCloseDialog && register) { + const expectedAmount = register.expectedAmount || 0; + const closingValue = parseFloat(closingAmount) || 0; + const difference = closingValue - expectedAmount; + + return ( + + +

+ Cerrar Caja +

+
+ {/* Expected Amount */} +
+

Monto esperado en caja

+

+ {formatCurrency(expectedAmount)} +

+

+ Apertura: {formatCurrency(Number(register.openingAmount))} + Ventas efectivo:{" "} + {formatCurrency(register.totalCashSales || 0)} +

+
+ + {/* Closing Amount Input */} +
+ + setClosingAmount(e.target.value)} + min={0} + step={0.01} + disabled={actionLoading} + /> +
+ + {/* Difference Display */} + {closingAmount && ( +
0 + ? "bg-blue-50" + : "bg-red-50" + )} + > +

0 + ? "text-blue-600" + : "text-red-600" + )} + > + {difference === 0 + ? "Cuadre perfecto" + : difference > 0 + ? "Sobrante" + : "Faltante"} +

+

0 + ? "text-blue-700" + : "text-red-700" + )} + > + {formatCurrency(Math.abs(difference))} +

+
+ )} + + {/* Notes */} +
+ + setClosingNotes(e.target.value)} + disabled={actionLoading} + /> +
+ + {error && ( +
+ {error} +
+ )} + +
+ + +
+
+
+
+ ); + } + + // Register is closed + if (!register) { + return ( + + +
+
+
+
+

Caja Cerrada

+

+ Abre la caja para comenzar a vender +

+
+
+ +
+ {error && ( +
+ {error} +
+ )} +
+
+ ); + } + + // Register is open + return ( + + +
+
+
+
+

Caja Abierta

+

+ Por {register.user.firstName} {register.user.lastName} +

+
+
+ + {/* Current Totals */} +
+
+

Ventas hoy

+

+ {register._count.sales} +

+
+
+

Efectivo esperado

+

+ {formatCurrency(register.expectedAmount || 0)} +

+
+ +
+
+ + {/* Payment Breakdown */} + {register.paymentBreakdown && + Object.keys(register.paymentBreakdown).length > 0 && ( +
+ {register.paymentBreakdown.CASH !== undefined && ( + + Efectivo: {formatCurrency(register.paymentBreakdown.CASH)} + + )} + {register.paymentBreakdown.CARD !== undefined && ( + + Tarjeta: {formatCurrency(register.paymentBreakdown.CARD)} + + )} + {register.paymentBreakdown.TRANSFER !== undefined && ( + + Transferencia:{" "} + {formatCurrency(register.paymentBreakdown.TRANSFER)} + + )} +
+ )} + + {error && ( +
+ {error} +
+ )} +
+
+ ); +} diff --git a/apps/web/components/pos/index.ts b/apps/web/components/pos/index.ts new file mode 100644 index 0000000..4184f1c --- /dev/null +++ b/apps/web/components/pos/index.ts @@ -0,0 +1,5 @@ +export { ProductGrid } from "./product-grid"; +export { Cart } from "./cart"; +export type { CartItem } from "./cart"; +export { PaymentDialog } from "./payment-dialog"; +export { CashRegisterStatus } from "./cash-register-status"; diff --git a/apps/web/components/pos/payment-dialog.tsx b/apps/web/components/pos/payment-dialog.tsx new file mode 100644 index 0000000..672f61f --- /dev/null +++ b/apps/web/components/pos/payment-dialog.tsx @@ -0,0 +1,277 @@ +"use client"; + +import { useState } from "react"; +import { Button } from "@/components/ui/button"; +import { Card, CardContent, CardFooter, CardHeader, CardTitle } from "@/components/ui/card"; +import { Input } from "@/components/ui/input"; +import { formatCurrency, cn } from "@/lib/utils"; +import { CartItem } from "./cart"; + +type PaymentMethod = "CASH" | "CARD" | "TRANSFER"; + +interface PaymentDialogProps { + items: CartItem[]; + total: number; + siteId: string; + onClose: () => void; + onPaymentComplete: (saleId: string) => void; +} + +const paymentMethods: { value: PaymentMethod; label: string; icon: string }[] = [ + { value: "CASH", label: "Efectivo", icon: "💵" }, + { value: "TRANSFER", label: "Transferencia", icon: "🏦" }, + { value: "CARD", label: "Terminal", icon: "💳" }, +]; + +export function PaymentDialog({ + items, + total, + siteId, + onClose, + onPaymentComplete, +}: PaymentDialogProps) { + const [selectedMethod, setSelectedMethod] = useState("CASH"); + const [cashReceived, setCashReceived] = useState(""); + const [reference, setReference] = useState(""); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + + const cashReceivedAmount = parseFloat(cashReceived) || 0; + const change = cashReceivedAmount - total; + const canPay = + selectedMethod !== "CASH" || cashReceivedAmount >= total; + + const handleSubmit = async () => { + if (!canPay) return; + + setLoading(true); + setError(null); + + try { + const saleData = { + siteId, + items: items.map((item) => ({ + productId: item.id, + quantity: item.quantity, + price: item.price, + })), + payments: [ + { + amount: total, + method: selectedMethod, + reference: reference || undefined, + }, + ], + }; + + const response = await fetch("/api/sales", { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify(saleData), + }); + + if (!response.ok) { + const errorData = await response.json(); + throw new Error(errorData.error || "Error al procesar la venta"); + } + + const sale = await response.json(); + onPaymentComplete(sale.id); + } catch (err) { + setError(err instanceof Error ? err.message : "Error desconocido"); + } finally { + setLoading(false); + } + }; + + const handleQuickCash = (amount: number) => { + setCashReceived(amount.toString()); + }; + + // Generate quick cash options based on total + const quickCashOptions = [ + Math.ceil(total / 10) * 10, + Math.ceil(total / 50) * 50, + Math.ceil(total / 100) * 100, + Math.ceil(total / 500) * 500, + ].filter((value, index, self) => + value >= total && self.indexOf(value) === index + ).slice(0, 4); + + return ( +
+ + +
+ Procesar Pago + +
+
+ + + {/* Total Display */} +
+

Total a pagar

+

+ {formatCurrency(total)} +

+
+ + {/* Payment Method Selection */} +
+

+ Metodo de pago +

+
+ {paymentMethods.map((method) => ( + + ))} +
+
+ + {/* Cash Payment Options */} + {selectedMethod === "CASH" && ( +
+
+ + setCashReceived(e.target.value)} + className="text-lg font-medium" + min={0} + step={0.01} + disabled={loading} + /> +
+ + {/* Quick Cash Buttons */} +
+ {quickCashOptions.map((amount) => ( + + ))} +
+ + {/* Change Display */} + {cashReceivedAmount > 0 && ( +
= 0 ? "bg-green-50" : "bg-red-50" + )} + > +

= 0 ? "text-green-600" : "text-red-600" + )} + > + {change >= 0 ? "Cambio" : "Falta"} +

+

= 0 ? "text-green-700" : "text-red-700" + )} + > + {formatCurrency(Math.abs(change))} +

+
+ )} +
+ )} + + {/* Transfer Reference */} + {(selectedMethod === "TRANSFER" || selectedMethod === "CARD") && ( +
+ + setReference(e.target.value)} + disabled={loading} + /> +
+ )} + + {/* Error Display */} + {error && ( +
+ {error} +
+ )} +
+ + + + + +
+
+ ); +} diff --git a/apps/web/components/pos/product-grid.tsx b/apps/web/components/pos/product-grid.tsx new file mode 100644 index 0000000..0e75891 --- /dev/null +++ b/apps/web/components/pos/product-grid.tsx @@ -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([]); + const [categories, setCategories] = useState([]); + const [selectedCategory, setSelectedCategory] = useState(null); + const [searchQuery, setSearchQuery] = useState(""); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(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 ( +
+ {/* Search Input */} +
+ setSearchQuery(e.target.value)} + className="w-full" + /> +
+ + {/* Category Tabs */} +
+ + {categories.map((category) => ( + + ))} +
+ + {/* Loading State */} + {loading && ( +
+
+
+

Cargando productos...

+
+
+ )} + + {/* Error State */} + {error && !loading && ( +
+
+

{error}

+ +
+
+ )} + + {/* Empty State */} + {!loading && !error && filteredProducts.length === 0 && ( +
+
+

No se encontraron productos

+ {searchQuery && ( + + )} +
+
+ )} + + {/* Product Grid */} + {!loading && !error && filteredProducts.length > 0 && ( +
+
+ {filteredProducts.map((product) => { + const isOutOfStock = product.trackStock && product.stock <= 0; + const isLowStock = product.lowStock; + + return ( + handleProductClick(product)} + > + {/* Product Image Placeholder */} + {product.image ? ( +
+ {product.name} +
+ ) : ( +
+ + {product.name.charAt(0).toUpperCase()} + +
+ )} + + {/* Product Info */} +

+ {product.name} +

+

+ {formatCurrency(product.price)} +

+ + {/* Stock Indicator */} + {product.trackStock && ( +
+ {isOutOfStock ? ( + + Agotado + + ) : isLowStock ? ( + + Stock bajo: {product.stock} + + ) : ( + + Stock: {product.stock} + + )} +
+ )} +
+ ); + })} +
+
+ )} +
+ ); +}