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,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<PaymentMethod>("CASH");
const [cashReceived, setCashReceived] = useState<string>("");
const [reference, setReference] = useState<string>("");
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(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 (
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50 p-4">
<Card className="w-full max-w-md">
<CardHeader className="border-b">
<div className="flex items-center justify-between">
<CardTitle>Procesar Pago</CardTitle>
<Button
variant="ghost"
size="icon"
onClick={onClose}
disabled={loading}
>
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 20 20"
fill="currentColor"
className="w-5 h-5"
>
<path d="M6.28 5.22a.75.75 0 00-1.06 1.06L8.94 10l-3.72 3.72a.75.75 0 101.06 1.06L10 11.06l3.72 3.72a.75.75 0 101.06-1.06L11.06 10l3.72-3.72a.75.75 0 00-1.06-1.06L10 8.94 6.28 5.22z" />
</svg>
</Button>
</div>
</CardHeader>
<CardContent className="p-6 space-y-6">
{/* Total Display */}
<div className="text-center p-4 bg-primary-50 rounded-lg">
<p className="text-sm text-primary-500 mb-1">Total a pagar</p>
<p className="text-3xl font-bold text-primary-800">
{formatCurrency(total)}
</p>
</div>
{/* Payment Method Selection */}
<div>
<p className="text-sm font-medium text-primary-700 mb-3">
Metodo de pago
</p>
<div className="grid grid-cols-3 gap-2">
{paymentMethods.map((method) => (
<button
key={method.value}
className={cn(
"p-3 rounded-lg border-2 text-center transition-all",
selectedMethod === method.value
? "border-primary bg-primary-50"
: "border-primary-200 hover:border-primary-300"
)}
onClick={() => setSelectedMethod(method.value)}
disabled={loading}
>
<span className="text-2xl block mb-1">{method.icon}</span>
<span className="text-sm font-medium text-primary-700">
{method.label}
</span>
</button>
))}
</div>
</div>
{/* Cash Payment Options */}
{selectedMethod === "CASH" && (
<div className="space-y-4">
<div>
<label className="text-sm font-medium text-primary-700 block mb-2">
Monto recibido
</label>
<Input
type="number"
placeholder="0.00"
value={cashReceived}
onChange={(e) => setCashReceived(e.target.value)}
className="text-lg font-medium"
min={0}
step={0.01}
disabled={loading}
/>
</div>
{/* Quick Cash Buttons */}
<div className="grid grid-cols-4 gap-2">
{quickCashOptions.map((amount) => (
<Button
key={amount}
variant="outline"
size="sm"
onClick={() => handleQuickCash(amount)}
disabled={loading}
>
{formatCurrency(amount)}
</Button>
))}
</div>
{/* Change Display */}
{cashReceivedAmount > 0 && (
<div
className={cn(
"p-3 rounded-lg text-center",
change >= 0 ? "bg-green-50" : "bg-red-50"
)}
>
<p
className={cn(
"text-sm mb-1",
change >= 0 ? "text-green-600" : "text-red-600"
)}
>
{change >= 0 ? "Cambio" : "Falta"}
</p>
<p
className={cn(
"text-2xl font-bold",
change >= 0 ? "text-green-700" : "text-red-700"
)}
>
{formatCurrency(Math.abs(change))}
</p>
</div>
)}
</div>
)}
{/* Transfer Reference */}
{(selectedMethod === "TRANSFER" || selectedMethod === "CARD") && (
<div>
<label className="text-sm font-medium text-primary-700 block mb-2">
Referencia (opcional)
</label>
<Input
type="text"
placeholder="Numero de referencia o autorizacion"
value={reference}
onChange={(e) => setReference(e.target.value)}
disabled={loading}
/>
</div>
)}
{/* Error Display */}
{error && (
<div className="p-3 rounded-lg bg-red-50 text-red-700 text-sm">
{error}
</div>
)}
</CardContent>
<CardFooter className="border-t p-4 gap-2">
<Button
variant="outline"
className="flex-1"
onClick={onClose}
disabled={loading}
>
Cancelar
</Button>
<Button
className="flex-1"
onClick={handleSubmit}
disabled={!canPay || loading}
>
{loading ? (
<>
<div className="animate-spin rounded-full h-4 w-4 border-b-2 border-white mr-2"></div>
Procesando...
</>
) : (
"Confirmar Pago"
)}
</Button>
</CardFooter>
</Card>
</div>
);
}