feat(F-189): completed feature

This commit is contained in:
chattie
2026-08-22 23:01:59 +02:00
parent 0e3c488c85
commit 6b93e91ef4
24 changed files with 1535 additions and 63 deletions

View File

@@ -5,6 +5,7 @@ import DiscountPanel from '@/components/DiscountPanel';
import FreeItemModal from '@/components/FreeItemModal';
import PaymentModal from '@/components/PaymentModal';
import ReceiptModal from '@/components/ReceiptModal';
import ReturnModal from '@/components/ReturnModal';
import { posApi } from '@/lib/api-client';
import { generateIdempotencyKey } from '@/lib/idempotency';
import { formatPrice } from '@/lib/money';
@@ -112,6 +113,9 @@ export default function RegisterPage() {
const [loadingPending, setLoadingPending] = useState(false);
const [restPaymentFor, setRestPaymentFor] = useState<PosPendingSale | null>(null);
const [processingRest, setProcessingRest] = useState(false);
const [returnOrder, setReturnOrder] = useState<{ orderId: string; receipt: PosReceipt } | null>(
null,
);
const loadConfig = useCallback(async () => {
setConfigError('');
@@ -1163,6 +1167,26 @@ export default function RegisterPage() {
receipt={receipt}
initialEmail={customer?.email ?? receipt.customerEmail ?? ''}
onDelivered={resetCashier}
onReturn={
config?.session && config?.terminal
? (orderId, latest) => {
setReturnOrder({ orderId, receipt: latest });
}
: undefined
}
/>
)}
{returnOrder && config?.session && config?.terminal && (
<ReturnModal
receipt={returnOrder.receipt}
orderId={returnOrder.orderId}
cashSessionId={config.session.id}
terminalId={config.terminal.id}
onClose={() => setReturnOrder(null)}
onReturned={(latest) => {
setReturnOrder(null);
setReceipt(latest);
}}
/>
)}
</div>

View File

@@ -9,12 +9,14 @@ interface ReceiptModalProps {
receipt: PosReceipt;
initialEmail?: string;
onDelivered: () => void;
onReturn?: (orderId: string, receipt: PosReceipt) => void;
}
export default function ReceiptModal({
receipt,
initialEmail = '',
onDelivered,
onReturn,
}: ReceiptModalProps) {
const [email, setEmail] = useState(initialEmail);
const [sending, setSending] = useState(false);
@@ -166,6 +168,15 @@ export default function ReceiptModal({
>
🖨 Imprimir ticket
</button>
{onReturn && !receipt.isReturn && (
<button
type="button"
onClick={() => onReturn(receipt.orderId, receipt)}
className="min-h-12 w-full rounded-xl border border-amber-300 bg-amber-50 font-bold text-amber-800"
>
Devolver artículos
</button>
)}
<form onSubmit={emailReceipt} className="flex gap-2">
<label className="sr-only" htmlFor="receipt-email">
Email del ticket

View File

@@ -0,0 +1,233 @@
'use client';
import { useEffect, useMemo, useState } from 'react';
import { posApi } from '@/lib/api-client';
import { formatPrice } from '@/lib/money';
import { generateIdempotencyKey } from '@/lib/idempotency';
import type { PosReceipt, PosSaleResponse } from '@/types/checkout';
interface OrderItem {
id: string;
name: string;
quantity: number;
returnedQuantity: number;
freeItem: boolean;
unitPriceCents: number;
discountCents: number;
}
interface ReturnModalProps {
receipt: PosReceipt;
orderId: string;
cashSessionId: string;
terminalId: string;
onClose: () => void;
onReturned: (newReceipt: PosReceipt) => void;
}
export default function ReturnModal({
receipt,
orderId,
cashSessionId,
terminalId,
onClose,
onReturned,
}: ReturnModalProps) {
const [items, setItems] = useState<OrderItem[]>([]);
const [lines, setLines] = useState<Record<string, number>>({});
const [reason, setReason] = useState('Cambio/devolución');
const [error, setError] = useState('');
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
useEffect(() => {
void (async () => {
try {
const data = await posApi.listOrderItems<{ items: OrderItem[] }>(orderId);
setItems(
data.items.map((item) => ({
id: item.id,
name: item.name,
quantity: item.quantity,
returnedQuantity: item.returnedQuantity,
freeItem: item.freeItem,
unitPriceCents: item.unitPriceCents,
discountCents: item.discountCents,
})),
);
setLines(
Object.fromEntries(data.items.map((item) => [item.id, 0])),
);
} catch (err) {
setError(err instanceof Error ? err.message : 'No se pudieron cargar las líneas');
} finally {
setLoading(false);
}
})();
}, [orderId]);
const totalRefund = useMemo(() => {
return items.reduce((sum, item) => {
const requested = lines[item.id] ?? 0;
if (requested <= 0) return sum;
const perUnit = Math.max(item.unitPriceCents - item.discountCents, 0);
return sum + perUnit * requested;
}, 0);
}, [lines, items]);
const submit = async () => {
setError('');
const itemsPayload = items
.map((item) => ({ orderItemId: item.id, returnedQuantity: lines[item.id] ?? 0 }))
.filter((line) => line.returnedQuantity > 0);
if (itemsPayload.length === 0) {
setError('Selecciona al menos una unidad para devolver');
return;
}
setSaving(true);
try {
const result = await posApi.createReturn<PosSaleResponse>(orderId, {
idempotencyKey: generateIdempotencyKey(),
cashSessionId,
terminalId,
reason: reason.trim() || 'Devolución',
items: itemsPayload,
});
onReturned(result.receipt);
} catch (err) {
setError(err instanceof Error ? err.message : 'No se pudo registrar la devolución');
} finally {
setSaving(false);
}
};
return (
<div
className="fixed inset-0 z-[70] flex items-center justify-center bg-black/40 p-4"
role="dialog"
aria-modal="true"
aria-labelledby="return-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">
Devolución · ticket {receipt.receiptNumber}
</p>
<h2 id="return-title" className="text-2xl font-bold text-gray-900">
Devolver artículos
</h2>
</div>
<button
type="button"
onClick={onClose}
aria-label="Cerrar"
className="text-2xl text-gray-400 hover:text-gray-700"
>
</button>
</div>
{loading ? (
<p className="text-sm text-gray-500">Cargando</p>
) : (
<>
<ul className="space-y-2">
{items.map((item) => {
const requested = lines[item.id] ?? 0;
const max = item.quantity - item.returnedQuantity;
const step = item.freeItem ? max : 1;
return (
<li
key={item.id}
className="flex items-center justify-between rounded-xl border border-gray-200 bg-gray-50 p-3 text-sm"
>
<div>
<p className="font-medium text-gray-800">{item.name}</p>
<p className="text-xs text-gray-500">
Vendidas {item.quantity} · devueltas {item.returnedQuantity}
{item.freeItem ? ' · artículo libre' : ''}
</p>
</div>
<div className="flex items-center gap-2">
<button
type="button"
onClick={() =>
setLines((current) => ({
...current,
[item.id]: Math.max(0, requested - step),
}))
}
disabled={requested <= 0}
className="h-8 w-8 rounded-lg border text-lg font-bold disabled:opacity-40"
>
</button>
<span className="w-8 text-center text-lg font-bold">{requested}</span>
<button
type="button"
onClick={() =>
setLines((current) => ({
...current,
[item.id]: Math.min(max, requested + step),
}))
}
disabled={requested === max}
className="h-8 w-8 rounded-lg border text-lg font-bold disabled:opacity-40"
>
+
</button>
</div>
</li>
);
})}
</ul>
<label className="block text-sm font-medium text-gray-700">
Motivo
<input
type="text"
required
maxLength={500}
value={reason}
onChange={(event) => setReason(event.target.value)}
className="mt-1 w-full rounded-xl border border-gray-300 px-3 py-2"
/>
</label>
<div className="rounded-xl bg-amber-50 p-3 text-sm">
<p className="flex justify-between font-bold">
<span>Reembolso estimado</span>
<span>{formatPrice(totalRefund)}</span>
</p>
</div>
{error && (
<p className="text-sm font-medium text-red-600" aria-live="polite">
{error}
</p>
)}
<div className="flex gap-2">
<button
type="button"
onClick={onClose}
disabled={saving}
className="flex-1 rounded-xl border border-gray-300 py-3 text-sm font-bold disabled:opacity-50"
>
Cancelar
</button>
<button
type="button"
onClick={() => void submit()}
disabled={saving || totalRefund === 0}
className="flex-1 rounded-xl bg-amber-500 py-3 text-sm font-bold text-white disabled:opacity-50"
>
{saving ? 'Procesando…' : 'Confirmar devolución'}
</button>
</div>
</>
)}
</div>
</div>
);
}

View File

@@ -66,6 +66,15 @@ export const posApi = {
method: 'POST',
body: JSON.stringify(data),
}),
/** Apply a partial/full return to a POS sale. */
createReturn: <T>(orderId: string, data: unknown) =>
apiFetch<T>(`/pos/sales/${encodeURIComponent(orderId)}/returns`, {
method: 'POST',
body: JSON.stringify(data),
}),
/** List order items for a sale (used by the return UI). */
listOrderItems: <T>(orderId: string) =>
apiFetch<T>(`/pos/sales/${encodeURIComponent(orderId)}/items`),
/** List POS sales for the session, optionally filtered by state. */
listSales: <T>(params?: { state?: 'PENDING' | 'COMPLETED'; sessionId?: string }) => {
const qs = new URLSearchParams();

View File

@@ -61,6 +61,8 @@ export interface PosReceipt {
header: string | null;
returnPolicy: string;
footer: string | null;
originalReceiptNumber?: string;
isReturn?: boolean;
}
export interface PosSaleResponse {