- 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>
432 lines
13 KiB
TypeScript
432 lines
13 KiB
TypeScript
"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>
|
|
);
|
|
}
|