${icon(ChevronRight, 16, 'text-zinc-500 rotate-90')}
diff --git a/web/apps/desk/src/pages/inventory/tabs/ProductCollateralTab.ts b/web/apps/desk/src/pages/inventory/tabs/ProductCollateralTab.ts
index 802421008..fd898df96 100644
--- a/web/apps/desk/src/pages/inventory/tabs/ProductCollateralTab.ts
+++ b/web/apps/desk/src/pages/inventory/tabs/ProductCollateralTab.ts
@@ -2,6 +2,7 @@
// SPDX-FileCopyrightText: 2026 FutureBuild, Inc. and OpenLBM contributors
import { LitElement, html, nothing } from 'lit';
+import '../../../components/ui/gable-select.ts';
import { customElement, property, state } from 'lit/decorators.js';
import { icon } from '../../../lib/icons.ts';
import { Sparkles, Trash2, Copy, Loader2, FileText, RefreshCw, AlertTriangle } from 'lucide';
@@ -87,33 +88,27 @@ export class GableProductCollateralTab extends LitElement {
-
+ >
-
+ >
-
+ >
-
+ >
${icon(Shield, 14, 'absolute right-2 top-1/2 -translate-y-1/2 text-zinc-500 pointer-events-none')}
diff --git a/web/apps/desk/src/pages/pos/POSTerminal.test.ts b/web/apps/desk/src/pages/pos/POSTerminal.test.ts
index 5912e1294..c37b8aae9 100644
--- a/web/apps/desk/src/pages/pos/POSTerminal.test.ts
+++ b/web/apps/desk/src/pages/pos/POSTerminal.test.ts
@@ -31,6 +31,7 @@ import './POSTerminal';
import type { POSTerminal } from './POSTerminal';
import { branchContext } from '../../services/BranchContext';
import { authCustody } from '@gable/auth';
+import { openAppDialog, answerAppDialog, chooseOption } from '../../test/dom';
import type { CounterRegister } from '../../types/pos';
const reg = (id: string, branch: string, name = 'Front till'): CounterRegister => ({
@@ -699,28 +700,28 @@ describe('the Panels board', () => {
});
it('asks first, and declining removes nothing', async () => {
- const confirm = vi.spyOn(window, 'confirm').mockReturnValue(false);
const el = await mount(sale());
q(el, 'pos-clear-sale').click();
await flush(el);
- expect(confirm).toHaveBeenCalledTimes(1);
+ expect(openAppDialog()).toBeTruthy();
+ await answerAppDialog(false);
+ await flush(el);
expect(svc.removeItem).not.toHaveBeenCalled();
- confirm.mockRestore();
el.remove();
});
it('removes every line through the line removal once confirmed', async () => {
- const confirm = vi.spyOn(window, 'confirm').mockReturnValue(true);
svc.removeItem
.mockResolvedValueOnce(sale({ lines: [line('b', 3000)] as unknown as Sale['lines'] }))
.mockResolvedValueOnce(sale({ lines: [] }));
const el = await mount(sale());
q(el, 'pos-clear-sale').click();
await flush(el);
+ await answerAppDialog(true);
+ await flush(el);
await flush(el);
expect(svc.removeItem.mock.calls).toEqual([['s1', 'a'], ['s1', 'b']]);
expect(q(el, 'pos-lines')).toBeNull();
- confirm.mockRestore();
el.remove();
});
});
@@ -1120,9 +1121,10 @@ describe('the counter opens on its own yard\'s register', () => {
document.body.appendChild(el);
await settle(el);
expect(svc.startSale).toHaveBeenLastCalledWith('REG-01');
- vi.spyOn(window, 'confirm').mockReturnValue(true);
branchContext.setCurrent('b-wk');
await settle(el);
+ await answerAppDialog(true);
+ await settle(el);
expect(svc.currentTill).toHaveBeenLastCalledWith('REG-WK');
expect(svc.startSale).toHaveBeenLastCalledWith('REG-WK');
expect(q(el, 'pos-register').textContent).toContain('REG-WK');
@@ -1152,11 +1154,10 @@ describe('the counter opens on its own yard\'s register', () => {
await settle(el);
// the first by name: Front till
expect(svc.startSale).toHaveBeenLastCalledWith('REG-WK');
- const picker = q(el, 'pos-register-picker') as HTMLSelectElement;
+ const picker = q(el, 'pos-register-picker') as HTMLElement & { value: string };
expect(picker).toBeTruthy();
- vi.spyOn(window, 'confirm').mockReturnValue(true);
- picker.value = 'REG-WK2';
- picker.dispatchEvent(new Event('change'));
+ await chooseOption(picker, 'REG-WK2');
+ await answerAppDialog(true);
await settle(el);
expect(svc.startSale).toHaveBeenLastCalledWith('REG-WK2');
el.remove();
@@ -1234,19 +1235,20 @@ describe('the register and the sale when the cashier moves', () => {
const el = document.createElement('gable-pos-terminal') as POSTerminal;
document.body.appendChild(el);
await settle(el);
- const ask = vi.spyOn(window, 'confirm').mockReturnValue(false);
branchContext.setCurrent('b-wk');
await settle(el);
- expect(ask).toHaveBeenCalledTimes(1);
+ expect(openAppDialog()).toBeTruthy();
+ await answerAppDialog(false);
+ await settle(el);
expect(branchContext.currentId).toBe('b-lk');
expect(q(el, 'pos-register').textContent).toContain('REG-LK');
expect(svc.startSale).toHaveBeenCalledTimes(1);
- ask.mockReturnValue(true);
branchContext.setCurrent('b-wk');
await settle(el);
- expect(ask).toHaveBeenCalledTimes(2);
+ expect(openAppDialog()).toBeTruthy();
+ await answerAppDialog(true);
+ await settle(el);
expect(q(el, 'pos-register').textContent).toContain('REG-WK');
- ask.mockRestore();
el.remove();
});
@@ -1255,12 +1257,10 @@ describe('the register and the sale when the cashier moves', () => {
const el = document.createElement('gable-pos-terminal') as POSTerminal;
document.body.appendChild(el);
await settle(el);
- const ask = vi.spyOn(window, 'confirm').mockReturnValue(false);
branchContext.setCurrent('b-wk');
await settle(el);
- expect(ask).not.toHaveBeenCalled();
+ expect(openAppDialog()).toBeNull();
expect(q(el, 'pos-register').textContent).toContain('REG-WK');
- ask.mockRestore();
el.remove();
});
@@ -1775,15 +1775,15 @@ describe('closing the till', () => {
it('asks first over a sale with lines, and declining keeps the till and the sale', async () => {
const el = await mount(sale());
- const confirm = vi.spyOn(window, 'confirm').mockReturnValue(false);
const close = [...el.querySelectorAll('button')].find((b) => b.textContent?.trim() === 'Close Till') as HTMLElement;
close.click();
await el.updateComplete;
- expect(confirm).toHaveBeenCalledTimes(1);
+ expect(openAppDialog()).toBeTruthy();
+ await answerAppDialog(false);
+ await el.updateComplete;
expect(svc.tillReport).not.toHaveBeenCalled();
expect(q(el, 'pos-till-state').textContent).toContain('Till open');
expect((el as unknown as { _sale: Sale })._sale.id).toBe('s1');
- confirm.mockRestore();
el.remove();
});
@@ -1899,7 +1899,6 @@ describe('the resets after Clear tenders and Clear sale', () => {
});
it('puts a tender that was pressed but not added back to none on Clear sale', async () => {
- const confirm = vi.spyOn(window, 'confirm').mockReturnValue(true);
svc.removeItem
.mockResolvedValueOnce(sale({ lines: [line('b', 3000)] as unknown as Sale['lines'] }))
.mockResolvedValueOnce(sale({ lines: [], subtotal_cents: 0, tax_cents: 0, total_cents: 0 }));
@@ -1910,6 +1909,8 @@ describe('the resets after Clear tenders and Clear sale', () => {
expect(pressed(el)).toEqual(['cash']);
q(el, 'pos-clear-sale').click();
await settle();
+ await answerAppDialog(true);
+ await settle();
await el.updateComplete;
(el as unknown as { _sale: Sale })._sale = sale({
lines: [line('n', 5000)] as unknown as Sale['lines'], subtotal_cents: 5000, tax_cents: 0, total_cents: 5000,
@@ -1920,12 +1921,10 @@ describe('the resets after Clear tenders and Clear sale', () => {
q(el, 'pos-pay').click();
await settle();
expect(svc.completeSale).not.toHaveBeenCalled();
- confirm.mockRestore();
el.remove();
});
it('drops the added tenders on Clear sale, so the next lines are not paid on them', async () => {
- const confirm = vi.spyOn(window, 'confirm').mockReturnValue(true);
svc.removeItem
.mockResolvedValueOnce(sale({ lines: [line('b', 3000)] as unknown as Sale['lines'] }))
.mockResolvedValueOnce(sale({ lines: [], subtotal_cents: 0, tax_cents: 0, total_cents: 0 }));
@@ -1937,6 +1936,8 @@ describe('the resets after Clear tenders and Clear sale', () => {
await el.updateComplete;
q(el, 'pos-clear-sale').click();
await settle();
+ await answerAppDialog(true);
+ await settle();
await el.updateComplete;
// a new line lands on the emptied sale
(el as unknown as { _sale: Sale })._sale = sale({
@@ -1948,7 +1949,6 @@ describe('the resets after Clear tenders and Clear sale', () => {
q(el, 'pos-pay').click();
await settle();
expect(svc.completeSale).not.toHaveBeenCalled();
- confirm.mockRestore();
el.remove();
});
});
diff --git a/web/apps/desk/src/pages/pos/POSTerminal.ts b/web/apps/desk/src/pages/pos/POSTerminal.ts
index f6bac5deb..8ab85a583 100644
--- a/web/apps/desk/src/pages/pos/POSTerminal.ts
+++ b/web/apps/desk/src/pages/pos/POSTerminal.ts
@@ -2,6 +2,7 @@
// SPDX-FileCopyrightText: 2026 FutureBuild, Inc. and OpenLBM contributors
import { LitElement, html, nothing } from 'lit';
+import '../../components/ui/gable-select';
import { customElement, state } from 'lit/decorators.js';
import { posService, dollarsToCents, type TenderIn } from '../../services/POSService';
import type { Sale, QuickSearchResult, SaleLine, TillSession, TillReport, TenderMethod, RefundMethod, CounterCustomer, CounterRegister } from '../../types/pos';
@@ -10,6 +11,7 @@ import { chargeRefusal, standingOf, money, PO_REFUSAL } from './standing';
import { formatPrice4 } from '../../lib/utils';
import { isCountedUnit, normalizeQuantity } from '../../lib/money';
import { payPlan, panelProgress, type PanelState, type PanelKey } from './panels';
+import { confirmDialog, promptDialog } from '../../components/ui/app-dialog';
import { authCustody } from '@gable/auth';
import './catalog/gable-pos-catalog';
@@ -106,17 +108,27 @@ export class POSTerminal extends LitElement {
*/
private _onYardChanged = () => {
if (this._revertingYard) return;
- if (this._saleHasLines && !window.confirm(this._leaveSaleQuestion())) {
- this._revertingYard = true;
- try {
- branchContext.setCurrent(this._yardId);
- } finally {
- this._revertingYard = false;
- }
+ if (!this._saleHasLines) {
+ this._yardId = branchContext.currentId;
+ void this._loadRegisters();
return;
}
- this._yardId = branchContext.currentId;
- void this._loadRegisters();
+ const askedYard = branchContext.currentId;
+ void confirmDialog({ title: this._leaveSaleQuestion(), confirmLabel: 'Leave sale', cancelLabel: 'Stay' }).then((leave) => {
+ if (!leave) {
+ // A newer yard change asked its own question in the meantime; leave it be.
+ if (branchContext.currentId !== askedYard) return;
+ this._revertingYard = true;
+ try {
+ branchContext.setCurrent(this._yardId);
+ } finally {
+ this._revertingYard = false;
+ }
+ return;
+ }
+ this._yardId = askedYard;
+ void this._loadRegisters();
+ });
};
private get _saleHasLines(): boolean {
@@ -207,10 +219,10 @@ export class POSTerminal extends LitElement {
}
/** False when the cashier declined to leave the open sale. */
- private _chooseRegister(id: string): boolean {
+ private async _chooseRegister(id: string): Promise
{
const next = this._registers.find((r) => r.id === id);
if (!next || next.id === this._registerId) return true;
- if (this._saleHasLines && !window.confirm(this._leaveSaleQuestion())) return false;
+ if (this._saleHasLines && !(await confirmDialog({ title: this._leaveSaleQuestion(), confirmLabel: 'Leave sale', cancelLabel: 'Stay' }))) return false;
localStorage.setItem(this._storageKey(), next.id);
this._register = next;
void this._openRegister();
@@ -257,7 +269,7 @@ export class POSTerminal extends LitElement {
private async _beginCloseTill() {
if (!this._till) return;
// The same question a register change asks: declining keeps till and sale.
- if (this._saleHasLines && !window.confirm(this._leaveSaleQuestion())) return;
+ if (this._saleHasLines && !(await confirmDialog({ title: this._leaveSaleQuestion(), confirmLabel: 'Leave sale', cancelLabel: 'Stay' }))) return;
try {
this._tillBusy = true;
this._tillReport = await posService.tillReport(this._till.id);
@@ -457,14 +469,11 @@ export class POSTerminal extends LitElement {
${c.jobs.length > 0 ? html`
-
-
- ▾
+ >
` : nothing}
@@ -662,7 +671,11 @@ export class POSTerminal extends LitElement {
if (!sale || sale.status !== 'open') return;
const ids = sale.lines.filter((l) => l.parent_line_id === null).map((l) => l.id);
if (ids.length === 0) return;
- if (!window.confirm(`Remove all ${ids.length} line${ids.length === 1 ? '' : 's'} from sale ${sale.number}?`)) return;
+ if (!await confirmDialog({
+ title: `Remove all ${ids.length} line${ids.length === 1 ? '' : 's'} from sale ${sale.number}?`,
+ confirmLabel: 'Remove',
+ tone: 'danger',
+ })) return;
for (const id of ids) {
if (this._sale?.id !== sale.id) return;
await this._removeItem(id);
@@ -818,7 +831,7 @@ export class POSTerminal extends LitElement {
this._error = 'Only a completed sale is voided, while its drawer is open';
return;
}
- const reason = window.prompt('Void reason?');
+ const reason = await promptDialog({ title: `Void sale ${sale.number}?`, label: 'Reason', required: true, confirmLabel: 'Void sale', tone: 'danger' });
if (!reason) return;
try {
const voided = await posService.voidSale(sale.id, sale.revision, reason);
@@ -983,13 +996,16 @@ export class POSTerminal extends LitElement {
`)}
{ this._returnReason = (e.target as HTMLInputElement).value; }}
@@ -1030,14 +1046,16 @@ export class POSTerminal extends LitElement {
` : nothing}
+ >` : nothing}
` : nothing}