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..e2837690d --- /dev/null +++ b/web/apps/desk/src/components/dashboard/InventoryAlertsWidget.test.ts @@ -0,0 +1,27 @@ +// 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, keeping the figures in the name and adding the action", 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')).toBeNull() + expect(text(link)).toContain('Open the stock for Joist Hanger 2x6') + expect(text(link)).toContain('HANGER-26') + expect(text(link)).toContain('Low Stock') + }) +}) diff --git a/web/apps/desk/src/components/dashboard/InventoryAlertsWidget.ts b/web/apps/desk/src/components/dashboard/InventoryAlertsWidget.ts index bbd0de4d7..baf44f73e 100644 --- a/web/apps/desk/src/components/dashboard/InventoryAlertsWidget.ts +++ b/web/apps/desk/src/components/dashboard/InventoryAlertsWidget.ts @@ -58,7 +58,10 @@ export class GableInventoryAlertsWidget extends LitElement { ` : this.alerts.map((alert) => html` - - + Open the stock for ${alert.name} + `) } 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..762ccc3e0 --- /dev/null +++ b/web/apps/desk/src/components/dashboard/KPICard.test.ts @@ -0,0 +1,44 @@ +// 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, q, text } 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) + }) + + it('a card with a drill-in target is a real link whose name keeps the figure and adds the action', 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"]') + // No aria-label: it would replace the card's content as the accessible + // name and hide the figure. The action is visually hidden text instead. + expect(link.getAttribute('aria-label')).toBeNull() + expect(text(link)).toContain("Open today's sales") + expect(text(link)).toContain('100.00') + expect(text(link)).toContain("Today's Revenue") + }) +}) diff --git a/web/apps/desk/src/components/dashboard/KPICard.ts b/web/apps/desk/src/components/dashboard/KPICard.ts index 2b34f5125..1819650d1 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,12 +65,8 @@ export class GableKPICard extends LitElement { ? numericValue.toLocaleString(undefined, { minimumFractionDigits: 2, maximumFractionDigits: 2 }) : this.value; - return html` -
-
- ${this.iconHtml ? html`
${this.iconHtml}
` : nothing} -
- + const card = html` +

${this.cardTitle}

@@ -97,5 +97,16 @@ export class GableKPICard extends LitElement {
`; + + if (!this.href) return card; + return html` + + ${card} + ${this.linkLabel ?? this.cardTitle} + + `; } } diff --git a/web/apps/desk/src/components/dashboard/RecentOrdersFeed.test.ts b/web/apps/desk/src/components/dashboard/RecentOrdersFeed.test.ts index ba278ded5..de7220828 100644 --- a/web/apps/desk/src/components/dashboard/RecentOrdersFeed.test.ts +++ b/web/apps/desk/src/components/dashboard/RecentOrdersFeed.test.ts @@ -3,22 +3,48 @@ /** * The dashboard's recent orders: each row names the customer and links to the - * order; it never prints a fragment of the order's id. + * order; it never prints a fragment of the order's id. Two rows for one + * customer get distinct names (the status and the total ride along). */ 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' +const NEUTRAL_AT = '2000-01-01T00:00:00Z' describe('recent orders feed', () => { it('links each order and prints no id fragment', 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' }], + orders: [{ order_id: ORDER_ID, customer_name: 'Kelbrook Construction', total_amount: 186600, status: 'CONFIRMED', created_at: NEUTRAL_AT }], }) expect(text(el)).toContain('Kelbrook Construction') expect(el.querySelector(`a[href="/orders/${ORDER_ID}"]`)).not.toBeNull() expect(text(el)).not.toContain('f9d3cc06') }) + + it('names each row for a screen reader without hiding its content', 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: NEUTRAL_AT }], + }) + const link = q(el, `a[href="/orders/${ORDER_ID}"]`) + expect(link.getAttribute('aria-label')).toBeNull() + expect(text(link)).toContain('Open the order for Kelbrook Construction') + expect(text(link)).toContain('CONFIRMED') + }) + + it('gives two orders of one customer distinct names', 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: NEUTRAL_AT }, + { order_id: 'a1b2c3d4-6456-4a7a-b6ba-63d0a04f7a63', customer_name: 'Kelbrook Construction', total_amount: 4200, status: 'COMPLETED', created_at: NEUTRAL_AT }, + ], + }) + const links = Array.from(el.querySelectorAll('a[href^="/orders/"]')) + expect(links.length).toBe(2) + expect(text(links[0])).not.toBe(text(links[1])) + expect(text(links[0])).toContain('$1,866.00') + expect(text(links[1])).toContain('$42.00') + }) }) diff --git a/web/apps/desk/src/components/dashboard/RecentOrdersFeed.ts b/web/apps/desk/src/components/dashboard/RecentOrdersFeed.ts index 5b98c36a2..9763b3880 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` - + + Open the order for ${order.customer_name}
${order.customer_name} 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..fa16ad86e --- /dev/null +++ b/web/apps/desk/src/components/dashboard/TopCustomersTable.test.ts @@ -0,0 +1,78 @@ +// 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. + * The row's link is the keyboard path; a click anywhere on the row + * follows it. + */ +import { describe, it, expect, afterEach, vi } from 'vitest' +import { router } from '../../lib/router.ts' +import './TopCustomersTable.ts' +import type { GableTopCustomersTable } from './TopCustomersTable.ts' +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', { + customers: [CUSTOMER], + }) + + 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') + }) + + it('a click anywhere on the row follows the row\'s link', 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], + }) + + // The revenue cell is not inside the link. + const revenueCell = Array.from(el.querySelectorAll('td')).find((td) => text(td).includes('$1,866.00')) as HTMLElement + expect(revenueCell, 'expected the revenue cell').toBeDefined() + revenueCell.click() + await flush() + + 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') + }) +}) diff --git a/web/apps/desk/src/components/dashboard/TopCustomersTable.ts b/web/apps/desk/src/components/dashboard/TopCustomersTable.ts index 2cf76d21a..a7e9d6df6 100644 --- a/web/apps/desk/src/components/dashboard/TopCustomersTable.ts +++ b/web/apps/desk/src/components/dashboard/TopCustomersTable.ts @@ -3,6 +3,7 @@ import { LitElement, html } from 'lit'; import { customElement, property } from 'lit/decorators.js'; +import { router } from '../../lib/router.ts'; import type { TopCustomer } from '../../types/dashboard.ts'; @customElement('gable-top-customers-table') @@ -12,6 +13,20 @@ export class GableTopCustomersTable extends LitElement { @property({ attribute: false }) customers: TopCustomer[] = []; @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. 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; + const url = `/accounts/${customerId}`; + if (e instanceof MouseEvent && (e.ctrlKey || e.metaKey || e.button === 1)) { + window.open(url, '_blank'); + return; + } + router.navigate(url); + } + render() { if (this.loading) { return html` @@ -61,14 +76,22 @@ export class GableTopCustomersTable extends LitElement { ` : this.customers.map((customer) => html` - + this._openAccount(e, customer.customer_id)} + @auxclick=${(e: Event) => this._openAccount(e, customer.customer_id)} + > - + ${customer.customer_name} + ${customer.order_count} 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..78d92f097 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,143 @@ 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"]') + const item = q(menu, '[role="menuitem"]') + expect(text(item)).toBe('Sign out') + // The menu owns only menu items; the name and role sit beside it in the dropdown. + expect(Array.from(menu.children).every((c) => c.getAttribute('role') === 'menuitem')).toBe(true) + const dropdown = q(el, '[data-user-menu]') + expect(text(dropdown)).toContain('Jordan Pike') + expect(text(dropdown)).toContain('Cashier') + }) + + 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) + }) + + it('opening the menu by click (or Enter or Space, which fire click) moves the focus to Sign out', async () => { + const el = await signedInShell() + const chip = q(el, 'button[aria-haspopup="menu"]') + chip.click() + await update(el, {}) + await flush() + + const item = q(el, '[role="menuitem"]') + expect(document.activeElement).toBe(item) + }) + + it('Escape on the chip closes the menu and keeps the focus on the chip', async () => { + const el = await signedInShell() + const chip = q(el, 'button[aria-haspopup="menu"]') + chip.click() + await update(el, {}) + await flush() + + chip.focus() + chip.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true })) + await update(el, {}) + + expect(el.querySelector('[role="menu"]')).toBeNull() + expect(document.activeElement).toBe(chip) + }) + + it('the menu closes when the focus leaves it', async () => { + const el = await signedInShell() + const chip = q(el, 'button[aria-haspopup="menu"]') + chip.click() + await update(el, {}) + await flush() + + const item = q(el, '[role="menuitem"]') + item.dispatchEvent(new FocusEvent('focusout', { relatedTarget: document.body, bubbles: true })) + await update(el, {}) + + expect(el.querySelector('[role="menu"]')).toBeNull() + }) + + it('ArrowUp on the chip opens the menu on its last item', async () => { + const el = await signedInShell() + const chip = q(el, 'button[aria-haspopup="menu"]') + chip.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowUp', bubbles: true })) + await update(el, {}) + await flush() + + const items = Array.from(el.querySelectorAll('[role="menuitem"]')) + expect(items.length).toBeGreaterThan(0) + expect(document.activeElement).toBe(items[items.length - 1]) + }) +}) diff --git a/web/apps/desk/src/components/layout/app-shell.ts b/web/apps/desk/src/components/layout/app-shell.ts index 25908130a..5e5db71c6 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,147 @@ export class GableAppShell extends LitElement { : 'local development'; const initials = name === '' ? '??' : initialsFor(name); return html` - -
- ${initials} +
+ + ${this._userMenuOpen ? html` +
+
+ ${name !== '' ? html`
${name}
` : nothing} +
${roleWords}
+
+
+ +
+
+ ` : nothing}
`; } + /** Open the menu and move the focus to its first (or last) item. */ + private _openUserMenu(focus: 'first' | 'last') { + this._userMenuOpen = true; + void this.updateComplete.then(() => { + const items = Array.from(this.querySelectorAll('[role="menuitem"]')); + if (items.length === 0) return; + (focus === 'last' ? items[items.length - 1] : items[0]).focus(); + }); + } + + /** Click, Enter or Space on the chip (all fire click): toggle; an open + * moves the focus into the menu, the ARIA menu button pattern. */ + private _toggleUserMenu() { + if (this._userMenuOpen) { + this._closeUserMenu(false); + } else { + this._openUserMenu('first'); + } + } + + /** On the chip: ArrowDown opens on the first item, ArrowUp on the last, + * Escape closes an open menu (the focus is already on the chip). */ + private _handleUserChipKeydown(e: KeyboardEvent) { + if (e.key === 'Escape') { + if (!this._userMenuOpen) return; + e.preventDefault(); + this._closeUserMenu(false); + return; + } + if (e.key === 'ArrowDown') { + e.preventDefault(); + this._openUserMenu('first'); + return; + } + if (e.key === 'ArrowUp') { + e.preventDefault(); + this._openUserMenu('last'); + } + } + + /** The menu closes when the focus leaves the chip and menu for good + * (a Tab past the menu, a click elsewhere that moves the focus). */ + private _handleUserMenuFocusout(e: FocusEvent) { + if (!this._userMenuOpen) return; + const next = e.relatedTarget as Node | null; + const wrap = this.querySelector('[data-user-menu]'); + if (next && wrap?.contains(next)) return; + this._userMenuOpen = false; + } + + /** 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` 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..56b117d1b --- /dev/null +++ b/web/apps/desk/src/pages/Dashboard.test.ts @@ -0,0 +1,79 @@ +// 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', () => { + // A card's accessible name keeps its visible content (the title and the + // figure); the action rides along as visually hidden text, not an + // aria-label that would hide the figure. + 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')).toBeNull() + expect(link.textContent).toContain("Open today's sales") + expect(link.textContent).toContain('3,134.94') + }) + + it('Active Orders opens the orders list on its Confirmed tab', async () => { + const el = await mountAsync('gable-dashboard') + const link = q(el, 'a[href="/orders?status=confirmed"]') + expect(link.getAttribute('aria-label')).toBeNull() + expect(link.textContent).toContain('Active Orders') + expect(link.textContent).toContain('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')).toBeNull() + expect(link.textContent).toContain('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')).toBeNull() + expect(link.textContent).toContain('Open AR Aging') + }) +}) diff --git a/web/apps/desk/src/pages/Dashboard.ts b/web/apps/desk/src/pages/Dashboard.ts index 6d91f33df..f3a8dcc30 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" >
diff --git a/web/apps/desk/src/pages/orders/OrderList.test.ts b/web/apps/desk/src/pages/orders/OrderList.test.ts new file mode 100644 index 000000000..646e4b203 --- /dev/null +++ b/web/apps/desk/src/pages/orders/OrderList.test.ts @@ -0,0 +1,83 @@ +// SPDX-License-Identifier: LicenseRef-OpenLBM-Surface-1.0 +// SPDX-FileCopyrightText: 2026 FutureBuild, Inc. and OpenLBM contributors + +/** + * The orders list reads a known `status` from the page address on load + * and opens on that tab, so a link like `/orders?status=confirmed` (the + * dashboard's Active Orders card) lands on the list it counted. + */ +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest' +import './OrderList.ts' +import type { GableOrderList } from './OrderList.ts' +import { mountAsync, text, flush, jsonResponse } from '../../test/dom' + +describe('the orders list reads the status from the address', () => { + let requestedUrls: string[] = [] + + beforeEach(() => { + requestedUrls = [] + vi.stubGlobal('fetch', vi.fn(async (url: string) => { + requestedUrls.push(url) + return jsonResponse({ items: [], next_cursor: null }) + })) + }) + + afterEach(() => { + vi.restoreAllMocks() + vi.unstubAllGlobals() + history.replaceState(null, '', '/orders') + }) + + it('?status=confirmed opens the list on the Confirmed tab and asks the server for confirmed orders', async () => { + history.replaceState(null, '', '/orders?status=confirmed') + const el = await mountAsync('gable-order-list') + + const ordersCall = requestedUrls.find((u) => u.includes('/api/v1/orders')) + expect(ordersCall, 'expected a call to the orders list').toBeDefined() + expect(ordersCall).toContain('status=confirmed') + + // The Confirmed tab is the chosen one. + const activeTab = Array.from(el.querySelectorAll('button')).find((b) => + b.className.includes('text-gable-green'), + ) + expect(text(activeTab ?? null)).toBe('Confirmed') + }) + + it('a status the list does not know opens All', async () => { + history.replaceState(null, '', '/orders?status=nonsense') + const el = await mountAsync('gable-order-list') + + const ordersCall = requestedUrls.find((u) => u.includes('/api/v1/orders')) + expect(ordersCall).toBeDefined() + expect(ordersCall).not.toContain('status=') + + const activeTab = Array.from(el.querySelectorAll('button')).find((b) => + b.className.includes('text-gable-green'), + ) + 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') + }) +}) diff --git a/web/apps/desk/src/pages/orders/OrderList.ts b/web/apps/desk/src/pages/orders/OrderList.ts index 9747cde80..cffd799da 100644 --- a/web/apps/desk/src/pages/orders/OrderList.ts +++ b/web/apps/desk/src/pages/orders/OrderList.ts @@ -26,6 +26,13 @@ export class GableOrderList extends LitElement { connectedCallback() { super.connectedCallback(); + // A link may name a tab in the address (the dashboard's Active + // Orders card opens /orders?status=confirmed). An unknown value + // opens All. + const requested = new URLSearchParams(window.location.search).get('status')?.toLowerCase(); + if (requested && requested !== 'all' && (STATUS_FILTERS as string[]).includes(requested)) { + this.status = requested as OrderStatus; + } this.loadOrders(); this._unsubBranch = onBranchChanged(() => { this.loading = true; @@ -72,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(); } diff --git a/web/apps/desk/src/pages/purchasing/PurchaseOrderDetail.test.ts b/web/apps/desk/src/pages/purchasing/PurchaseOrderDetail.test.ts index 9af41f7d1..a71f1aae1 100644 --- a/web/apps/desk/src/pages/purchasing/PurchaseOrderDetail.test.ts +++ b/web/apps/desk/src/pages/purchasing/PurchaseOrderDetail.test.ts @@ -195,4 +195,139 @@ 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, + // and the order is numeric inside a prefix (BIN-2 before BIN-10). + const locations = [ + loc('loc-d', PO_BRANCH_ID, 'bin', 'KEL-D'), + loc('bin-10', PO_BRANCH_ID, 'bin', 'BIN-10'), + loc('loc-c', PO_BRANCH_ID, 'bin', 'KEL-C'), + loc('bin-2', PO_BRANCH_ID, 'bin', 'BIN-2'), + 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(['BIN-2', 'BIN-10', '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') + // The receipt's own words, pinned: quantity, line, bin, one phrase each. + expect(page).toContain('50 × 2x4x8 SPF Premium (LUM-2X4-08) into KEL-A') + expect(page).toContain('30 × Joist Hanger 2x8 (LUM-2X4-08) 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 diff --git a/web/apps/desk/src/pages/purchasing/PurchaseOrderDetail.ts b/web/apps/desk/src/pages/purchasing/PurchaseOrderDetail.ts index c0b1cdeb4..218d58307 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; @@ -117,13 +120,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() { @@ -160,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) { @@ -220,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` @@ -278,6 +336,8 @@ export class PurchaseOrderDetail extends LitElement {
+ ${this._renderReceipt()} +