From 75c744ae9d215349b51b9df2a58500e79efdeb64 Mon Sep 17 00:00:00 2001 From: colton Date: Wed, 14 Oct 2026 13:27:26 -0700 Subject: [PATCH 01/21] test(desk): the KPI card draws its icon once (red for the double icon walk note) The dashboard's KPI cards draw the boxed icon and a faded enlarged copy behind it. This leg wants one icon per card and fails on today's code, which draws two. Signed-off-by: colton --- .../src/components/dashboard/KPICard.test.ts | 26 +++++++++++++++++++ 1 file changed, 26 insertions(+) create mode 100644 web/apps/desk/src/components/dashboard/KPICard.test.ts diff --git a/web/apps/desk/src/components/dashboard/KPICard.test.ts b/web/apps/desk/src/components/dashboard/KPICard.test.ts new file mode 100644 index 000000000..3d502ddf3 --- /dev/null +++ b/web/apps/desk/src/components/dashboard/KPICard.test.ts @@ -0,0 +1,26 @@ +// SPDX-License-Identifier: LicenseRef-OpenLBM-Surface-1.0 +// SPDX-FileCopyrightText: 2026 FutureBuild, Inc. and OpenLBM contributors + +/** + * The dashboard's KPI card: one icon per card (the boxed icon, never the + * faded enlarged copy behind it), and a card that drills in is a real link + * with a name a screen reader announces. + */ +import { describe, it, expect } from 'vitest' +import { icon } from '../../lib/icons.ts' +import { DollarSign } from 'lucide' +import './KPICard.ts' +import type { GableKPICard } from './KPICard.ts' +import { mount } from '../../test/dom' + +describe('kpi card', () => { + it('draws its icon once: the boxed icon only, no faded copy behind it', async () => { + const el = await mount('gable-kpi-card', { + cardTitle: "Today's Revenue", + value: '$100.00', + iconHtml: icon(DollarSign, 20, 'w-5 h-5'), + }) + + expect(el.querySelectorAll('svg').length).toBe(1) + }) +}) From 1fa9ea734baf7830cebb8f0ede405352db30096d Mon Sep 17 00:00:00 2001 From: colton Date: Wed, 14 Oct 2026 13:28:01 -0700 Subject: [PATCH 02/21] fix(desk): the KPI card keeps only its boxed icon The faded enlarged copy behind the boxed icon is dropped, so each card on the dashboard draws one icon. Signed-off-by: colton --- web/apps/desk/src/components/dashboard/KPICard.ts | 4 ---- 1 file changed, 4 deletions(-) diff --git a/web/apps/desk/src/components/dashboard/KPICard.ts b/web/apps/desk/src/components/dashboard/KPICard.ts index 2b34f5125..a80b46cea 100644 --- a/web/apps/desk/src/components/dashboard/KPICard.ts +++ b/web/apps/desk/src/components/dashboard/KPICard.ts @@ -63,10 +63,6 @@ export class GableKPICard extends LitElement { return html`
-
- ${this.iconHtml ? html`
${this.iconHtml}
` : nothing} -
-

${this.cardTitle}

