feat(POS-FIX-9): completed feature

This commit is contained in:
chattie
2026-08-24 07:44:30 +02:00
parent e15918058f
commit 75d0d5b6a7
8 changed files with 212 additions and 16 deletions

View File

@@ -121,6 +121,8 @@ export default function RegisterPage() {
// POS-FIX-8: delete confirmation for pending sales
const [deleteConfirmFor, setDeleteConfirmFor] = useState<PosPendingSale | null>(null);
const [recoveringSaleId, setRecoveringSaleId] = useState<string | null>(null);
// POS-FIX-9: merge dialog when recovering with cart items
const [mergePendingSale, setMergePendingSale] = useState<PosPendingSale | null>(null);
// POS-FIX-3/POS-FIX-5: close session
const [showCloseSession, setShowCloseSession] = useState(false);
const [closingActualCash, setClosingActualCash] = useState('');
@@ -199,13 +201,7 @@ export default function RegisterPage() {
}
}, [config?.terminal?.id, config?.session?.status]);
// POS-FIX-2: poll pending sales every 10s while session is open
useEffect(() => {
const interval = setInterval(() => {
void loadPendingSales();
}, 10_000);
return () => clearInterval(interval);
}, [loadPendingSales]);
// POS-FIX-9: removed 10s polling — only manual refresh via button
// POS-FIX-6: re-run when config loads (terminalId changes from undefined to real id)
useEffect(() => {
@@ -568,6 +564,17 @@ export default function RegisterPage() {
// POS-FIX-8: recover a parked sale into the current cart
const recoverSale = async (sale: PosPendingSale) => {
// POS-FIX-9: if cart has items, show merge dialog instead of auto-merge
if (cart.length > 0) {
setRecoveringSaleId(sale.id);
setMergePendingSale(sale);
return;
}
await doRecoverSale(sale);
};
// POS-FIX-9: actual recovery after merge dialog decision
const doRecoverSale = async (sale: PosPendingSale) => {
setRecoveringSaleId(sale.id);
setError('');
try {
@@ -588,12 +595,8 @@ export default function RegisterPage() {
quantity: item.quantity - item.returnedQuantity,
stock: null,
}));
if (cart.length > 0) {
// Merge: append recovered items to existing cart
setCart((prev) => [...prev, ...items]);
} else {
setCart(items);
}
setCart(items);
setMergePendingSale(null);
void loadPendingSales();
} catch (err) {
setError(err instanceof Error ? err.message : 'No se pudo recuperar la venta');
@@ -602,6 +605,74 @@ export default function RegisterPage() {
}
};
// POS-FIX-9: park current cart and recover selected sale
const parkAndRecover = async () => {
if (!config?.session || !mergePendingSale) return;
setRecoveringSaleId(mergePendingSale.id);
setError('');
try {
// Park current cart first
await posApi.createSale<PosSaleResponse>({
idempotencyKey: generateIdempotencyKey(),
cashSessionId: config.session.id,
terminalId: config.terminal.id,
items: cart.map((item) =>
item.kind === 'free'
? {
kind: 'free',
name: item.name,
unitPriceCents: item.unitPriceCents,
quantity: item.quantity,
}
: {
kind: 'stock',
variantId: item.variantId,
quantity: item.quantity,
discountCents: item.discountCents,
},
),
payments: [],
});
// Then recover the selected sale
await doRecoverSale(mergePendingSale);
} catch (err) {
setError(err instanceof Error ? err.message : 'No se pudo aparcar y recuperar');
}
};
// POS-FIX-9: add recovered items to current cart (merge)
const mergeRecoveredItems = async () => {
if (!mergePendingSale) return;
setRecoveringSaleId(mergePendingSale.id);
setError('');
try {
const data = (await posApi.listOrderItems<{ items: RecoveredOrderItem[] }>(mergePendingSale.id));
const items: CartItem[] = data.items
.filter((item) => !item.freeItem)
.map((item) => ({
lineId: `${item.id}-recovered-${Date.now()}`,
kind: 'stock' as const,
variantId: null,
productId: null,
sku: item.sku,
name: item.name,
ean: null,
unitPriceCents: item.unitPriceCents,
discountCents: item.discountCents,
taxCents: 0,
quantity: item.quantity - item.returnedQuantity,
stock: null,
}));
setCart((prev) => [...prev, ...items]);
setMergePendingSale(null);
void loadPendingSales();
} catch (err) {
setError(err instanceof Error ? err.message : 'No se pudo añadir artículos');
} finally {
setRecoveringSaleId(null);
}
};
// POS-FIX-8: delete a parked sale
const deleteSale = async (sale: PosPendingSale) => {
setError('');
@@ -1447,6 +1518,50 @@ export default function RegisterPage() {
</div>
)}
{/* POS-FIX-9: merge dialog when recovering a sale with items in cart */}
{mergePendingSale && (
<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">Ticket con artículos</h2>
<p className="mb-1 text-sm text-gray-600">
Hay {cart.length} {cart.length === 1 ? 'artículo' : 'artículos'} en el ticket actual.
</p>
<p className="mb-4 text-sm text-gray-500">
¿Qué deseas hacer con la venta {mergePendingSale.receiptNumber ?? mergePendingSale.id.slice(0, 8)}?
</p>
<div className="flex flex-col gap-2">
<button
type="button"
onClick={() => void parkAndRecover()}
disabled={recoveringSaleId !== null}
className="flex-1 rounded-xl border border-amber-300 bg-amber-50 px-4 py-2.5 text-sm font-bold text-amber-700 hover:bg-amber-100 disabled:opacity-50"
>
💾 Dejar actual pendiente y recuperar
</button>
<button
type="button"
onClick={() => void mergeRecoveredItems()}
disabled={recoveringSaleId !== null}
className="flex-1 rounded-xl border border-green-300 bg-green-50 px-4 py-2.5 text-sm font-bold text-green-700 hover:bg-green-100 disabled:opacity-50"
>
Añadir al ticket actual
</button>
<button
type="button"
onClick={() => {
setMergePendingSale(null);
setRecoveringSaleId(null);
}}
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>
</div>
{error && <p className="mt-2 text-center text-xs text-red-600">{error}</p>}
</div>
</div>
)}
{/* POS-FIX-3/POS-FIX-5: close session — 2-step: PIN then cash */}
{showCloseSession && (
(config?.terminal?.settings?.closeSessionRequiresPin && !closingPinStep) ? (