feat(POS-FIX-11): completed feature

This commit is contained in:
chattie
2026-08-24 08:13:43 +02:00
parent 579e0a18eb
commit d103b634aa
13 changed files with 207 additions and 13 deletions

View File

@@ -123,6 +123,8 @@ export default function RegisterPage() {
const [recoveringSaleId, setRecoveringSaleId] = useState<string | null>(null);
// POS-FIX-9: merge dialog when recovering with cart items
const [mergePendingSale, setMergePendingSale] = useState<PosPendingSale | null>(null);
// POS-FIX-11: park with name dialog
const [pendingParkName, setPendingParkName] = useState<string | null>(null);
// POS-FIX-3/POS-FIX-5: close session
const [showCloseSession, setShowCloseSession] = useState(false);
const [closingActualCash, setClosingActualCash] = useState('');
@@ -481,6 +483,20 @@ export default function RegisterPage() {
setError('Carrito vacío');
return;
}
// POS-FIX-11: if no customer, ask for a name
if (!customer) {
setPendingParkName('');
return;
}
await doParkSale();
};
// POS-FIX-11: actual parking with optional name
const doParkSale = async (name?: string) => {
if (!config?.session) {
setError('No hay sesión abierta');
return;
}
setProcessing(true);
setError('');
try {
@@ -505,8 +521,10 @@ export default function RegisterPage() {
),
payments: [],
...(customer ? { customerId: customer.id } : {}),
...(name ? { posLabel: name } : {}),
});
setRestPaymentFor(null);
setPendingParkName(null);
void loadPendingSales();
// Refresh the pending panel so the new parked sale appears
setLoadingPending(true);
@@ -866,14 +884,19 @@ export default function RegisterPage() {
<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) => (
{pendingSales.map((sale) => {
const saleDate = new Date(sale.createdAt);
const dateStr = saleDate.toLocaleDateString('es-ES', { day: '2-digit', month: '2-digit', year: '2-digit' });
const timeStr = saleDate.toLocaleTimeString('es-ES', { hour: '2-digit', minute: '2-digit' });
const displayName = sale.posLabel || sale.receiptNumber || sale.id.slice(0, 8);
return (
<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 className="font-semibold text-gray-800">{displayName}</p>
<p className="text-xs text-gray-400">
📅 {dateStr} {timeStr} · Total {formatPrice(sale.totalCents)} · pendiente {formatPrice(sale.outstandingCents)}
</p>
<div className="mt-2 flex gap-1">
<button
@@ -894,7 +917,8 @@ export default function RegisterPage() {
</button>
</div>
</li>
))}
);
})}
</ul>
)}
</aside>
@@ -1484,6 +1508,53 @@ export default function RegisterPage() {
</div>
)}
{/* POS-FIX-11: name dialog when parking without customer */}
{pendingParkName !== null && (
<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">Nombre para identificar</h2>
<p className="mb-4 text-sm text-gray-500">
¿A nombre de quién es este ticket pendiente?
</p>
<input
type="text"
value={pendingParkName}
onChange={(e) => setPendingParkName(e.target.value)}
placeholder="Ej: María García"
maxLength={100}
className="mb-4 w-full rounded-xl border border-gray-300 px-3 py-2 text-sm outline-none focus:border-[#2D6A4F]"
autoFocus
onKeyDown={(e) => {
if (e.key === 'Enter' && pendingParkName.trim()) {
void doParkSale(pendingParkName.trim());
}
}}
/>
<div className="flex gap-2">
<button
type="button"
onClick={() => setPendingParkName(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={() => {
if (pendingParkName.trim()) {
void doParkSale(pendingParkName.trim());
}
}}
disabled={!pendingParkName.trim()}
className="flex-1 rounded-xl bg-[#2D6A4F] px-4 py-2 text-sm font-bold text-white disabled:opacity-50"
>
Guardar
</button>
</div>
</div>
</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">

View File

@@ -87,6 +87,7 @@ export interface PosPendingSale {
createdAt: string;
cashierEmail?: string | null;
receiptNumber?: string | null;
posLabel?: string | null;
}
export interface RecoveredOrderItem {

View File

@@ -0,0 +1,23 @@
/**
* POS-FIX-11: Add pos_label field for pending sales identification
* Allows naming a pending sale for easier identification when no customer is associated.
* @param {import('node-pg-migrate').MigrationBuilder} pgm
*/
export const up = (pgm) => {
pgm.sql(`
ALTER TABLE orders_orders
ADD COLUMN IF NOT EXISTS pos_label text;
`);
pgm.sql(`
COMMENT ON COLUMN orders_orders.pos_label IS 'Optional label/name for pending POS sales without customer';
`);
};
/**
* @param {import('node-pg-migrate').MigrationBuilder} pgm
*/
export const down = (pgm) => {
pgm.sql(`
ALTER TABLE orders_orders DROP COLUMN IF EXISTS pos_label;
`);
};

View File

@@ -1338,6 +1338,7 @@ export async function registerPosRoutes(app: FastifyInstance, deps: PosRouteDeps
items: { type: 'array', minItems: 1, items: { type: 'object' } },
payments: { type: 'array', minItems: 0, items: { type: 'object' } },
customerId: { type: 'string', format: 'uuid' },
posLabel: { type: 'string', maxLength: 100 },
},
},
response: {
@@ -1389,6 +1390,7 @@ export async function registerPosRoutes(app: FastifyInstance, deps: PosRouteDeps
items: z.array(z.union([freeLine, stockLine])).min(1),
payments: z.array(payment).min(0),
customerId: z.string().uuid().optional(),
posLabel: z.string().max(100).optional(),
}),
request.body ?? {},
);
@@ -1655,6 +1657,7 @@ export async function registerPosRoutes(app: FastifyInstance, deps: PosRouteDeps
o.discount_cents AS "discountCents", o.created_at AS "createdAt",
o.store_id AS "storeId", o.terminal_id AS "terminalId",
o.cash_session_id AS "cashSessionId",
o.pos_label AS "posLabel",
COALESCE(payments.sum_paid, 0)::int AS "paidCents",
(o.total_cents - COALESCE(payments.sum_paid, 0))::int AS "outstandingCents",
u.email AS "userEmail"

View File

@@ -288,8 +288,8 @@ export class CreatePosSaleUseCase {
const orderResult = await client.query<{ id: string; created_at: Date }>(
`INSERT INTO orders_orders (
user_id, idempotency_key, state, subtotal_cents, discount_cents, tax_cents,
total_cents, source, terminal_id, cash_session_id, store_id, receipt_number
) VALUES ($1, $2, 'PENDING', $3, $4, $5, $6, 'pos', $7, $8, $9, $10)
total_cents, source, terminal_id, cash_session_id, store_id, receipt_number, pos_label
) VALUES ($1, $2, 'PENDING', $3, $4, $5, $6, 'pos', $7, $8, $9, $10, $11)
RETURNING id, created_at`,
[
input.customerId ?? null,
@@ -302,6 +302,7 @@ export class CreatePosSaleUseCase {
input.cashSessionId,
session.store_id,
receiptNumber,
input.posLabel ?? null,
],
);
const order = orderResult.rows[0];

View File

@@ -57,6 +57,8 @@ export interface PosSaleInput {
items: PosSaleLineInput[];
payments: PosPaymentInput[];
customerId?: string;
/** Optional label for pending sales without customer */
posLabel?: string;
}
export interface PosReceiptItem {