feat(POS-FIX-7): completed feature
This commit is contained in:
@@ -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) ? (
|
||||
|
||||
@@ -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');
|
||||
};
|
||||
|
||||
@@ -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']);
|
||||
};
|
||||
|
||||
@@ -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 ?? {},
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
Reference in New Issue
Block a user