feat(F-188): completed feature
This commit is contained in:
@@ -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}
|
||||
|
||||
@@ -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`, {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user