feat(F-189): completed feature
This commit is contained in:
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
233
project/apps/pos/src/components/ReturnModal.tsx
Normal file
233
project/apps/pos/src/components/ReturnModal.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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();
|
||||
|
||||
@@ -61,6 +61,8 @@ export interface PosReceipt {
|
||||
header: string | null;
|
||||
returnPolicy: string;
|
||||
footer: string | null;
|
||||
originalReceiptNumber?: string;
|
||||
isReturn?: boolean;
|
||||
}
|
||||
|
||||
export interface PosSaleResponse {
|
||||
|
||||
Reference in New Issue
Block a user