Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
23 commits
Select commit Hold shift + click to select a range
75c744a
test(desk): the KPI card draws its icon once (red for the double icon…
futurebuildai Oct 14, 2026
1fa9ea7
fix(desk): the KPI card keeps only its boxed icon
futurebuildai Oct 14, 2026
13b623a
test(desk): the dashboard's cards and rows drill in (red for the walk…
futurebuildai Oct 14, 2026
a70ee2e
feat(desk): the dashboard's cards and rows drill in
futurebuildai Oct 14, 2026
265252e
test(desk): the receive bin picker lists the yard's bins in path orde…
futurebuildai Oct 14, 2026
6d620cc
fix(desk): the receive bin picker lists the yard's bins in path order
futurebuildai Oct 14, 2026
5259b97
test(desk): the received purchase order says what was received (red f…
futurebuildai Oct 14, 2026
fcb54bb
feat(desk): the received purchase order says what was received
futurebuildai Oct 14, 2026
9f8406c
test(desk): the header's person menu holds Sign out (red for the walk…
futurebuildai Oct 14, 2026
5e6b718
feat(desk): sign out lives in the header's person menu
futurebuildai Oct 14, 2026
af613a5
fix(desk): the account menu chip keeps its own accessible name
futurebuildai Oct 14, 2026
09f9870
test(desk): the person menu's keys on every open path (red for the PR…
futurebuildai Oct 14, 2026
74c74d9
fix(desk): the person menu keeps the keys on every open path
futurebuildai Oct 14, 2026
01dc6a3
test(desk): Active Orders lands on the Confirmed orders (red for the …
futurebuildai Oct 14, 2026
215ed7f
fix(desk): Active Orders opens the orders list on its Confirmed tab
futurebuildai Oct 14, 2026
bf1878d
test(desk): the P3 review legs (red for the PR 151 review)
futurebuildai Oct 14, 2026
ad83ec6
fix(desk): the P3 review fixes
futurebuildai Oct 14, 2026
e00e6e9
Merge origin/refactor/v1 into refactor/g1-manager-view
futurebuildai Oct 14, 2026
fb663c1
test(desk): the orders list's address follows the chosen tab (red for…
futurebuildai Oct 14, 2026
93bf47a
fix(desk): the orders list's address follows the chosen tab
futurebuildai Oct 14, 2026
c84235d
test(desk): a modifier click on a Top Customers row opens a new tab (…
futurebuildai Oct 14, 2026
d0344f9
fix(desk): a modifier click on a Top Customers row opens a new tab
futurebuildai Oct 14, 2026
b7958ea
Merge origin/refactor/v1 into refactor/g1-manager-view
futurebuildai Oct 15, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -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<GableInventoryAlertsWidget>('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<HTMLAnchorElement>(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')
})
})
Original file line number Diff line number Diff line change
Expand Up @@ -58,7 +58,10 @@ export class GableInventoryAlertsWidget extends LitElement {
</div>
`
: this.alerts.map((alert) => html`
<div class="p-4 hover:bg-white/5 transition-colors group">
<a
href=${`/inventory/${alert.product_id}`}
class="block p-4 hover:bg-white/5 transition-colors group focus:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-gable-green"
>
<h4 class="text-sm font-medium text-white mb-1 group-hover:text-amber-400 transition-colors">
${alert.name}
</h4>
Expand All @@ -75,7 +78,8 @@ export class GableInventoryAlertsWidget extends LitElement {
</span>
</div>
</div>
</div>
<span class="sr-only">Open the stock for ${alert.name}</span>
</a>
`)
}
</div>
Expand Down
44 changes: 44 additions & 0 deletions web/apps/desk/src/components/dashboard/KPICard.test.ts
Original file line number Diff line number Diff line change
@@ -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<GableKPICard>('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<GableKPICard>('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<HTMLAnchorElement>(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")
})
})
23 changes: 17 additions & 6 deletions web/apps/desk/src/components/dashboard/KPICard.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -61,12 +65,8 @@ export class GableKPICard extends LitElement {
? numericValue.toLocaleString(undefined, { minimumFractionDigits: 2, maximumFractionDigits: 2 })
: this.value;

return html`
<div class="rounded-xl border border-white/10 bg-slate-steel/50 hover:bg-slate-steel/70 hover:border-white/20 transition-all duration-300 cursor-pointer group relative overflow-hidden">
<div class="absolute top-0 right-0 p-4 opacity-10 group-hover:opacity-20 transition-opacity duration-300 transform group-hover:scale-110">
${this.iconHtml ? html`<div class="text-current scale-150">${this.iconHtml}</div>` : nothing}
</div>

const card = html`
<div class="rounded-xl border border-white/10 bg-slate-steel/50 hover:bg-slate-steel/70 hover:border-white/20 transition-all duration-300 cursor-pointer group relative overflow-hidden h-full">
<div class="p-6 relative z-10">
<div class="flex items-center justify-between mb-2">
<h3 class="text-sm font-medium text-zinc-400 font-sans tracking-wide">${this.cardTitle}</h3>
Expand Down Expand Up @@ -97,5 +97,16 @@ export class GableKPICard extends LitElement {
<div class="absolute -bottom-4 -right-4 w-24 h-24 bg-gable-green/20 blur-3xl rounded-full opacity-0 group-hover:opacity-100 transition-opacity duration-500 pointer-events-none"></div>
</div>
`;

if (!this.href) return card;
return html`
<a
href=${this.href}
class="block rounded-xl focus:outline-none focus-visible:ring-2 focus-visible:ring-gable-green focus-visible:ring-offset-2 focus-visible:ring-offset-deep-space"
>
${card}
<span class="sr-only">${this.linkLabel ?? this.cardTitle}</span>
</a>
`;
}
}
32 changes: 29 additions & 3 deletions web/apps/desk/src/components/dashboard/RecentOrdersFeed.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<GableRecentOrdersFeed>('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<GableRecentOrdersFeed>('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<HTMLAnchorElement>(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<GableRecentOrdersFeed>('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<HTMLAnchorElement>('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')
})
})
6 changes: 5 additions & 1 deletion web/apps/desk/src/components/dashboard/RecentOrdersFeed.ts
Original file line number Diff line number Diff line change
Expand Up @@ -58,7 +58,11 @@ export class GableRecentOrdersFeed extends LitElement {
${this.orders.length === 0
? html`<div class="p-6 text-center text-zinc-500">No recent orders</div>`
: this.orders.map((order) => html`
<a href="/orders/${order.order_id}" class="block p-4 hover:bg-white/5 transition-colors group">
<a
href="/orders/${order.order_id}"
class="block p-4 hover:bg-white/5 transition-colors group focus:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-gable-green"
>
<span class="sr-only">Open the order for ${order.customer_name}</span>
<div class="flex justify-between items-start mb-1">
<div class="font-medium text-white group-hover:text-gable-green transition-colors">
${order.customer_name}
Expand Down
78 changes: 78 additions & 0 deletions web/apps/desk/src/components/dashboard/TopCustomersTable.test.ts
Original file line number Diff line number Diff line change
@@ -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<GableTopCustomersTable>('gable-top-customers-table', {
customers: [CUSTOMER],
})

const link = q<HTMLAnchorElement>(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<GableTopCustomersTable>('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<GableTopCustomersTable>('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')
})
})
31 changes: 27 additions & 4 deletions web/apps/desk/src/components/dashboard/TopCustomersTable.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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')
Expand All @@ -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`
Expand Down Expand Up @@ -61,14 +76,22 @@ export class GableTopCustomersTable extends LitElement {
</tr>
`
: this.customers.map((customer) => html`
<tr class="group hover:bg-white/5 transition-colors">
<tr
class="group hover:bg-white/5 transition-colors cursor-pointer"
@click=${(e: Event) => this._openAccount(e, customer.customer_id)}
@auxclick=${(e: Event) => this._openAccount(e, customer.customer_id)}
>
<td class="px-6 py-3 font-medium text-white">
<div class="flex items-center gap-3">
<a
href=${`/accounts/${customer.customer_id}`}
aria-label="Open ${customer.customer_name}'s account"
class="flex items-center gap-3 rounded-md focus:outline-none focus-visible:ring-2 focus-visible:ring-gable-green"
>
<div class="h-8 w-8 rounded-full bg-gradient-to-br from-zinc-700 to-zinc-800 flex items-center justify-center text-xs font-bold text-white border border-white/10 shadow-sm group-hover:scale-105 transition-transform">
${customer.customer_name.substring(0, 2).toUpperCase()}
</div>
${customer.customer_name}
</div>
<span class="group-hover:text-gable-green transition-colors">${customer.customer_name}</span>
</a>
</td>
<td class="px-6 py-3 text-right font-mono text-zinc-400">
${customer.order_count}
Expand Down
Loading
Loading