feat(POS-FIX-8): completed feature

This commit is contained in:
chattie
2026-08-24 07:39:32 +02:00
parent 2467a02dd4
commit e15918058f
11 changed files with 314 additions and 10 deletions

View File

@@ -15,6 +15,7 @@ import type {
PosPendingSale,
PosReceipt,
PosSaleResponse,
RecoveredOrderItem,
} from '@/types/checkout';
interface CartItem {
@@ -117,6 +118,9 @@ export default function RegisterPage() {
null,
);
const [showClearConfirm, setShowClearConfirm] = useState(false);
// POS-FIX-8: delete confirmation for pending sales
const [deleteConfirmFor, setDeleteConfirmFor] = useState<PosPendingSale | null>(null);
const [recoveringSaleId, setRecoveringSaleId] = useState<string | null>(null);
// POS-FIX-3/POS-FIX-5: close session
const [showCloseSession, setShowCloseSession] = useState(false);
const [closingActualCash, setClosingActualCash] = useState('');
@@ -562,6 +566,54 @@ export default function RegisterPage() {
}
};
// POS-FIX-8: recover a parked sale into the current cart
const recoverSale = async (sale: PosPendingSale) => {
setRecoveringSaleId(sale.id);
setError('');
try {
const data = (await posApi.listOrderItems<{ items: RecoveredOrderItem[] }>(sale.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,
}));
if (cart.length > 0) {
// Merge: append recovered items to existing cart
setCart((prev) => [...prev, ...items]);
} else {
setCart(items);
}
void loadPendingSales();
} catch (err) {
setError(err instanceof Error ? err.message : 'No se pudo recuperar la venta');
} finally {
setRecoveringSaleId(null);
}
};
// POS-FIX-8: delete a parked sale
const deleteSale = async (sale: PosPendingSale) => {
setError('');
try {
await posApi.deleteSale(sale.id);
setDeleteConfirmFor(null);
void loadPendingSales();
} catch (err) {
setError(err instanceof Error ? err.message : 'No se pudo eliminar la venta');
}
};
const resetCashier = () => {
setReceipt(null);
setCart([]);
@@ -751,14 +803,24 @@ export default function RegisterPage() {
<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>
<div className="mt-2 flex gap-1">
<button
type="button"
onClick={() => void recoverSale(sale)}
disabled={recoveringSaleId === sale.id}
className="flex-1 rounded-lg bg-[#2D6A4F] px-2 py-1.5 text-xs font-bold text-white disabled:opacity-50"
>
{recoveringSaleId === sale.id ? 'Cargando…' : 'Recuperar'}
</button>
<button
type="button"
onClick={() => setDeleteConfirmFor(sale)}
className="flex items-center justify-center rounded-lg bg-red-50 px-2 py-1.5 text-xs font-bold text-red-600 hover:bg-red-100"
aria-label="Eliminar venta pendiente"
>
🗑
</button>
</div>
</li>
))}
</ul>
@@ -1350,6 +1412,41 @@ export default function RegisterPage() {
</div>
)}
{/* POS-FIX-8: delete confirmation for pending sale */}
{deleteConfirmFor && (
<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">¿Eliminar venta?</h2>
<p className="mb-1 text-sm text-gray-600">
Ticket {deleteConfirmFor.receiptNumber ?? deleteConfirmFor.id.slice(0, 8)}
</p>
<p className="mb-4 text-sm text-gray-500">
Total {formatPrice(deleteConfirmFor.totalCents)} · pendiente {formatPrice(deleteConfirmFor.outstandingCents)}
</p>
<p className="mb-4 text-xs text-amber-600">
Se liberará el stock reservado y no se podrá deshacer.
</p>
<div className="flex gap-2">
<button
type="button"
onClick={() => setDeleteConfirmFor(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>
<button
type="button"
onClick={() => void deleteSale(deleteConfirmFor)}
className="flex-1 rounded-xl bg-red-600 px-4 py-2 text-sm font-bold text-white hover:bg-red-700"
>
Eliminar
</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) ? (

View File

@@ -93,6 +93,12 @@ export const posApi = {
const tail = qs.toString();
return apiFetch<T>(`/pos/sales${tail ? `?${tail}` : ''}`);
},
/** Delete a pending parked sale and restore inventory. */
deleteSale: <T>(orderId: string) =>
apiFetch<T>(`/pos/sales/${encodeURIComponent(orderId)}`, { method: 'DELETE' }),
/** List order items (used for recovering a parked sale). */
listOrderItems: <T>(orderId: string) =>
apiFetch<T>(`/pos/sales/${encodeURIComponent(orderId)}/items`),
/** Email the immutable generated receipt. */
emailReceipt: <T>(orderId: string, email: string) =>
apiFetch<T>(`/pos/sales/${encodeURIComponent(orderId)}/receipt/email`, {

View File

@@ -88,3 +88,14 @@ export interface PosPendingSale {
cashierEmail?: string | null;
receiptNumber?: string | null;
}
export interface RecoveredOrderItem {
id: string;
name: string;
sku: string;
quantity: number;
returnedQuantity: number;
freeItem: boolean;
unitPriceCents: number;
discountCents: number;
}

View File

@@ -1780,6 +1780,93 @@ export async function registerPosRoutes(app: FastifyInstance, deps: PosRouteDeps
},
);
// ── POS-FIX-8: DELETE /pos/sales/:id — remove a pending parked sale ───────
app.delete<{ Params: { id: string } }>(
'/pos/sales/:id',
{
schema: {
tags: ['POS Terminal'],
summary: 'Remove a pending parked sale and restore inventory',
params: { type: 'object', properties: { id: { type: 'string', format: 'uuid' } } },
response: { 401: errorSchema, 403: errorSchema, 404: errorSchema, 409: errorSchema },
} as FastifySchema,
},
async (request, reply) => {
const user = await authenticate(request);
requireAnyRole(user, ['admin', 'pos_manager', 'pos_cashier'] as ReadonlyArray<Role>);
const { id } = parseJson(idParamSchema, request.params);
const client = await pool.connect();
try {
await client.query('BEGIN');
const orderRows = await client.query<{
id: string;
state: string;
store_id: string;
}>(
`SELECT id, state, store_id FROM orders_orders WHERE id = $1 FOR UPDATE`,
[id],
);
const order = orderRows.rows[0];
if (!order) throw new AppError(404, 'POS_SALE_NOT_FOUND', 'Venta no encontrada');
if (order.state !== 'PENDING') {
throw new AppError(409, 'POS_SALE_NOT_PENDING', 'Solo se pueden eliminar ventas pendientes');
}
const paymentRows = await client.query<{ cnt: string }>(
`SELECT COUNT(*) AS cnt FROM payments_transactions WHERE order_id = $1 AND status = 'succeeded'`,
[id],
);
if (Number(paymentRows.rows[0]?.cnt ?? 0) > 0) {
throw new AppError(409, 'POS_SALE_HAS_PAYMENTS', 'No se puede eliminar una venta con pagos registrados');
}
const itemRows = await client.query<{ variant_id: string | null; quantity: number; is_free_item: boolean }>(
`SELECT variant_id, quantity, is_free_item FROM orders_items WHERE order_id = $1`,
[id],
);
for (const item of itemRows.rows) {
if (!item.is_free_item && item.variant_id) {
await client.query(
`UPDATE inventory_stock
SET available = available + $3,
sold = GREATEST(sold - $3, 0),
updated_at = now()
WHERE variant_id = $1 AND store_id = $2`,
[item.variant_id, order.store_id, item.quantity],
);
await client.query(
`INSERT INTO inventory_movements (variant_id, store_id, operation, quantity)
VALUES ($1, $2, 'cancel', $3)`,
[item.variant_id, order.store_id, item.quantity],
);
}
}
await client.query(
`UPDATE orders_orders
SET state = 'CANCELLED', state_changed_at = now(), updated_at = now()
WHERE id = $1`,
[id],
);
await client.query(
`INSERT INTO orders_order_events (order_id, event, actor_id, metadata)
VALUES ($1, 'CANCELLED', $2, $3)`,
[id, user.id, JSON.stringify({ source: 'pos_delete' })],
);
await client.query('COMMIT');
return reply.send({ ok: true, deletedAt: new Date().toISOString() });
} catch (error) {
await client.query('ROLLBACK');
throw error;
} finally {
client.release();
}
},
);
app.get<{ Params: { id: string } }>(
'/pos/sales/:id/receipt',
{