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:
431
apps/web/components/pos/cash-register-status.tsx
Normal file
431
apps/web/components/pos/cash-register-status.tsx
Normal file
@@ -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<string, number>;
|
||||
totalCashSales?: number;
|
||||
expectedAmount?: number;
|
||||
}
|
||||
|
||||
interface CashRegisterStatusProps {
|
||||
siteId: string;
|
||||
onRegisterStatusChange: (isOpen: boolean, registerId?: string) => void;
|
||||
}
|
||||
|
||||
export function CashRegisterStatus({
|
||||
siteId,
|
||||
onRegisterStatusChange,
|
||||
}: CashRegisterStatusProps) {
|
||||
const [register, setRegister] = useState<CashRegister | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [showOpenDialog, setShowOpenDialog] = useState(false);
|
||||
const [showCloseDialog, setShowCloseDialog] = useState(false);
|
||||
const [openingAmount, setOpeningAmount] = useState<string>("0");
|
||||
const [closingAmount, setClosingAmount] = useState<string>("");
|
||||
const [closingNotes, setClosingNotes] = useState<string>("");
|
||||
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 (
|
||||
<Card className="mb-4">
|
||||
<CardContent className="p-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="animate-spin rounded-full h-5 w-5 border-b-2 border-primary"></div>
|
||||
<span className="text-primary-500">Verificando estado de caja...</span>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
// Open Register Dialog
|
||||
if (showOpenDialog) {
|
||||
return (
|
||||
<Card className="mb-4">
|
||||
<CardContent className="p-6">
|
||||
<h3 className="text-lg font-semibold text-primary-800 mb-4">
|
||||
Abrir Caja
|
||||
</h3>
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label className="text-sm font-medium text-primary-700 block mb-2">
|
||||
Monto inicial en caja
|
||||
</label>
|
||||
<Input
|
||||
type="number"
|
||||
placeholder="0.00"
|
||||
value={openingAmount}
|
||||
onChange={(e) => setOpeningAmount(e.target.value)}
|
||||
min={0}
|
||||
step={0.01}
|
||||
disabled={actionLoading}
|
||||
/>
|
||||
</div>
|
||||
{error && (
|
||||
<div className="p-3 rounded-lg bg-red-50 text-red-700 text-sm">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
className="flex-1"
|
||||
onClick={() => {
|
||||
setShowOpenDialog(false);
|
||||
setError(null);
|
||||
}}
|
||||
disabled={actionLoading}
|
||||
>
|
||||
Cancelar
|
||||
</Button>
|
||||
<Button
|
||||
className="flex-1"
|
||||
onClick={handleOpenRegister}
|
||||
disabled={actionLoading}
|
||||
>
|
||||
{actionLoading ? "Abriendo..." : "Abrir Caja"}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
// Close Register Dialog
|
||||
if (showCloseDialog && register) {
|
||||
const expectedAmount = register.expectedAmount || 0;
|
||||
const closingValue = parseFloat(closingAmount) || 0;
|
||||
const difference = closingValue - expectedAmount;
|
||||
|
||||
return (
|
||||
<Card className="mb-4">
|
||||
<CardContent className="p-6">
|
||||
<h3 className="text-lg font-semibold text-primary-800 mb-4">
|
||||
Cerrar Caja
|
||||
</h3>
|
||||
<div className="space-y-4">
|
||||
{/* Expected Amount */}
|
||||
<div className="p-3 bg-primary-50 rounded-lg">
|
||||
<p className="text-sm text-primary-500">Monto esperado en caja</p>
|
||||
<p className="text-xl font-bold text-primary-800">
|
||||
{formatCurrency(expectedAmount)}
|
||||
</p>
|
||||
<p className="text-xs text-primary-400 mt-1">
|
||||
Apertura: {formatCurrency(Number(register.openingAmount))} + Ventas efectivo:{" "}
|
||||
{formatCurrency(register.totalCashSales || 0)}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Closing Amount Input */}
|
||||
<div>
|
||||
<label className="text-sm font-medium text-primary-700 block mb-2">
|
||||
Monto real en caja
|
||||
</label>
|
||||
<Input
|
||||
type="number"
|
||||
placeholder="0.00"
|
||||
value={closingAmount}
|
||||
onChange={(e) => setClosingAmount(e.target.value)}
|
||||
min={0}
|
||||
step={0.01}
|
||||
disabled={actionLoading}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Difference Display */}
|
||||
{closingAmount && (
|
||||
<div
|
||||
className={cn(
|
||||
"p-3 rounded-lg text-center",
|
||||
difference === 0
|
||||
? "bg-green-50"
|
||||
: difference > 0
|
||||
? "bg-blue-50"
|
||||
: "bg-red-50"
|
||||
)}
|
||||
>
|
||||
<p
|
||||
className={cn(
|
||||
"text-sm",
|
||||
difference === 0
|
||||
? "text-green-600"
|
||||
: difference > 0
|
||||
? "text-blue-600"
|
||||
: "text-red-600"
|
||||
)}
|
||||
>
|
||||
{difference === 0
|
||||
? "Cuadre perfecto"
|
||||
: difference > 0
|
||||
? "Sobrante"
|
||||
: "Faltante"}
|
||||
</p>
|
||||
<p
|
||||
className={cn(
|
||||
"text-xl font-bold",
|
||||
difference === 0
|
||||
? "text-green-700"
|
||||
: difference > 0
|
||||
? "text-blue-700"
|
||||
: "text-red-700"
|
||||
)}
|
||||
>
|
||||
{formatCurrency(Math.abs(difference))}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Notes */}
|
||||
<div>
|
||||
<label className="text-sm font-medium text-primary-700 block mb-2">
|
||||
Notas (opcional)
|
||||
</label>
|
||||
<Input
|
||||
type="text"
|
||||
placeholder="Observaciones del cierre"
|
||||
value={closingNotes}
|
||||
onChange={(e) => setClosingNotes(e.target.value)}
|
||||
disabled={actionLoading}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="p-3 rounded-lg bg-red-50 text-red-700 text-sm">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
className="flex-1"
|
||||
onClick={() => {
|
||||
setShowCloseDialog(false);
|
||||
setError(null);
|
||||
}}
|
||||
disabled={actionLoading}
|
||||
>
|
||||
Cancelar
|
||||
</Button>
|
||||
<Button
|
||||
className="flex-1"
|
||||
onClick={handleCloseRegister}
|
||||
disabled={actionLoading || !closingAmount}
|
||||
>
|
||||
{actionLoading ? "Cerrando..." : "Cerrar Caja"}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
// Register is closed
|
||||
if (!register) {
|
||||
return (
|
||||
<Card className="mb-4 border-amber-300 bg-amber-50">
|
||||
<CardContent className="p-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-3 h-3 rounded-full bg-amber-500"></div>
|
||||
<div>
|
||||
<p className="font-medium text-amber-800">Caja Cerrada</p>
|
||||
<p className="text-sm text-amber-600">
|
||||
Abre la caja para comenzar a vender
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<Button onClick={() => setShowOpenDialog(true)}>Abrir Caja</Button>
|
||||
</div>
|
||||
{error && (
|
||||
<div className="mt-3 p-3 rounded-lg bg-red-50 text-red-700 text-sm">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
// Register is open
|
||||
return (
|
||||
<Card className="mb-4 border-green-300 bg-green-50">
|
||||
<CardContent className="p-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-3 h-3 rounded-full bg-green-500 animate-pulse"></div>
|
||||
<div>
|
||||
<p className="font-medium text-green-800">Caja Abierta</p>
|
||||
<p className="text-sm text-green-600">
|
||||
Por {register.user.firstName} {register.user.lastName}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Current Totals */}
|
||||
<div className="flex items-center gap-6">
|
||||
<div className="text-right">
|
||||
<p className="text-xs text-green-600">Ventas hoy</p>
|
||||
<p className="font-semibold text-green-800">
|
||||
{register._count.sales}
|
||||
</p>
|
||||
</div>
|
||||
<div className="text-right">
|
||||
<p className="text-xs text-green-600">Efectivo esperado</p>
|
||||
<p className="font-semibold text-green-800">
|
||||
{formatCurrency(register.expectedAmount || 0)}
|
||||
</p>
|
||||
</div>
|
||||
<Button variant="outline" onClick={() => setShowCloseDialog(true)}>
|
||||
Cerrar Caja
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Payment Breakdown */}
|
||||
{register.paymentBreakdown &&
|
||||
Object.keys(register.paymentBreakdown).length > 0 && (
|
||||
<div className="mt-3 pt-3 border-t border-green-200 flex gap-4">
|
||||
{register.paymentBreakdown.CASH !== undefined && (
|
||||
<span className="text-sm text-green-700">
|
||||
Efectivo: {formatCurrency(register.paymentBreakdown.CASH)}
|
||||
</span>
|
||||
)}
|
||||
{register.paymentBreakdown.CARD !== undefined && (
|
||||
<span className="text-sm text-green-700">
|
||||
Tarjeta: {formatCurrency(register.paymentBreakdown.CARD)}
|
||||
</span>
|
||||
)}
|
||||
{register.paymentBreakdown.TRANSFER !== undefined && (
|
||||
<span className="text-sm text-green-700">
|
||||
Transferencia:{" "}
|
||||
{formatCurrency(register.paymentBreakdown.TRANSFER)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{error && (
|
||||
<div className="mt-3 p-3 rounded-lg bg-red-50 text-red-700 text-sm">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user