From 13b623a5d14b09c8039ad5408d503c76f1d05b46 Mon Sep 17 00:00:00 2001 From: colton Date: Wed, 14 Oct 2026 13:30:53 -0700 Subject: [PATCH 03/21] test(desk): the dashboard's cards and rows drill in (red for the walk note) Clicking a card does nothing today. These legs want each KPI card a named link to its own list (today's till, the orders list, the dispatch board, AR Aging), each Top Customers row a named link to the customer's account, each Inventory Alerts row a named link to the product's stock, and each Recent Activity row named for a screen reader. They fail on today's code: the cards and the customer and alert rows have no link at all, and the activity rows have no accessible name. Signed-off-by: colton --- .../dashboard/InventoryAlertsWidget.test.ts | 25 +++++++ .../src/components/dashboard/KPICard.test.ts | 15 +++- .../dashboard/RecentOrdersFeed.test.ts | 10 ++- .../dashboard/TopCustomersTable.test.ts | 25 +++++++ web/apps/desk/src/pages/Dashboard.test.ts | 70 +++++++++++++++++++ 5 files changed, 143 insertions(+), 2 deletions(-) create mode 100644 web/apps/desk/src/components/dashboard/InventoryAlertsWidget.test.ts create mode 100644 web/apps/desk/src/components/dashboard/TopCustomersTable.test.ts create mode 100644 web/apps/desk/src/pages/Dashboard.test.ts diff --git a/web/apps/desk/src/components/dashboard/InventoryAlertsWidget.test.ts b/web/apps/desk/src/components/dashboard/InventoryAlertsWidget.test.ts new file mode 100644 index 000000000..5fb0c1875 --- /dev/null +++ b/web/apps/desk/src/components/dashboard/InventoryAlertsWidget.test.ts @@ -0,0 +1,25 @@ +// SPDX-License-Identifier: LicenseRef-OpenLBM-Surface-1.0 +// SPDX-FileCopyrightText: 2026 FutureBuild, Inc. and OpenLBM contributors + +/** + * The dashboard's inventory alerts: each row opens that product's stock at + * the yard and is a real link named for a screen reader. + */ +import { describe, it, expect } from 'vitest' +import './InventoryAlertsWidget.ts' +import type { GableInventoryAlertsWidget } from './InventoryAlertsWidget.ts' +import { mount, q, text } from '../../test/dom' + +describe('inventory alerts widget', () => { + it("links each row to the product's stock, named for a screen reader", async () => { + const el = await mount('gable-inventory-alerts-widget', { + alerts: [ + { product_id: 'prod-1', sku: 'HANGER-26', name: 'Joist Hanger 2x6', current_qty: 4, reorder_qty: 10, alert_type: 'LOW_STOCK' }, + ], + }) + + const link = q(el, 'a[href="/inventory/prod-1"]') + expect(link.getAttribute('aria-label')).toBe('Open the stock for Joist Hanger 2x6') + expect(text(el)).toContain('HANGER-26') + }) +}) diff --git a/web/apps/desk/src/components/dashboard/KPICard.test.ts b/web/apps/desk/src/components/dashboard/KPICard.test.ts index 3d502ddf3..27dfa8009 100644 --- a/web/apps/desk/src/components/dashboard/KPICard.test.ts +++ b/web/apps/desk/src/components/dashboard/KPICard.test.ts @@ -11,7 +11,7 @@ import { icon } from '../../lib/icons.ts' import { DollarSign } from 'lucide' import './KPICard.ts' import type { GableKPICard } from './KPICard.ts' -import { mount } from '../../test/dom' +import { mount, q } from '../../test/dom' describe('kpi card', () => { it('draws its icon once: the boxed icon only, no faded copy behind it', async () => { @@ -23,4 +23,17 @@ describe('kpi card', () => { expect(el.querySelectorAll('svg').length).toBe(1) }) + + it('a card with a drill-in target is a real link named for a screen reader', async () => { + const el = await mount('gable-kpi-card', { + cardTitle: "Today's Revenue", + value: '$100.00', + iconHtml: icon(DollarSign, 20, 'w-5 h-5'), + href: '/reports/daily-till', + linkLabel: "Open today's sales", + }) + + const link = q(el, 'a[href="/reports/daily-till"]') + expect(link.getAttribute('aria-label')).toBe("Open today's sales") + }) }) diff --git a/web/apps/desk/src/components/dashboard/RecentOrdersFeed.test.ts b/web/apps/desk/src/components/dashboard/RecentOrdersFeed.test.ts index ba278ded5..7e01a790a 100644 --- a/web/apps/desk/src/components/dashboard/RecentOrdersFeed.test.ts +++ b/web/apps/desk/src/components/dashboard/RecentOrdersFeed.test.ts @@ -8,7 +8,7 @@ import { describe, it, expect } from 'vitest' import './RecentOrdersFeed' import type { GableRecentOrdersFeed } from './RecentOrdersFeed' -import { mount, text } from '../../test/dom' +import { mount, q, text } from '../../test/dom' const ORDER_ID = 'f9d3cc06-6456-4a7a-b6ba-63d0a04f7a63' @@ -21,4 +21,12 @@ describe('recent orders feed', () => { expect(el.querySelector(`a[href="/orders/${ORDER_ID}"]`)).not.toBeNull() expect(text(el)).not.toContain('f9d3cc06') }) + + it('names each row for a screen reader', async () => { + const el = await mount('gable-recent-orders-feed', { + orders: [{ order_id: ORDER_ID, customer_name: 'Kelbrook Construction', total_amount: 186600, status: 'CONFIRMED', created_at: '2026-10-12T00:00:00Z' }], + }) + const link = q(el, `a[href="/orders/${ORDER_ID}"]`) + expect(link.getAttribute('aria-label')).toBe('Open the order for Kelbrook Construction') + }) }) diff --git a/web/apps/desk/src/components/dashboard/TopCustomersTable.test.ts b/web/apps/desk/src/components/dashboard/TopCustomersTable.test.ts new file mode 100644 index 000000000..c20dae2bb --- /dev/null +++ b/web/apps/desk/src/components/dashboard/TopCustomersTable.test.ts @@ -0,0 +1,25 @@ +// SPDX-License-Identifier: LicenseRef-OpenLBM-Surface-1.0 +// SPDX-FileCopyrightText: 2026 FutureBuild, Inc. and OpenLBM contributors + +/** + * The dashboard's top customers: each row opens that customer's account + * and is a real link named for a screen reader. + */ +import { describe, it, expect } from 'vitest' +import './TopCustomersTable.ts' +import type { GableTopCustomersTable } from './TopCustomersTable.ts' +import { mount, q, text } from '../../test/dom' + +describe('top customers table', () => { + it("links each row to the customer's account, named for a screen reader", async () => { + const el = await mount('gable-top-customers-table', { + customers: [ + { customer_id: 'cust-1', customer_name: 'Kelbrook Construction', total_revenue: 186600, order_count: 4 }, + ], + }) + + const link = q(el, 'a[href="/accounts/cust-1"]') + expect(link.getAttribute('aria-label')).toBe("Open Kelbrook Construction's account") + expect(text(el)).toContain('Kelbrook Construction') + }) +}) diff --git a/web/apps/desk/src/pages/Dashboard.test.ts b/web/apps/desk/src/pages/Dashboard.test.ts new file mode 100644 index 000000000..cff20c741 --- /dev/null +++ b/web/apps/desk/src/pages/Dashboard.test.ts @@ -0,0 +1,70 @@ +// SPDX-License-Identifier: LicenseRef-OpenLBM-Surface-1.0 +// SPDX-FileCopyrightText: 2026 FutureBuild, Inc. and OpenLBM contributors + +/** + * The manager's dashboard drills in: each KPI card is a link to its own + * list at the yard the dashboard shows. Today's Revenue opens the day's + * till, Active Orders the orders list, Pending Dispatch the dispatch + * board, Outstanding AR the AR Aging page. + */ +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest' + +vi.mock('chart.js/auto', () => ({ + default: class { + destroy() {} + }, +})) + +import './Dashboard.ts' +import type { GableDashboard } from './Dashboard.ts' +import { mountAsync, q, jsonResponse } from '../test/dom' + +beforeEach(() => { + vi.stubGlobal('fetch', vi.fn(async (url: string) => { + if (url.includes('/dashboard/summary')) { + return jsonResponse({ + today_revenue: 313494, + today_revenue_change: 0, + active_orders: 3, + pending_dispatch: 2, + outstanding_ar: 6898394, + outstanding_ar_count: 6, + }) + } + if (url.includes('/dashboard/inventory-alerts')) return jsonResponse([]) + if (url.includes('/dashboard/top-customers')) return jsonResponse([]) + if (url.includes('/dashboard/order-activity')) return jsonResponse({ recent_orders: [], status_breakdown: {} }) + if (url.includes('/dashboard/revenue-trend')) return jsonResponse([]) + return jsonResponse([]) + })) +}) + +afterEach(() => { + vi.unstubAllGlobals() +}) + +describe('the dashboard drills in', () => { + it("Today's Revenue opens the day's till", async () => { + const el = await mountAsync('gable-dashboard') + const link = q(el, 'a[href="/reports/daily-till"]') + expect(link.getAttribute('aria-label')).toBe("Open today's sales") + }) + + it('Active Orders opens the orders list', async () => { + const el = await mountAsync('gable-dashboard') + const link = q(el, 'a[href="/orders"]') + expect(link.getAttribute('aria-label')).toBe('Open the active orders') + }) + + it('Pending Dispatch opens the dispatch board', async () => { + const el = await mountAsync('gable-dashboard') + const link = q(el, 'a[href="/dispatch"]') + expect(link.getAttribute('aria-label')).toBe('Open the deliveries waiting for dispatch') + }) + + it('Outstanding AR opens the AR Aging page', async () => { + const el = await mountAsync('gable-dashboard') + const link = q(el, 'a[href="/reports/ar-aging"]') + expect(link.getAttribute('aria-label')).toBe('Open AR Aging') + }) +}) From a70ee2e544dedc9e541d9629f84510d2d98f53ba Mon Sep 17 00:00:00 2001 From: colton Date: Wed, 14 Oct 2026 13:33:24 -0700 Subject: [PATCH 04/21] feat(desk): the dashboard's cards and rows drill in Each KPI card is a link to its own list at the yard the dashboard shows: Today's Revenue opens the day's till, Active Orders the orders list, Pending Dispatch the dispatch board, Outstanding AR the AR Aging page. Each Top Customers row opens the customer's account, each Inventory Alerts row the product's stock, each Recent Activity row its order. Cards and rows are real links with an accessible name and a visible focus ring, keeping the hover state the cards already had. Signed-off-by: colton --- .../dashboard/InventoryAlertsWidget.ts | 8 ++++++-- .../desk/src/components/dashboard/KPICard.ts | 19 +++++++++++++++++-- .../components/dashboard/RecentOrdersFeed.ts | 6 +++++- .../components/dashboard/TopCustomersTable.ts | 10 +++++++--- web/apps/desk/src/pages/Dashboard.ts | 8 ++++++++ 5 files changed, 43 insertions(+), 8 deletions(-) diff --git a/web/apps/desk/src/components/dashboard/InventoryAlertsWidget.ts b/web/apps/desk/src/components/dashboard/InventoryAlertsWidget.ts index bbd0de4d7..4b01f882f 100644 --- a/web/apps/desk/src/components/dashboard/InventoryAlertsWidget.ts +++ b/web/apps/desk/src/components/dashboard/InventoryAlertsWidget.ts @@ -58,7 +58,11 @@ export class GableInventoryAlertsWidget extends LitElement {
` : this.alerts.map((alert) => html` -
-
+ `) } diff --git a/web/apps/desk/src/components/dashboard/KPICard.ts b/web/apps/desk/src/components/dashboard/KPICard.ts index a80b46cea..70ed42b6f 100644 --- a/web/apps/desk/src/components/dashboard/KPICard.ts +++ b/web/apps/desk/src/components/dashboard/KPICard.ts @@ -16,6 +16,10 @@ export class GableKPICard extends LitElement { @property({ attribute: false }) iconHtml?: TemplateResult; @property({ type: Boolean }) loading = false; @property({ type: String }) valueColor = 'text-white'; + /** Where the card drills in; unset renders a plain card. */ + @property({ type: String }) href?: string; + /** The link's spoken name ("Open today's sales"). */ + @property({ type: String, attribute: 'link-label' }) linkLabel?: string; private getTrendColor(): string { if (this.trend === undefined || this.trend === null) return 'text-zinc-500'; @@ -61,8 +65,8 @@ export class GableKPICard extends LitElement { ? numericValue.toLocaleString(undefined, { minimumFractionDigits: 2, maximumFractionDigits: 2 }) : this.value; - return html` -
+ const card = html` +

${this.cardTitle}

@@ -93,5 +97,16 @@ export class GableKPICard extends LitElement {
`; + + if (!this.href) return card; + return html` + + ${card} + + `; } } diff --git a/web/apps/desk/src/components/dashboard/RecentOrdersFeed.ts b/web/apps/desk/src/components/dashboard/RecentOrdersFeed.ts index 5b98c36a2..5fbc9b497 100644 --- a/web/apps/desk/src/components/dashboard/RecentOrdersFeed.ts +++ b/web/apps/desk/src/components/dashboard/RecentOrdersFeed.ts @@ -58,7 +58,11 @@ export class GableRecentOrdersFeed extends LitElement { ${this.orders.length === 0 ? html`
No recent orders
` : this.orders.map((order) => html` - +
${order.customer_name} diff --git a/web/apps/desk/src/components/dashboard/TopCustomersTable.ts b/web/apps/desk/src/components/dashboard/TopCustomersTable.ts index 2cf76d21a..e68a96ae8 100644 --- a/web/apps/desk/src/components/dashboard/TopCustomersTable.ts +++ b/web/apps/desk/src/components/dashboard/TopCustomersTable.ts @@ -63,12 +63,16 @@ export class GableTopCustomersTable extends LitElement { : this.customers.map((customer) => html` - + ${customer.customer_name} + ${customer.order_count} diff --git a/web/apps/desk/src/pages/Dashboard.ts b/web/apps/desk/src/pages/Dashboard.ts index 6d91f33df..e233c4c2d 100644 --- a/web/apps/desk/src/pages/Dashboard.ts +++ b/web/apps/desk/src/pages/Dashboard.ts @@ -173,12 +173,16 @@ export class GableDashboard extends LitElement { .iconHtml=${icon(DollarSign, 20, 'w-5 h-5')} ?loading=${this.loading} valueColor="text-gable-green" + href="/reports/daily-till" + link-label="Open today's sales" >
From 265252e95c8c056b5b602467c6cf1b86641a5048 Mon Sep 17 00:00:00 2001 From: colton Date: Wed, 14 Oct 2026 13:34:30 -0700 Subject: [PATCH 05/21] test(desk): the receive bin picker lists the yard's bins in path order (red for the walk note) The picker offered the bins in reverse (KEL-D first). This leg serves the locations reversed and wants them listed by their path, first bin first; it fails on today's code, which keeps the wire's order. Signed-off-by: colton --- .../purchasing/PurchaseOrderDetail.test.ts | 34 +++++++++++++++++++ 1 file changed, 34 insertions(+) diff --git a/web/apps/desk/src/pages/purchasing/PurchaseOrderDetail.test.ts b/web/apps/desk/src/pages/purchasing/PurchaseOrderDetail.test.ts index 9af41f7d1..1f179a09a 100644 --- a/web/apps/desk/src/pages/purchasing/PurchaseOrderDetail.test.ts +++ b/web/apps/desk/src/pages/purchasing/PurchaseOrderDetail.test.ts @@ -195,4 +195,38 @@ describe('purchase order detail receive form', () => { expect(textOf(el)).toContain('$10.00') expect(textOf(el)).not.toContain('$9.00') }) + + it('lists the receive bins in path order, first bin first, however the wire serves them', async () => { + // The walk saw the bins in reverse (KEL-D first). The picker must list + // them by their path, in order, whatever order the locations call returns. + const locations = [ + loc('loc-d', PO_BRANCH_ID, 'bin', 'KEL-D'), + loc('loc-c', PO_BRANCH_ID, 'bin', 'KEL-C'), + loc('loc-b', PO_BRANCH_ID, 'bin', 'KEL-B'), + loc('loc-a', PO_BRANCH_ID, 'bin', 'KEL-A'), + ] + const locationsPage = { items: locations, next_cursor: null, limit: 200 } + vi.stubGlobal('fetch', vi.fn(async (url: string) => { + if (url.includes(`/purchase-orders/${PO_ID}/freight`)) return jsonResponse([]) + if (url.endsWith(`/purchase-orders/${PO_ID}`)) return jsonResponse(po({ status: 'SENT' })) + if (url.includes('/locations')) return jsonResponse(locationsPage) + if (url.endsWith('/vendors/v-1')) return jsonResponse({ id: 'v-1', name: 'Acme Lumber' }) + if (url.endsWith(`/products/${PRODUCT_ID}`)) return jsonResponse({ id: PRODUCT_ID, sku: 'LUM-2X4-08' }) + return jsonResponse([]) + })) + const el = await mountAsync('gable-purchase-order-detail', { routeId: PO_ID }) + + const receiveBtn = Array.from(el.querySelectorAll('button')).find((b) => textOf(b as Element).includes('Receive Items')) as HTMLButtonElement + expect(receiveBtn, 'expected a Receive Items button').toBeDefined() + receiveBtn.click() + await flush() + await flush() + await el.updateComplete + + const select = q(el, 'select') + const optionLabels = Array.from(select.querySelectorAll('option')) + .map((o) => textOf(o as Element)) + .filter((label) => label !== 'Select...') + expect(optionLabels).toEqual(['KEL-A', 'KEL-B', 'KEL-C', 'KEL-D']) + }) }) \ No newline at end of file From 6d620cc54e2932eaee8abb08deab869adf76c674 Mon Sep 17 00:00:00 2001 From: colton Date: Wed, 14 Oct 2026 13:35:01 -0700 Subject: [PATCH 06/21] fix(desk): the receive bin picker lists the yard's bins in path order The receive form sorted nothing, so the bins came in the wire's order (KEL-D first on the walk). The picker's data is now sorted by the bin's path, first bin first. Signed-off-by: colton --- .../desk/src/pages/purchasing/PurchaseOrderDetail.ts | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/web/apps/desk/src/pages/purchasing/PurchaseOrderDetail.ts b/web/apps/desk/src/pages/purchasing/PurchaseOrderDetail.ts index bb7536216..4fe4065e0 100644 --- a/web/apps/desk/src/pages/purchasing/PurchaseOrderDetail.ts +++ b/web/apps/desk/src/pages/purchasing/PurchaseOrderDetail.ts @@ -117,13 +117,14 @@ export class PurchaseOrderDetail extends LitElement { /** Receive bins only: locations under the PO's own yard, no top-level * branch rows. `branch_id` on a Location is the link, and `type === 'branch'` * rows are skipped because a stray top-level location would let a user - * receive into another yard by accident. */ + * receive into another yard by accident. Listed by their path, first bin + * first, whatever order the wire serves. */ private get _receiveLocations(): Location[] { const po = this.po; if (!po?.branch_id) return []; - return this.locations.filter( - (loc) => loc.branch_id === po.branch_id && loc.type !== 'branch', - ); + return this.locations + .filter((loc) => loc.branch_id === po.branch_id && loc.type !== 'branch') + .sort((a, b) => (a.path || a.code).localeCompare(b.path || b.code, undefined, { numeric: true })); } private async _handleSubmitPO() { From 5259b9756e2c05430e1d2066293696da23a4e640 Mon Sep 17 00:00:00 2001 From: colton Date: Wed, 14 Oct 2026 13:37:15 -0700 Subject: [PATCH 07/21] test(desk): the received purchase order says what was received (red for the walk note) After Confirm Receipt the order moves to RECEIVED but nothing on the page says what was received. These legs want the page to say in words what came in, how much of each line, into which bin, and that the order is now received, and they fail on today's code. Signed-off-by: colton --- .../purchasing/PurchaseOrderDetail.test.ts | 101 ++++++++++++++++++ 1 file changed, 101 insertions(+) diff --git a/web/apps/desk/src/pages/purchasing/PurchaseOrderDetail.test.ts b/web/apps/desk/src/pages/purchasing/PurchaseOrderDetail.test.ts index 1f179a09a..a8700d3df 100644 --- a/web/apps/desk/src/pages/purchasing/PurchaseOrderDetail.test.ts +++ b/web/apps/desk/src/pages/purchasing/PurchaseOrderDetail.test.ts @@ -229,4 +229,105 @@ describe('purchase order detail receive form', () => { .filter((label) => label !== 'Select...') expect(optionLabels).toEqual(['KEL-A', 'KEL-B', 'KEL-C', 'KEL-D']) }) +}) + +/** + * After Confirm Receipt the page itself must say what happened: what came + * in, how much of each line, into which bin, and that the purchase order + * is now received. A toast that vanishes is not enough. + */ +describe('purchase order receipt confirmation', () => { + const line1 = { id: 'l-1', po_id: PO_ID, product_id: PRODUCT_ID, description: '2x4x8 SPF Premium', quantity: 50, qty_received: 0, cost: 4.5 } + const line2 = { id: 'l-2', po_id: PO_ID, product_id: PRODUCT_ID, description: 'Joist Hanger 2x8', quantity: 30, qty_received: 0, cost: 1.25 } + const locations = [ + loc('loc-a', PO_BRANCH_ID, 'bin', 'KEL-A'), + loc('loc-b', PO_BRANCH_ID, 'bin', 'KEL-B'), + ] + const locationsPage = { items: locations, next_cursor: null, limit: 200 } + + function stubFetch() { + let received = false + vi.stubGlobal('fetch', vi.fn(async (url: string) => { + if (url.includes(`/purchase-orders/${PO_ID}/receive`)) { + received = true + return jsonResponse({ status: 'received' }) + } + if (url.includes(`/purchase-orders/${PO_ID}/freight`)) return jsonResponse([]) + if (url.endsWith(`/purchase-orders/${PO_ID}`)) { + return jsonResponse(received + ? po({ status: 'RECEIVED', lines: [{ ...line1, qty_received: 50 }, { ...line2, qty_received: 30 }] }) + : po({ status: 'SENT', lines: [line1, line2] })) + } + if (url.includes('/locations')) return jsonResponse(locationsPage) + if (url.endsWith('/vendors/v-1')) return jsonResponse({ id: 'v-1', name: 'Acme Lumber' }) + if (url.endsWith(`/products/${PRODUCT_ID}`)) return jsonResponse({ id: PRODUCT_ID, sku: 'LUM-2X4-08' }) + return jsonResponse([]) + })) + } + + async function openReceiveForm(el: PurchaseOrderDetail) { + const receiveBtn = Array.from(el.querySelectorAll('button')).find((b) => textOf(b as Element).includes('Receive Items')) as HTMLButtonElement + expect(receiveBtn, 'expected a Receive Items button').toBeDefined() + receiveBtn.click() + await flush() + await flush() + await el.updateComplete + } + + it('after Confirm Receipt the page says what came in, how much of each line, into which bin, and that the order is now received', async () => { + stubFetch() + const el = await mountAsync('gable-purchase-order-detail', { routeId: PO_ID }) + await openReceiveForm(el) + + const qtyInputs = Array.from(el.querySelectorAll('input[type="number"]')) as HTMLInputElement[] + const selects = Array.from(el.querySelectorAll('select')) as HTMLSelectElement[] + expect(qtyInputs.length).toBe(2) + expect(selects.length).toBe(2) + + qtyInputs[0].value = '50' + qtyInputs[0].dispatchEvent(new Event('input')) + selects[0].value = 'loc-a' + selects[0].dispatchEvent(new Event('change')) + qtyInputs[1].value = '30' + qtyInputs[1].dispatchEvent(new Event('input')) + selects[1].value = 'loc-b' + selects[1].dispatchEvent(new Event('change')) + await flush() + await el.updateComplete + + const confirmBtn = Array.from(el.querySelectorAll('button')).find((b) => textOf(b as Element).includes('Confirm Receipt')) as HTMLButtonElement + expect(confirmBtn, 'expected a Confirm Receipt button').toBeDefined() + confirmBtn.click() + await flush() + await flush() + await el.updateComplete + + const page = textOf(el) + expect(page).toContain('This purchase order is now received') + expect(page).toContain('2x4x8 SPF Premium') + expect(page).toContain('Joist Hanger 2x8') + expect(page).toContain('50') + expect(page).toContain('30') + expect(page).toContain('into KEL-A') + expect(page).toContain('into KEL-B') + }) + + it('a purchase order already received says so in words on the page', async () => { + vi.stubGlobal('fetch', vi.fn(async (url: string) => { + if (url.includes(`/purchase-orders/${PO_ID}/freight`)) return jsonResponse([]) + if (url.endsWith(`/purchase-orders/${PO_ID}`)) { + return jsonResponse(po({ status: 'RECEIVED', lines: [{ ...line1, qty_received: 50 }, { ...line2, qty_received: 30 }] })) + } + if (url.includes('/locations')) return jsonResponse(locationsPage) + if (url.endsWith('/vendors/v-1')) return jsonResponse({ id: 'v-1', name: 'Acme Lumber' }) + if (url.endsWith(`/products/${PRODUCT_ID}`)) return jsonResponse({ id: PRODUCT_ID, sku: 'LUM-2X4-08' }) + return jsonResponse([]) + })) + const el = await mountAsync('gable-purchase-order-detail', { routeId: PO_ID }) + + const page = textOf(el) + expect(page).toContain('This purchase order is now received') + expect(page).toContain('50 of 50 received') + expect(page).toContain('30 of 30 received') + }) }) \ No newline at end of file From fcb54bb96f6f8676c3ddbef6c63fee43b5d8dfe6 Mon Sep 17 00:00:00 2001 From: colton Date: Wed, 14 Oct 2026 13:39:02 -0700 Subject: [PATCH 08/21] feat(desk): the received purchase order says what was received Confirm Receipt used to move the order to RECEIVED with only a toast that vanished. The page now carries the receipt in words: what came in, how much of each line, into which bin, and that the purchase order is now received. A reloaded received order lists each line's received count; the bins are named for the receipt the page just confirmed, as the wire does not carry a line's bin. Signed-off-by: colton --- .../pages/purchasing/PurchaseOrderDetail.ts | 61 ++++++++++++++++++- 1 file changed, 60 insertions(+), 1 deletion(-) diff --git a/web/apps/desk/src/pages/purchasing/PurchaseOrderDetail.ts b/web/apps/desk/src/pages/purchasing/PurchaseOrderDetail.ts index 4fe4065e0..e5f45f775 100644 --- a/web/apps/desk/src/pages/purchasing/PurchaseOrderDetail.ts +++ b/web/apps/desk/src/pages/purchasing/PurchaseOrderDetail.ts @@ -27,6 +27,9 @@ export class PurchaseOrderDetail extends LitElement { @state() private receiving = false; @state() private receiveData: Record = {}; @state() private isSubmitting = false; + /** The receipt just confirmed on this page: what came in, how much of + * each line, into which bin. Kept so the page says it in words. */ + @state() private lastReceipt: { description: string; sku?: string; qty: number; bin: string }[] | null = null; @state() private freightCharges: FreightCharge[] = []; @state() private freightUploading = false; @@ -161,7 +164,19 @@ export class PurchaseOrderDetail extends LitElement { } await PurchaseOrderService.receivePO(this.po.id, { lines }); - ToastService.show('Items received into inventory', 'success'); + const binLabel = (id: string) => { + const loc = this.locations.find((l) => l.id === id); + return loc ? loc.path || loc.code : id; + }; + this.lastReceipt = lines.map((l) => { + const poLine = (this.po?.lines || []).find((pl) => pl.id === l.line_id); + return { + description: poLine?.description ?? '', + sku: poLine?.sku, + qty: l.qty_received, + bin: binLabel(l.location_id), + }; + }); this.receiving = false; this._loadPO(this.po.id); } catch (err) { @@ -221,6 +236,48 @@ export class PurchaseOrderDetail extends LitElement { }; } + /** The receipt, in words, on the page: what came in, how much of each + * line, into which bin, and that the order is now received. The bins + * are named only for the receipt this page just confirmed (the wire + * does not carry a line's bin); a reloaded received order lists each + * line's received count instead. */ + private _renderReceipt() { + const po = this.po; + if (!po) return nothing; + const receivedLines = (po.lines || []).filter((l) => l.qty_received > 0); + if (!this.lastReceipt && receivedLines.length === 0) return nothing; + if (po.status !== 'RECEIVED' && po.status !== 'PARTIAL') return nothing; + + const statusWords = po.status === 'RECEIVED' + ? 'This purchase order is now received.' + : 'This purchase order is partially received.'; + + return html` +
+
+ ${icon(CheckCircle, 20, 'w-5 h-5 text-gable-green')} +

${this.lastReceipt ? 'Receipt confirmed' : 'Received into inventory'}

+
+

${statusWords}

+
    + ${this.lastReceipt + ? this.lastReceipt.map((r) => html` +
  • + ${r.qty} × ${r.description}${r.sku ? html` (${r.sku})` : nothing} into ${r.bin} +
  • + `) + : receivedLines.map((l) => html` +
  • + ${l.description}${l.sku ? html` (${l.sku})` : nothing}: + ${l.qty_received} of + ${l.quantity} received +
  • + `)} +
+
+ `; + } + render() { if (!this.po) { return html` @@ -279,6 +336,8 @@ export class PurchaseOrderDetail extends LitElement {
+ ${this._renderReceipt()} +
From 9f8406c2847ec1e42ebe5ba93c2fe806a67d2062 Mon Sep 17 00:00:00 2001 From: colton Date: Wed, 14 Oct 2026 13:41:19 -0700 Subject: [PATCH 09/21] test(desk): the header's person menu holds Sign out (red for the walk note) The desk's header has no sign out; only the portal layout has one. These legs want the name chip to open a real menu with the person's name and role and Sign out, signing out to clear the session custody and leave the desk for the front door, with arrows moving, Escape closing and returning the focus, and a click outside closing. They fail on today's code: the header has no menu at all. Signed-off-by: colton --- .../src/components/layout/app-shell.test.ts | 85 +++++++++++++++++++ 1 file changed, 85 insertions(+) diff --git a/web/apps/desk/src/components/layout/app-shell.test.ts b/web/apps/desk/src/components/layout/app-shell.test.ts index 2565053e4..12f3ac7e5 100644 --- a/web/apps/desk/src/components/layout/app-shell.test.ts +++ b/web/apps/desk/src/components/layout/app-shell.test.ts @@ -351,3 +351,88 @@ describe('gable-app-shell: the header user block', () => { expect(text(el)).toContain('Sales') }) }) + +/** + * The header's person menu (walk note: there is no obvious way to sign + * out). The name chip opens a menu with the person's name and role and + * Sign out; signing out clears the session custody and leaves the desk + * for the front door. The menu is a real menu: arrows move, Escape + * closes and returns the focus, a click outside closes. + */ +describe('gable-app-shell: the person menu', () => { + async function signedInShell(): Promise { + authConfig.devMode = true + authCustody.signInDev('Jordan Pike', { role: 'cashier', homeYardCode: 'KEL-MAIN' }) + const el = await shellAt('/orders') + await flush() + await update(el, {}) + return el + } + + it('the name chip is a menu button holding the name, the role and Sign out', async () => { + const el = await signedInShell() + const chip = q(el, 'button[aria-haspopup="menu"]') + expect(chip.getAttribute('aria-expanded')).toBe('false') + + chip.click() + await update(el, {}) + + expect(chip.getAttribute('aria-expanded')).toBe('true') + const menu = q(el, '[role="menu"]') + expect(text(menu)).toContain('Jordan Pike') + expect(text(menu)).toContain('Cashier') + const item = q(menu, '[role="menuitem"]') + expect(text(item)).toBe('Sign out') + }) + + it('Sign out clears the session and leaves the desk for the door', async () => { + const el = await signedInShell() + const goToDoor = vi.spyOn(el as unknown as { _goToDoor: () => void }, '_goToDoor') + .mockImplementation(() => {}) + + q(el, 'button[aria-haspopup="menu"]').click() + await update(el, {}) + q(el, '[role="menuitem"]').click() + await flush() + + expect(authCustody.session).toBeNull() + expect(goToDoor).toHaveBeenCalled() + }) + + it('Escape closes the menu and returns the focus to the chip', async () => { + const el = await signedInShell() + const chip = q(el, 'button[aria-haspopup="menu"]') + chip.click() + await update(el, {}) + + q(el, '[role="menu"]').dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true })) + await update(el, {}) + await flush() + + expect(el.querySelector('[role="menu"]')).toBeNull() + expect(document.activeElement).toBe(chip) + }) + + it('a click outside closes the menu', async () => { + const el = await signedInShell() + q(el, 'button[aria-haspopup="menu"]').click() + await update(el, {}) + expect(el.querySelector('[role="menu"]')).not.toBeNull() + + document.body.click() + await update(el, {}) + + expect(el.querySelector('[role="menu"]')).toBeNull() + }) + + it('ArrowDown on the chip opens the menu and moves to Sign out', async () => { + const el = await signedInShell() + const chip = q(el, 'button[aria-haspopup="menu"]') + chip.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowDown', bubbles: true })) + await update(el, {}) + await flush() + + const item = q(el, '[role="menuitem"]') + expect(document.activeElement).toBe(item) + }) +}) From 5e6b71858f616e60535267502f42ecea7e97b4e2 Mon Sep 17 00:00:00 2001 From: colton Date: Wed, 14 Oct 2026 13:43:34 -0700 Subject: [PATCH 10/21] feat(desk): sign out lives in the header's person menu The desk's header had no sign out; only the portal layout had one. The signed in person's name chip now opens a menu with their name and role and Sign out, which clears the shared auth custody and leaves the desk for the front door. The menu is a real menu: the chip carries aria-haspopup and aria-expanded, arrows move between items, Escape closes and returns the focus to the chip, and a click outside closes. Signed-off-by: colton --- .../desk/src/components/layout/app-shell.ts | 124 +++++++++++++++--- 1 file changed, 107 insertions(+), 17 deletions(-) diff --git a/web/apps/desk/src/components/layout/app-shell.ts b/web/apps/desk/src/components/layout/app-shell.ts index 63b50e0fc..8420020b9 100644 --- a/web/apps/desk/src/components/layout/app-shell.ts +++ b/web/apps/desk/src/components/layout/app-shell.ts @@ -20,7 +20,7 @@ import '../ui/omnibar.ts'; import '../ui/shortcuts-modal.ts'; import './branch-switcher.ts'; import { icon } from '../../lib/icons.ts'; -import { Search, X, Plus } from 'lucide'; +import { Search, X, Plus, LogOut } from 'lucide'; /** Initials for a display name (the same rule the door uses). */ function initialsFor(name: string): string { @@ -44,10 +44,16 @@ export class GableAppShell extends LitElement { @state() private _shortcutsOpen = false; @state() private _isOffline = !navigator.onLine; + @state() private _userMenuOpen = false; private _boundKeyDown = this._handleKeyDown.bind(this); private _boundOnline = () => { this._isOffline = false; }; private _boundOffline = () => { this._isOffline = true; }; + private _boundDocClick = (e: Event) => { + if (!this._userMenuOpen) return; + if ((e.target as HTMLElement).closest?.('[data-user-menu]')) return; + this._userMenuOpen = false; + }; private _boundRouteChanged = () => { workspace.syncToPath(router.currentPath); this.requestUpdate(); @@ -59,6 +65,7 @@ export class GableAppShell extends LitElement { window.addEventListener('keydown', this._boundKeyDown); window.addEventListener('online', this._boundOnline); window.addEventListener('offline', this._boundOffline); + document.addEventListener('click', this._boundDocClick); router.addEventListener('route-changed', this._boundRouteChanged); workspace.addEventListener('workspace-changed', this._boundWorkspaceChanged); workspace.restore(); @@ -70,6 +77,7 @@ export class GableAppShell extends LitElement { window.removeEventListener('keydown', this._boundKeyDown); window.removeEventListener('online', this._boundOnline); window.removeEventListener('offline', this._boundOffline); + document.removeEventListener('click', this._boundDocClick); router.removeEventListener('route-changed', this._boundRouteChanged); workspace.removeEventListener('workspace-changed', this._boundWorkspaceChanged); } @@ -82,11 +90,11 @@ export class GableAppShell extends LitElement { } /** - * The signed in user's initials, name and role, read from the auth session. - * A persona card sets the role and home yard; a token session uses the - * email/name from the claims. When nothing is signed in this renders the - * empty state (the existing header avatar is gone: there is no signed in - * user to show). + * The signed in user's name chip and its menu. The chip opens a small + * menu with the person's name and role and Sign out; signing out clears + * the shared auth custody and leaves the desk for the front door. When + * nothing is signed in this renders the empty state (there is no signed + * in user to show). */ private _renderUser() { const session = authCustody.session; @@ -101,21 +109,103 @@ export class GableAppShell extends LitElement { : 'local development'; const initials = name === '' ? '??' : initialsFor(name); return html` - -
- ${initials} +
+ + ${this._userMenuOpen ? html` + + ` : nothing}
`; } + /** ArrowDown on the chip opens the menu and moves to its first item. */ + private _handleUserChipKeydown(e: KeyboardEvent) { + if (e.key !== 'ArrowDown') return; + e.preventDefault(); + this._userMenuOpen = true; + void this.updateComplete.then(() => { + this.querySelector('[role="menuitem"]')?.focus(); + }); + } + + /** In the menu: the arrows move between items, Escape closes and + * returns the focus to the chip. */ + private _handleUserMenuKeydown(e: KeyboardEvent) { + if (e.key === 'Escape') { + e.preventDefault(); + this._closeUserMenu(true); + return; + } + if (e.key !== 'ArrowDown' && e.key !== 'ArrowUp') return; + e.preventDefault(); + const items = Array.from(this.querySelectorAll('[role="menuitem"]')); + if (items.length === 0) return; + const idx = items.indexOf(document.activeElement as HTMLElement); + const next = e.key === 'ArrowDown' + ? (idx + 1) % items.length + : (idx - 1 + items.length) % items.length; + items[next].focus(); + } + + private _closeUserMenu(returnFocus: boolean) { + this._userMenuOpen = false; + if (returnFocus) { + void this.updateComplete.then(() => { + this.querySelector('button[aria-haspopup="menu"]')?.focus(); + }); + } + } + + private _handleSignOut() { + this._userMenuOpen = false; + authCustody.signOut(); + this._goToDoor(); + } + + /** The front door is a separate document served at /, so leaving the + * desk for it is a full navigation, the same way the door's tiles open + * the desk. */ + private _goToDoor() { + window.location.assign('/'); + } + private _tab(tab: WorkspaceTab) { const active = workspace.activeKey === tab.key; return html` From af613a5b651695758d6905a71c3db18864843841 Mon Sep 17 00:00:00 2001 From: colton Date: Wed, 14 Oct 2026 14:18:27 -0700 Subject: [PATCH 11/21] fix(desk): the account menu chip keeps its own accessible name The chip's accessible name came from its content, so a signed in name like a demo persona's became part of it and page queries that match a button by a word in the person's name (the Payments tab on the account page, under the persona Playwright Payments) hit the chip instead. The chip now announces itself as Account menu; the person's name and role stay visible on the chip and inside the menu. Signed-off-by: colton --- web/apps/desk/src/components/layout/app-shell.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/web/apps/desk/src/components/layout/app-shell.ts b/web/apps/desk/src/components/layout/app-shell.ts index 8420020b9..4c08c2063 100644 --- a/web/apps/desk/src/components/layout/app-shell.ts +++ b/web/apps/desk/src/components/layout/app-shell.ts @@ -115,6 +115,7 @@ export class GableAppShell extends LitElement { @keydown=${this._handleUserChipKeydown} aria-haspopup="menu" aria-expanded=${this._userMenuOpen ? 'true' : 'false'} + aria-label="Account menu" class="flex items-center gap-2 rounded-lg focus:outline-none focus-visible:ring-2 focus-visible:ring-gable-green focus-visible:ring-offset-2 focus-visible:ring-offset-deep-space" >
+ this._openAccount(e, customer.customer_id)} + > this._openAccount(e, customer.customer_id)} + @auxclick=${(e: Event) => this._openAccount(e, customer.customer_id)} >
${this._userMenuOpen ? html` ` : nothing} diff --git a/web/apps/desk/src/pages/Dashboard.test.ts b/web/apps/desk/src/pages/Dashboard.test.ts index 62535cf17..56b117d1b 100644 --- a/web/apps/desk/src/pages/Dashboard.test.ts +++ b/web/apps/desk/src/pages/Dashboard.test.ts @@ -52,7 +52,7 @@ describe('the dashboard drills in', () => { const link = q(el, 'a[href="/reports/daily-till"]') expect(link.getAttribute('aria-label')).toBeNull() expect(link.textContent).toContain("Open today's sales") - expect(link.textContent).toContain('$3,134.94') + expect(link.textContent).toContain('3,134.94') }) it('Active Orders opens the orders list on its Confirmed tab', async () => { From fb663c133c15074181052e59029a4a39bc8114fe Mon Sep 17 00:00:00 2001 From: colton Date: Wed, 14 Oct 2026 15:30:17 -0700 Subject: [PATCH 18/21] test(desk): the orders list's address follows the chosen tab (red for the round 2 notes) Choosing another tab left ?status=confirmed in the address, so a reload reopens Confirmed. This leg chooses All and wants the query dropped with replaceState, not a new history entry; it fails on today's head, which never touches the address. Signed-off-by: colton --- .../desk/src/pages/orders/OrderList.test.ts | 27 ++++++++++++++++++- 1 file changed, 26 insertions(+), 1 deletion(-) diff --git a/web/apps/desk/src/pages/orders/OrderList.test.ts b/web/apps/desk/src/pages/orders/OrderList.test.ts index 89cee3f02..646e4b203 100644 --- a/web/apps/desk/src/pages/orders/OrderList.test.ts +++ b/web/apps/desk/src/pages/orders/OrderList.test.ts @@ -9,7 +9,7 @@ import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest' import './OrderList.ts' import type { GableOrderList } from './OrderList.ts' -import { mountAsync, text, jsonResponse } from '../../test/dom' +import { mountAsync, text, flush, jsonResponse } from '../../test/dom' describe('the orders list reads the status from the address', () => { let requestedUrls: string[] = [] @@ -23,6 +23,7 @@ describe('the orders list reads the status from the address', () => { }) afterEach(() => { + vi.restoreAllMocks() vi.unstubAllGlobals() history.replaceState(null, '', '/orders') }) @@ -55,4 +56,28 @@ describe('the orders list reads the status from the address', () => { ) expect(text(activeTab ?? null)).toBe('All') }) + + it('the address follows the chosen tab, replacing rather than pushing', async () => { + history.replaceState(null, '', '/orders?status=confirmed') + const el = await mountAsync('gable-order-list') + const pushSpy = vi.spyOn(history, 'pushState') + const replaceSpy = vi.spyOn(history, 'replaceState') + + const allTab = Array.from(el.querySelectorAll('button')).find((b) => text(b) === 'All') as HTMLButtonElement + allTab.click() + await flush() + await el.updateComplete + + // Choosing All drops the query, so a reload reopens All. + expect(window.location.pathname + window.location.search).toBe('/orders') + expect(pushSpy).not.toHaveBeenCalled() + expect(replaceSpy).toHaveBeenCalled() + + const confirmedTab = Array.from(el.querySelectorAll('button')).find((b) => text(b) === 'Confirmed') as HTMLButtonElement + confirmedTab.click() + await flush() + await el.updateComplete + + expect(window.location.pathname + window.location.search).toBe('/orders?status=confirmed') + }) }) From 93bf47ac7dee2cae9631353a68503d124fdc8107 Mon Sep 17 00:00:00 2001 From: colton Date: Wed, 14 Oct 2026 15:30:57 -0700 Subject: [PATCH 19/21] fix(desk): the orders list's address follows the chosen tab Choosing a tab now replaces the address with /orders?status= (or /orders for All), so a reload or a copied link reopens what is on screen. A replace, not a new history entry: Back still leaves the page. Signed-off-by: colton --- web/apps/desk/src/pages/orders/OrderList.ts | 3 +++ 1 file changed, 3 insertions(+) diff --git a/web/apps/desk/src/pages/orders/OrderList.ts b/web/apps/desk/src/pages/orders/OrderList.ts index a437f55a9..0689a9da4 100644 --- a/web/apps/desk/src/pages/orders/OrderList.ts +++ b/web/apps/desk/src/pages/orders/OrderList.ts @@ -79,6 +79,9 @@ export class GableOrderList extends LitElement { private setStatus(status: OrderStatus | 'all') { if (this.status === status) return; this.status = status; + // The address follows the chosen tab (a replace, not a new history + // entry), so a reload or a copied link reopens what is on screen. + history.replaceState(null, '', status === 'all' ? '/orders' : `/orders?status=${status}`); this.loading = true; this.loadOrders(); } From c84235d5fce161be7c4176525af088fe1c902abc Mon Sep 17 00:00:00 2001 From: colton Date: Wed, 14 Oct 2026 15:32:22 -0700 Subject: [PATCH 20/21] test(desk): a modifier click on a Top Customers row opens a new tab (red for the round 2 notes) A Ctrl, Cmd or middle click on a row outside its link navigated in place, where the link itself would open a new tab. This leg wants window.open with the account's address and no in-place navigation; it fails on today's head. Signed-off-by: colton --- .../dashboard/TopCustomersTable.test.ts | 34 ++++++++++++++++++- 1 file changed, 33 insertions(+), 1 deletion(-) diff --git a/web/apps/desk/src/components/dashboard/TopCustomersTable.test.ts b/web/apps/desk/src/components/dashboard/TopCustomersTable.test.ts index 7014bb65d..fa16ad86e 100644 --- a/web/apps/desk/src/components/dashboard/TopCustomersTable.test.ts +++ b/web/apps/desk/src/components/dashboard/TopCustomersTable.test.ts @@ -6,7 +6,7 @@ * The row's link is the keyboard path; a click anywhere on the row * follows it. */ -import { describe, it, expect } from 'vitest' +import { describe, it, expect, afterEach, vi } from 'vitest' import { router } from '../../lib/router.ts' import './TopCustomersTable.ts' import type { GableTopCustomersTable } from './TopCustomersTable.ts' @@ -14,6 +14,10 @@ import { mount, q, text, flush } from '../../test/dom' const CUSTOMER = { customer_id: 'cust-1', customer_name: 'Kelbrook Construction', total_revenue: 186600, order_count: 4 } +afterEach(() => { + vi.restoreAllMocks() +}) + describe('top customers table', () => { it("links each row to the customer's account, named for a screen reader", async () => { const el = await mount('gable-top-customers-table', { @@ -43,4 +47,32 @@ describe('top customers table', () => { expect(window.location.pathname).toBe('/accounts/cust-1') }) + + it('a Ctrl, Cmd or middle click on a row outside its link opens the account in a new tab', async () => { + history.replaceState(null, '', '/home') + router.init([ + { path: '/accounts/:id', load: async () => {}, layout: 'erp' }, + { path: '/home', load: async () => {}, layout: 'erp' }, + ]) + const el = await mount('gable-top-customers-table', { + customers: [CUSTOMER], + }) + const openSpy = vi.spyOn(window, 'open').mockImplementation(() => null) + + const revenueCell = Array.from(el.querySelectorAll('td')).find((td) => text(td).includes('$1,866.00')) as HTMLElement + + revenueCell.dispatchEvent(new MouseEvent('click', { bubbles: true, ctrlKey: true })) + await flush() + expect(openSpy).toHaveBeenCalledWith('/accounts/cust-1', '_blank') + expect(window.location.pathname, 'a modifier click does not navigate in place').toBe('/home') + + revenueCell.dispatchEvent(new MouseEvent('click', { bubbles: true, metaKey: true })) + await flush() + expect(openSpy).toHaveBeenCalledTimes(2) + + revenueCell.dispatchEvent(new MouseEvent('auxclick', { bubbles: true, button: 1 })) + await flush() + expect(openSpy).toHaveBeenCalledTimes(3) + expect(window.location.pathname).toBe('/home') + }) }) From d0344f908014d3e170db3ce9c622ec08302af2b6 Mon Sep 17 00:00:00 2001 From: colton Date: Wed, 14 Oct 2026 15:33:26 -0700 Subject: [PATCH 21/21] fix(desk): a modifier click on a Top Customers row opens a new tab A Ctrl or Cmd click, or a middle click (auxclick), on a row outside its link now opens the customer's account in a new tab, as the link itself would; a plain click still navigates in place. Signed-off-by: colton --- .../src/components/dashboard/TopCustomersTable.ts | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/web/apps/desk/src/components/dashboard/TopCustomersTable.ts b/web/apps/desk/src/components/dashboard/TopCustomersTable.ts index 34d1ebe80..a7e9d6df6 100644 --- a/web/apps/desk/src/components/dashboard/TopCustomersTable.ts +++ b/web/apps/desk/src/components/dashboard/TopCustomersTable.ts @@ -14,10 +14,17 @@ export class GableTopCustomersTable extends LitElement { @property({ type: Boolean }) loading = false; /** A click anywhere on the row follows the row's link; a click on the - * link itself is the router's anchor interception, which navigates. */ + * link itself is the router's anchor interception, which navigates. A + * Ctrl, Cmd or middle click opens the account in a new tab, as the + * link itself would. */ private _openAccount(e: Event, customerId: string) { if ((e.target as HTMLElement).closest('a')) return; - router.navigate(`/accounts/${customerId}`); + const url = `/accounts/${customerId}`; + if (e instanceof MouseEvent && (e.ctrlKey || e.metaKey || e.button === 1)) { + window.open(url, '_blank'); + return; + } + router.navigate(url); } render() { @@ -72,6 +79,7 @@ export class GableTopCustomersTable extends LitElement {