F-201 F-202 F-203: POS cash close report + email + PIN admin
F-201: GET /pos/reports/cash-close/:id with financial summary, sales by state, payments breakdown, items sold. Extended /pos/sessions/:id. F-202: Cash close email sent on session close to smtpReportEmail (best-effort). smtpReportEmail field added to admin SMTP settings. F-203: Admin POS terminal config: selfpayMode, closeSessionRequiresPin, closeSessionPin (4-6 digits) with dedicated settings section.
This commit is contained in:
@@ -116,6 +116,46 @@ export default function RegisterPage() {
|
||||
const [returnOrder, setReturnOrder] = useState<{ orderId: string; receipt: PosReceipt } | null>(
|
||||
null,
|
||||
);
|
||||
// POS-FIX-3/POS-FIX-5: close session
|
||||
const [showCloseSession, setShowCloseSession] = useState(false);
|
||||
const [closingActualCash, setClosingActualCash] = useState('');
|
||||
const [closingPin, setClosingPin] = useState('');
|
||||
const [closingPinError, setClosingPinError] = useState('');
|
||||
const [closingPinStep, setClosingPinStep] = useState(false); // true = PIN entered, show cash dialog
|
||||
const [closing, setClosing] = useState(false);
|
||||
const [closeError, setCloseError] = useState('');
|
||||
|
||||
// POS-FIX-4: toast notification when product added to cart
|
||||
const [addedToast, setAddedToast] = useState<string | null>(null);
|
||||
let toastTimer: ReturnType<typeof setTimeout> | undefined;
|
||||
const showAddedToast = (name: string) => {
|
||||
clearTimeout(toastTimer);
|
||||
setAddedToast(name);
|
||||
toastTimer = setTimeout(() => setAddedToast(null), 2000);
|
||||
};
|
||||
|
||||
// POS-FIX-5: verify PIN then close session
|
||||
const handleVerifyPin = async () => {
|
||||
if (!config?.terminal) return;
|
||||
if (!closingPin || closingPin.length < 4) { setClosingPinError('PIN requerido'); return; }
|
||||
setClosingPinError('');
|
||||
setClosingPinStep(true); // proceed to cash amount dialog
|
||||
};
|
||||
|
||||
const handleCloseSession = async () => {
|
||||
if (!config?.session) return;
|
||||
const actual = parseInt(closingActualCash, 10);
|
||||
if (isNaN(actual) || actual < 0) { setCloseError('Cantidad inválida'); return; }
|
||||
setClosing(true);
|
||||
setCloseError('');
|
||||
try {
|
||||
await posApi.closeSession(config.session.id, actual, closingPin);
|
||||
window.location.reload();
|
||||
} catch (err) {
|
||||
setCloseError(err instanceof Error ? err.message : 'Error al cerrar');
|
||||
setClosing(false);
|
||||
}
|
||||
};
|
||||
|
||||
const loadConfig = useCallback(async () => {
|
||||
setConfigError('');
|
||||
@@ -134,8 +174,9 @@ export default function RegisterPage() {
|
||||
void loadConfig();
|
||||
}, [loadConfig]);
|
||||
|
||||
// FEAT-200: pending sales by terminalId enables cross-day resumption
|
||||
const loadPendingSales = useCallback(async () => {
|
||||
if (!config?.session || config.session.status !== 'OPEN') {
|
||||
if (!config?.terminal || config.session?.status !== 'OPEN') {
|
||||
setPendingSales([]);
|
||||
return;
|
||||
}
|
||||
@@ -143,7 +184,7 @@ export default function RegisterPage() {
|
||||
try {
|
||||
const data = await posApi.listSales<{ items: PosPendingSale[] }>({
|
||||
state: 'PENDING',
|
||||
sessionId: config.session.id,
|
||||
terminalId: config.terminal.id,
|
||||
});
|
||||
setPendingSales(data.items ?? []);
|
||||
} catch {
|
||||
@@ -151,12 +192,22 @@ export default function RegisterPage() {
|
||||
} finally {
|
||||
setLoadingPending(false);
|
||||
}
|
||||
}, [config?.session?.id, config?.session?.status]);
|
||||
}, [config?.terminal?.id, config?.session?.status]);
|
||||
|
||||
// POS-FIX-2: poll pending sales every 10s while session is open
|
||||
useEffect(() => {
|
||||
void loadPendingSales();
|
||||
const interval = setInterval(() => {
|
||||
void loadPendingSales();
|
||||
}, 10_000);
|
||||
return () => clearInterval(interval);
|
||||
}, [loadPendingSales]);
|
||||
|
||||
// POS-FIX-6: re-run when config loads (terminalId changes from undefined to real id)
|
||||
useEffect(() => {
|
||||
if (!config?.terminal?.id || config.session?.status !== 'OPEN') return;
|
||||
void loadPendingSales();
|
||||
}, [config?.terminal?.id, config?.session?.status, loadPendingSales]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!config?.session || config.session.status !== 'OPEN') return;
|
||||
void posApi
|
||||
@@ -276,6 +327,7 @@ export default function RegisterPage() {
|
||||
},
|
||||
];
|
||||
});
|
||||
showAddedToast(product.name);
|
||||
resetAllocations();
|
||||
setSearch('');
|
||||
setSearchResults([]);
|
||||
@@ -606,11 +658,25 @@ export default function RegisterPage() {
|
||||
className="hidden w-64 shrink-0 flex-col border-r bg-amber-50/40 p-3 lg:flex"
|
||||
aria-label="Pendientes de caja"
|
||||
>
|
||||
<div className="mb-3">
|
||||
<h2 className="text-sm font-bold text-[#2D6A4F]">Pendientes de caja</h2>
|
||||
<p className="text-xs text-gray-500">
|
||||
Ventas con saldo pendiente en esta sesión.
|
||||
</p>
|
||||
<div className="mb-3 flex items-center justify-between">
|
||||
<div>
|
||||
<h2 className="text-sm font-bold text-[#2D6A4F]">Pendientes de caja</h2>
|
||||
<p className="text-xs text-gray-500">
|
||||
Ventas con saldo pendiente en esta sesión.
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void loadPendingSales()}
|
||||
disabled={loadingPending}
|
||||
title="Actualizar pendientes"
|
||||
className="rounded-lg p-1.5 text-gray-400 hover:bg-gray-100 hover:text-gray-600 disabled:opacity-40"
|
||||
>
|
||||
<svg className={loadingPending ? 'animate-spin' : ''} width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2}>
|
||||
<path d="M23 4v6h-6M1 20v-6h6" strokeLinecap="round" strokeLinejoin="round"/>
|
||||
<path d="M3.51 9a9 9 0 0114.85-3.36L23 10M1 14l4.64 4.36A9 9 0 0020.49 15" strokeLinecap="round" strokeLinejoin="round"/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
{loadingPending ? (
|
||||
<p className="text-xs text-gray-500">Cargando…</p>
|
||||
@@ -640,6 +706,18 @@ export default function RegisterPage() {
|
||||
</ul>
|
||||
)}
|
||||
</aside>
|
||||
{/* POS-FIX-4: product added toast */}
|
||||
{addedToast && (
|
||||
<div className="pointer-events-none fixed top-6 right-6 z-50 animate-in slide-in-from-top-2 fade-in duration-200">
|
||||
<div className="flex items-center gap-2 rounded-xl bg-green-600 px-4 py-3 text-sm font-bold text-white shadow-lg">
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2.5}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M5 13l4 4L19 7"/>
|
||||
</svg>
|
||||
{addedToast}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<main className="flex min-w-0 flex-1 flex-col overflow-hidden border-r p-4">
|
||||
<div className="mb-3">
|
||||
<div className="mb-2 flex items-center gap-2">
|
||||
@@ -647,6 +725,22 @@ export default function RegisterPage() {
|
||||
<span className="rounded-full bg-green-100 px-2 py-0.5 text-xs text-green-700">
|
||||
Caja abierta
|
||||
</span>
|
||||
{!config?.terminal?.settings?.selfpayMode && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setClosingActualCash('');
|
||||
setClosingPin('');
|
||||
setClosingPinError('');
|
||||
setClosingPinStep(false);
|
||||
setCloseError('');
|
||||
setShowCloseSession(true);
|
||||
}}
|
||||
className="ml-auto rounded-lg border border-red-200 bg-red-50 px-3 py-1 text-xs font-bold text-red-600 hover:bg-red-100"
|
||||
>
|
||||
Cerrar caja
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<input
|
||||
@@ -1162,6 +1256,86 @@ export default function RegisterPage() {
|
||||
onClose={() => setPaymentMethod(null)}
|
||||
/>
|
||||
)}
|
||||
{/* POS-FIX-3/POS-FIX-5: close session — 2-step: PIN then cash */}
|
||||
{showCloseSession && (
|
||||
(config?.terminal?.settings?.closeSessionRequiresPin && !closingPinStep) ? (
|
||||
// STEP 1: PIN
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40">
|
||||
<div className="w-full max-w-xs rounded-2xl bg-white p-6 shadow-xl">
|
||||
<h2 className="mb-2 text-lg font-bold text-gray-900">Cerrar caja</h2>
|
||||
<p className="mb-4 text-sm text-gray-500">Introduce tu PIN de cajero para autorizar el cierre.</p>
|
||||
<input
|
||||
type="password"
|
||||
inputMode="numeric"
|
||||
maxLength={6}
|
||||
value={closingPin}
|
||||
onChange={(e) => { setClosingPin(e.target.value); setClosingPinError(''); }}
|
||||
placeholder="PIN"
|
||||
className="mb-1 w-full rounded-xl border border-gray-300 px-3 py-2 text-center text-2xl tracking-widest outline-none focus:border-[#2D6A4F]"
|
||||
autoFocus
|
||||
/>
|
||||
{closingPinError && <p className="mb-3 text-xs text-red-600">{closingPinError}</p>}
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => { setShowCloseSession(false); setClosingPinStep(false); }}
|
||||
className="flex-1 rounded-xl border border-gray-300 px-4 py-2 text-sm font-medium text-gray-700 hover:bg-gray-50"
|
||||
>
|
||||
Cancelar
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void handleVerifyPin()}
|
||||
className="flex-1 rounded-xl bg-red-600 px-4 py-2 text-sm font-bold text-white hover:bg-red-700"
|
||||
>
|
||||
Confirmar PIN
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
// STEP 2: cash amount
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40">
|
||||
<div className="w-full max-w-sm rounded-2xl bg-white p-6 shadow-xl">
|
||||
<h2 className="mb-4 text-lg font-bold text-gray-900">Cerrar caja</h2>
|
||||
<p className="mb-4 text-sm text-gray-600">
|
||||
Indica el efectivo real en caja para calcular la diferencia.
|
||||
</p>
|
||||
<label className="mb-1 block text-sm font-medium text-gray-700">
|
||||
Efectivo real (céntimos)
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
min="0"
|
||||
value={closingActualCash}
|
||||
onChange={(e) => setClosingActualCash(e.target.value)}
|
||||
className="mb-1 w-full rounded-xl border border-gray-300 px-3 py-2 text-lg outline-none focus:border-[#2D6A4F]"
|
||||
autoFocus
|
||||
/>
|
||||
{closeError && <p className="mb-3 text-xs text-red-600">{closeError}</p>}
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => { setShowCloseSession(false); setClosingPinStep(false); }}
|
||||
disabled={closing}
|
||||
className="flex-1 rounded-xl border border-gray-300 px-4 py-2 text-sm font-medium text-gray-700 hover:bg-gray-50 disabled:opacity-50"
|
||||
>
|
||||
Cancelar
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void handleCloseSession()}
|
||||
disabled={closing}
|
||||
className="flex-1 rounded-xl bg-red-600 px-4 py-2 text-sm font-bold text-white hover:bg-red-700 disabled:opacity-50"
|
||||
>
|
||||
{closing ? 'Cerrando…' : 'Confirmar cierre'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
)}
|
||||
|
||||
{receipt && (
|
||||
<ReceiptModal
|
||||
receipt={receipt}
|
||||
|
||||
Reference in New Issue
Block a user