feat(POS-FIX-7): completed feature

This commit is contained in:
chattie
2026-08-24 07:23:55 +02:00
parent cd2a8ab54d
commit 2467a02dd4
14 changed files with 305 additions and 81 deletions

View File

@@ -116,6 +116,7 @@ export default function RegisterPage() {
const [returnOrder, setReturnOrder] = useState<{ orderId: string; receipt: PosReceipt } | null>(
null,
);
const [showClearConfirm, setShowClearConfirm] = useState(false);
// POS-FIX-3/POS-FIX-5: close session
const [showCloseSession, setShowCloseSession] = useState(false);
const [closingActualCash, setClosingActualCash] = useState('');
@@ -471,6 +472,62 @@ export default function RegisterPage() {
}
};
const parkSale = async () => {
if (!config?.session) {
setError('No hay sesión abierta');
return;
}
if (cart.length === 0) {
setError('Carrito vacío');
return;
}
setProcessing(true);
setError('');
try {
const result = await posApi.createSale<PosSaleResponse>({
idempotencyKey: generateIdempotencyKey(),
cashSessionId: config.session.id,
terminalId: config.terminal.id,
items: cart.map((item) =>
item.kind === 'free'
? {
kind: 'free',
name: item.name,
unitPriceCents: item.unitPriceCents,
quantity: item.quantity,
}
: {
kind: 'stock',
variantId: item.variantId,
quantity: item.quantity,
discountCents: item.discountCents,
},
),
payments: [],
...(customer ? { customerId: customer.id } : {}),
});
setRestPaymentFor(null);
void loadPendingSales();
// Refresh the pending panel so the new parked sale appears
setLoadingPending(true);
try {
const data = (await posApi.listSales({ state: 'PENDING', terminalId: config.terminal.id })) as {
items: PosPendingSale[];
};
setPendingSales(data.items ?? []);
} catch {
// Non-fatal: the sale was created, panel refresh is best-effort
} finally {
setLoadingPending(false);
}
resetCashier();
} catch (err) {
setError(err instanceof Error ? err.message : 'No se pudo aparcar la venta');
} finally {
setProcessing(false);
}
};
const openRestPayment = (sale: PosPendingSale) => {
setRestPaymentFor(sale);
setError('');
@@ -512,6 +569,7 @@ export default function RegisterPage() {
setCustomer(null);
setError('');
setSearch('');
setShowClearConfirm(false);
};
const searchCustomers = async (query: string) => {
@@ -1144,6 +1202,14 @@ export default function RegisterPage() {
))}
</div>
<div className="mt-3 grid grid-cols-2 gap-2">
<button
type="button"
onClick={() => void parkSale()}
disabled={processing || cart.length === 0}
className="min-h-16 rounded-xl bg-amber-500 text-lg font-bold text-white disabled:opacity-40"
>
{processing ? 'Guardando…' : 'Guardar pendiente'}
</button>
<button
type="button"
onClick={() => void confirmSale()}
@@ -1156,21 +1222,17 @@ export default function RegisterPage() {
}
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
{processing ? 'Confirmando…' : 'Cobrar e imprimir'}
</button>
</div>
<button
type="button"
onClick={() => setShowClearConfirm(true)}
disabled={processing || cart.length === 0}
className="mt-2 w-full rounded-xl border border-gray-300 bg-white py-2 text-sm font-bold text-red-600 disabled:opacity-40"
>
Vaciar caja
</button>
</aside>
{showDiscountPanel && selectedItem && (
@@ -1256,6 +1318,38 @@ export default function RegisterPage() {
onClose={() => setPaymentMethod(null)}
/>
)}
{/* POS-FIX-7: confirmar antes de vaciar */}
{showClearConfirm && (
<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">¿Vaciar ticket?</h2>
<p className="mb-4 text-sm text-gray-600">
Se borrarán {cart.length} {cart.length === 1 ? 'artículo' : 'artículos'} del ticket actual.
Esta acción no se puede deshacer.
</p>
<div className="flex gap-2">
<button
type="button"
onClick={() => setShowClearConfirm(false)}
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={() => {
setShowClearConfirm(false);
resetCashier();
}}
className="flex-1 rounded-xl bg-red-600 px-4 py-2 text-sm font-bold text-white hover:bg-red-700"
>
Vaciar
</button>
</div>
</div>
</div>
)}
{/* POS-FIX-3/POS-FIX-5: close session — 2-step: PIN then cash */}
{showCloseSession && (
(config?.terminal?.settings?.closeSessionRequiresPin && !closingPinStep) ? (

View File

@@ -1,25 +1,17 @@
/* eslint-disable @typescript-eslint/naming-convention */
'use strict';
/**
* F-193: Adds `weight_grams` to catalog_product_variants.
* Authoritative per-variant shipping weight in grams.
* Defaults to NULL (fallback to product-level unit_weight_kg in checkout).
* Note: expiration_date already exists on catalog_products (migration 038).
*/
exports.up = function (db) {
return db.addColumn('catalog_product_variants', 'weight_grams', {
export const up = (pgm) => {
pgm.addColumn('catalog_product_variants', 'weight_grams', {
type: 'integer',
notNull: false,
default: null,
check: 'weight_grams IS NULL OR weight_grams > 0',
}, 'ean');
});
};
exports.down = function (db) {
return db.removeColumn('catalog_product_variants', 'weight_grams');
};
exports._meta = {
version: 57,
export const down = (pgm) => {
pgm.dropColumn('catalog_product_variants', 'weight_grams');
};

View File

@@ -1,39 +1,30 @@
/* eslint-disable @typescript-eslint/naming-convention */
'use strict';
/**
* FEAT-199: Adds email confirmation to user registration.
* - confirmation_token: random string sent in confirmation email (null after confirmed)
* - confirmed_at: timestamp when email was confirmed (null until confirmed)
* - confirmed users can login; unconfirmed cannot.
*/
exports.up = function (db) {
db.addColumn('identity_users', 'confirmation_token', {
export const up = (pgm) => {
pgm.addColumn('identity_users', 'confirmation_token', {
type: 'string',
notNull: false,
default: null,
});
db.addColumn('identity_users', 'confirmed_at', {
pgm.addColumn('identity_users', 'confirmed_at', {
type: 'timestamp',
notNull: false,
default: null,
});
db.addColumn('identity_users', 'email_confirmed', {
pgm.addColumn('identity_users', 'email_confirmed', {
type: 'boolean',
notNull: true,
default: false,
});
// FEAT-199: migrate existing users to confirmed (they already verified their email during signup)
return db.execute('UPDATE identity_users SET email_confirmed = true');
pgm.sql('UPDATE identity_users SET email_confirmed = true');
};
exports.down = function (db) {
db.removeColumn('identity_users', 'email_confirmed');
db.removeColumn('identity_users', 'confirmed_at');
db.removeColumn('identity_users', 'confirmation_token');
return null;
};
exports._meta = {
version: 58,
export const down = (pgm) => {
pgm.dropColumns('identity_users', ['email_confirmed', 'confirmed_at', 'confirmation_token']);
};

View File

@@ -1336,7 +1336,7 @@ export async function registerPosRoutes(app: FastifyInstance, deps: PosRouteDeps
cashSessionId: { type: 'string', format: 'uuid' },
terminalId: { type: 'string', format: 'uuid' },
items: { type: 'array', minItems: 1, items: { type: 'object' } },
payments: { type: 'array', minItems: 1, items: { type: 'object' } },
payments: { type: 'array', minItems: 0, items: { type: 'object' } },
customerId: { type: 'string', format: 'uuid' },
},
},
@@ -1387,7 +1387,7 @@ export async function registerPosRoutes(app: FastifyInstance, deps: PosRouteDeps
cashSessionId: z.string().uuid(),
terminalId: z.string().uuid(),
items: z.array(z.union([freeLine, stockLine])).min(1),
payments: z.array(payment).min(1),
payments: z.array(payment).min(0),
customerId: z.string().uuid().optional(),
}),
request.body ?? {},

View File

@@ -51,8 +51,9 @@ export function validatePaymentAllocations(
inputs: PosPaymentInput[],
methods: ConfiguredPaymentMethod[],
): ValidatedPayment[] {
// Zero payments are allowed — creates a parked/pending sale with full outstanding balance.
if (inputs.length === 0) {
throw new AppError(400, 'POS_PAYMENT_REQUIRED', 'Selecciona al menos una forma de pago');
return [];
}
const byCode = new Map(methods.map((method) => [method.code, method]));
const validated = inputs.map((input) => {