feat(F-188): completed feature

This commit is contained in:
chattie
2026-08-22 22:44:37 +02:00
parent c5e5b4c48c
commit 0e3c488c85
21 changed files with 1081 additions and 71 deletions

View File

@@ -11,6 +11,7 @@ import { formatPrice } from '@/lib/money';
import type {
PaymentAllocation,
PaymentMethod,
PosPendingSale,
PosReceipt,
PosSaleResponse,
} from '@/types/checkout';
@@ -107,6 +108,10 @@ export default function RegisterPage() {
const [creatingCustomer, setCreatingCustomer] = useState(false);
const [processing, setProcessing] = useState(false);
const [error, setError] = useState('');
const [pendingSales, setPendingSales] = useState<PosPendingSale[]>([]);
const [loadingPending, setLoadingPending] = useState(false);
const [restPaymentFor, setRestPaymentFor] = useState<PosPendingSale | null>(null);
const [processingRest, setProcessingRest] = useState(false);
const loadConfig = useCallback(async () => {
setConfigError('');
@@ -125,6 +130,29 @@ export default function RegisterPage() {
void loadConfig();
}, [loadConfig]);
const loadPendingSales = useCallback(async () => {
if (!config?.session || config.session.status !== 'OPEN') {
setPendingSales([]);
return;
}
setLoadingPending(true);
try {
const data = await posApi.listSales<{ items: PosPendingSale[] }>({
state: 'PENDING',
sessionId: config.session.id,
});
setPendingSales(data.items ?? []);
} catch {
setPendingSales([]);
} finally {
setLoadingPending(false);
}
}, [config?.session?.id, config?.session?.status]);
useEffect(() => {
void loadPendingSales();
}, [loadPendingSales]);
useEffect(() => {
if (!config?.session || config.session.status !== 'OPEN') return;
void posApi
@@ -340,8 +368,12 @@ export default function RegisterPage() {
setError('Carrito vacío');
return;
}
if (remainingCents !== 0 || paidCents !== totals.total) {
setError('Asigna el total entre las formas de pago antes de confirmar');
if (payments.length === 0 || paidCents <= 0) {
setError('Asigna al menos un pago antes de confirmar');
return;
}
if (paidCents > totals.total) {
setError('Los pagos asignados superan el total');
return;
}
setProcessing(true);
@@ -374,6 +406,8 @@ export default function RegisterPage() {
...(customer ? { customerId: customer.id } : {}),
});
setReceipt(result.receipt);
setRestPaymentFor(null);
void loadPendingSales();
} catch (err) {
setError(err instanceof Error ? err.message : 'No se pudo confirmar la venta');
} finally {
@@ -381,6 +415,40 @@ export default function RegisterPage() {
}
};
const openRestPayment = (sale: PosPendingSale) => {
setRestPaymentFor(sale);
setError('');
};
const submitRestPayment = async (allocation: PaymentAllocation) => {
if (!config?.session || !restPaymentFor) return;
setProcessingRest(true);
setError('');
try {
const result = await posApi.payRest<PosSaleResponse>(restPaymentFor.id, {
idempotencyKey: generateIdempotencyKey(),
cashSessionId: config.session.id,
terminalId: config.terminal.id,
payments: [
{
methodCode: allocation.methodCode,
amountCents: allocation.amountCents,
...(allocation.kind === 'cash' ? { tenderedCents: allocation.tenderedCents } : {}),
},
],
});
setRestPaymentFor(null);
if (result.state === 'COMPLETED' && result.outstandingCents === 0) {
setReceipt(result.receipt);
}
void loadPendingSales();
} catch (err) {
setError(err instanceof Error ? err.message : 'No se pudo aplicar el cobro');
} finally {
setProcessingRest(false);
}
};
const resetCashier = () => {
setReceipt(null);
setCart([]);
@@ -530,6 +598,44 @@ export default function RegisterPage() {
return (
<div className="flex h-screen" style={{ '--color-primary': '#2D6A4F' } as React.CSSProperties}>
<aside
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>
{loadingPending ? (
<p className="text-xs text-gray-500">Cargando</p>
) : pendingSales.length === 0 ? (
<p className="text-xs text-gray-500">Sin ventas pendientes.</p>
) : (
<ul className="flex flex-1 flex-col gap-2 overflow-y-auto">
{pendingSales.map((sale) => (
<li
key={sale.id}
className="rounded-xl border border-amber-200 bg-white p-3 text-sm shadow-sm"
>
<p className="font-semibold text-gray-800">{sale.receiptNumber ?? sale.id.slice(0, 8)}</p>
<p className="text-xs text-gray-500">
Total {formatPrice(sale.totalCents)} · pendiente {formatPrice(sale.outstandingCents)}
</p>
<button
type="button"
onClick={() => openRestPayment(sale)}
disabled={processingRest}
className="mt-2 w-full rounded-lg bg-amber-500 px-3 py-1.5 text-xs font-bold text-white disabled:opacity-50"
>
Cobrar resto
</button>
</li>
))}
</ul>
)}
</aside>
<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">
@@ -939,16 +1045,34 @@ export default function RegisterPage() {
</button>
))}
</div>
<button
type="button"
onClick={() => void confirmSale()}
disabled={
processing || cart.length === 0 || remainingCents !== 0 || paidCents !== totals.total
}
className="mt-3 min-h-16 w-full rounded-xl bg-[#1B4332] text-lg font-bold text-white disabled:opacity-40"
>
{processing ? 'Confirmando…' : 'Confirmar y cerrar ticket'}
</button>
<div className="mt-3 grid grid-cols-2 gap-2">
<button
type="button"
onClick={() => void confirmSale()}
disabled={
processing ||
cart.length === 0 ||
payments.length === 0 ||
paidCents <= 0 ||
paidCents > totals.total
}
className="min-h-16 rounded-xl bg-[#1B4332] text-lg font-bold text-white disabled:opacity-40"
>
{processing
? 'Confirmando…'
: paidCents < totals.total
? 'Guardar pendiente'
: 'Cobrar e imprimir'}
</button>
<button
type="button"
onClick={() => resetCashier()}
disabled={processing || cart.length === 0}
className="min-h-16 rounded-xl border border-gray-300 bg-white text-sm font-bold text-gray-700 disabled:opacity-40"
>
Vaciar caja
</button>
</div>
</aside>
{showDiscountPanel && selectedItem && (
@@ -976,6 +1100,64 @@ export default function RegisterPage() {
onClose={() => setPaymentMethod(null)}
/>
)}
{restPaymentFor && (
<div
className="fixed inset-0 z-50 flex items-center justify-center bg-black/40 p-4"
role="dialog"
aria-modal="true"
aria-labelledby="rest-payment-title"
>
<div className="w-full max-w-md space-y-4 rounded-2xl bg-white p-6 shadow-2xl">
<div className="flex items-start justify-between gap-4">
<div>
<p className="text-sm font-semibold text-amber-600">
Pendiente {formatPrice(restPaymentFor.outstandingCents)}
</p>
<h2 id="rest-payment-title" className="text-2xl font-bold text-gray-900">
Cobrar resto
</h2>
<p className="mt-1 text-xs text-gray-500">
Venta {restPaymentFor.receiptNumber ?? restPaymentFor.id.slice(0, 8)}
</p>
</div>
<button
type="button"
onClick={() => {
if (!processingRest) setRestPaymentFor(null);
}}
aria-label="Cerrar"
className="text-2xl text-gray-400 hover:text-gray-700"
>
</button>
</div>
<div className="grid grid-cols-2 gap-2">
{config.paymentMethods.map((method) => (
<button
key={method.id}
type="button"
onClick={() => setPaymentMethod(method)}
disabled={processingRest}
className={`min-h-14 rounded-xl px-2 font-bold text-white disabled:opacity-40 ${method.kind === 'cash' ? 'bg-green-600' : method.kind === 'card' ? 'bg-blue-600' : 'bg-slate-700'}`}
>
{method.kind === 'cash' ? '💵' : method.kind === 'card' ? '💳' : '◉'} {method.label}
</button>
))}
</div>
{error && <p className="text-sm text-red-600">{error}</p>}
</div>
</div>
)}
{paymentMethod && restPaymentFor && (
<PaymentModal
method={paymentMethod}
remainingCents={restPaymentFor.outstandingCents}
onAdd={(allocation) => {
void submitRestPayment(allocation);
}}
onClose={() => setPaymentMethod(null)}
/>
)}
{receipt && (
<ReceiptModal
receipt={receipt}

View File

@@ -60,6 +60,20 @@ export const posApi = {
/** Atomically confirm a fully allocated sale. */
createSale: <T>(data: unknown) =>
apiFetch<T>('/pos/sales', { method: 'POST', body: JSON.stringify(data) }),
/** Apply additional payments to a pending POS sale. */
payRest: <T>(orderId: string, data: unknown) =>
apiFetch<T>(`/pos/sales/${encodeURIComponent(orderId)}/payments`, {
method: 'POST',
body: JSON.stringify(data),
}),
/** List POS sales for the session, optionally filtered by state. */
listSales: <T>(params?: { state?: 'PENDING' | 'COMPLETED'; sessionId?: string }) => {
const qs = new URLSearchParams();
if (params?.state) qs.set('state', params.state);
if (params?.sessionId) qs.set('sessionId', params.sessionId);
const tail = qs.toString();
return apiFetch<T>(`/pos/sales${tail ? `?${tail}` : ''}`);
},
/** Email the immutable generated receipt. */
emailReceipt: <T>(orderId: string, email: string) =>
apiFetch<T>(`/pos/sales/${encodeURIComponent(orderId)}/receipt/email`, {

View File

@@ -66,7 +66,23 @@ export interface PosReceipt {
export interface PosSaleResponse {
orderId: string;
receiptNumber: string;
state: 'PENDING' | 'COMPLETED';
totalCents: number;
paidCents: number;
outstandingCents: number;
changeCents: number;
receipt: PosReceipt;
}
export interface PosPendingSale {
id: string;
state: 'PENDING' | 'COMPLETED';
totalCents: number;
paidCents: number;
outstandingCents: number;
subtotalCents: number;
discountCents: number;
createdAt: string;
cashierEmail?: string | null;
receiptNumber?: string | null;
}