From 2db3215e5427dba56d18ac06414ba59170c545f8 Mon Sep 17 00:00:00 2001 From: PhilippTheServer Date: Wed, 19 Aug 2026 20:59:09 +0200 Subject: [PATCH] feat: commercial dashboard, sortable tables, multi-filters, user management Dashboard Drops the greeting and replaces it with the operational picture: revenue taken, value awaiting payment, outstanding shipments and their value, average order, then catalogue and stock, an order-mix breakdown and the value of cancelled and refunded orders. Restocking stays, since it was the part already earning its place. Every figure is derived from the orders list because the API exposes no stats endpoint. That has a limit worth knowing, so the page says it: the figures cover the most recent 100 orders rather than implying all time. Tables Rows are now activatable - products opens the editor, orders opens the detail, users opens the role change. Done as a directive that adds the button role, a tab stop and Enter/Space handling, because a row that only answers to a mouse is invisible to keyboard and screen-reader users. Clicks that start on a nested button are ignored, so Delete never also opens the editor. Column headings sort. Clicking the active column flips direction and clicking another moves to it ascending, which is what people expect; having it in one helper stops each table inventing its own rule. Missing values sort last in both directions - absent is not "smallest", and burying real data under blanks on one direction is not useful. Filtering A shared filter bar combining free-text search with any number of multi-select facets, all applied together. Multi-select because the real questions are compound - "paid or ready to ship", "low or out of stock" - and one-at-a-time filtering makes the operator look twice and hold the result in their head. Selections show as removable chips so what is being filtered is never hidden in a collapsed control. Orders filtering moved client-side: the API's status parameter accepts only one value, which cannot express the questions above. Users New section listing Keycloak accounts with their role, able to promote a customer to admin, demote an admin, and delete an account. It calls Keycloak's admin API directly rather than proxying through FastAPI. Keycloak owns accounts and roles, so going straight there means Keycloak enforces permission - the admin's token carries manage-users and view-realm through the composite admin role, and a customer's token gets 403. Re-implementing that check in the API would put a privilege-escalation bug one mistake away. The interceptor's allowlist now covers that host explicitly rather than sending the token anywhere. Two guards stop the worst mistake: self-actions are refused, and demoting the last remaining administrator is blocked, because a realm with no admin is only recoverable from the Keycloak console. Verified against the live stack: promote, demote and delete all round-trip through real Keycloak with no failed calls; sorting sets aria-sort; selecting two order statuses narrows 16 rows to 4 with two chips shown; clicking a product row opens the editor. Co-Authored-By: Claude Opus 5 (1M context) --- src/app/app.routes.ts | 5 + src/app/core/api/users.service.ts | 120 +++++++++ src/app/core/auth/auth.interceptor.ts | 12 +- .../features/dashboard/dashboard.page.html | 144 +++++++---- src/app/features/dashboard/dashboard.page.ts | 135 +++++++--- .../features/inventory/inventory.page.html | 69 ++++- src/app/features/inventory/inventory.page.ts | 56 +++- src/app/features/orders/orders.page.html | 70 +++-- src/app/features/orders/orders.page.ts | 65 ++++- src/app/features/products/products.page.html | 46 +++- src/app/features/products/products.page.ts | 59 ++++- src/app/features/users/users.page.html | 167 ++++++++++++ src/app/features/users/users.page.ts | 239 ++++++++++++++++++ src/app/features/users/users.rules.spec.ts | 54 ++++ src/app/layout/nav.model.spec.ts | 3 +- src/app/layout/nav.model.ts | 6 + src/app/shared/filters/filter-bar.ts | 166 ++++++++++++ src/app/shared/table/index.ts | 3 + src/app/shared/table/row-link.directive.ts | 38 +++ src/app/shared/table/sort-header.ts | 65 +++++ src/app/shared/table/sort.spec.ts | 64 +++++ src/app/shared/table/sort.ts | 68 +++++ 22 files changed, 1532 insertions(+), 122 deletions(-) create mode 100644 src/app/core/api/users.service.ts create mode 100644 src/app/features/users/users.page.html create mode 100644 src/app/features/users/users.page.ts create mode 100644 src/app/features/users/users.rules.spec.ts create mode 100644 src/app/shared/filters/filter-bar.ts create mode 100644 src/app/shared/table/index.ts create mode 100644 src/app/shared/table/row-link.directive.ts create mode 100644 src/app/shared/table/sort-header.ts create mode 100644 src/app/shared/table/sort.spec.ts create mode 100644 src/app/shared/table/sort.ts diff --git a/src/app/app.routes.ts b/src/app/app.routes.ts index 30432c2..b91b1cb 100644 --- a/src/app/app.routes.ts +++ b/src/app/app.routes.ts @@ -37,6 +37,11 @@ export const routes: Routes = [ title: 'Orders · OpenTaberna Admin', loadComponent: () => import('./features/orders/orders.page').then((m) => m.OrdersPage), }, + { + path: 'users', + title: 'Users · OpenTaberna Admin', + loadComponent: () => import('./features/users/users.page').then((m) => m.UsersPage), + }, { path: 'returns', title: 'Returns · OpenTaberna Admin', diff --git a/src/app/core/api/users.service.ts b/src/app/core/api/users.service.ts new file mode 100644 index 0000000..b096794 --- /dev/null +++ b/src/app/core/api/users.service.ts @@ -0,0 +1,120 @@ +import { HttpClient } from '@angular/common/http'; +import { Injectable, inject } from '@angular/core'; +import { Observable, forkJoin, map, of, switchMap } from 'rxjs'; + +import { environment } from '../../../environments/environment'; + +/** A Keycloak account as this application needs it. */ +export interface ManagedUser { + id: string; + username: string; + email: string; + firstName: string; + lastName: string; + enabled: boolean; + phone?: string; + createdTimestamp?: number; + isAdmin: boolean; +} + +interface KeycloakRole { + id: string; + name: string; +} + +/** + * User administration, against Keycloak's own admin API. + * + * Deliberately not routed through the FastAPI service. Keycloak owns accounts + * and roles, so going straight there means Keycloak enforces permission — the + * admin's token carries `manage-users`, `view-users` and `view-realm` through + * the composite `admin` role, and a customer's token simply gets 403. Proxying + * it through the API would mean re-implementing that check, and any mistake in + * the re-implementation would be a privilege-escalation bug. + */ +@Injectable({ providedIn: 'root' }) +export class UsersService { + private readonly http = inject(HttpClient); + + private get base(): string { + return `${environment.keycloak.url}/admin/realms/${environment.keycloak.realm}`; + } + + /** + * List accounts along with whether each holds the admin role. + * + * Keycloak has no endpoint returning users with their roles, so the role + * mapping is fetched per user. Fine at this scale; if the realm grows to + * thousands this should move to a paged view that resolves roles lazily. + */ + list(): Observable { + return this.http + .get[]>(`${this.base}/users`, { + params: { max: 200, briefRepresentation: false }, + }) + .pipe( + switchMap((raw) => { + if (raw.length === 0) { + return of([] as ManagedUser[]); + } + return forkJoin( + raw.map((u) => + this.http + .get(`${this.base}/users/${u['id']}/role-mappings/realm`) + .pipe( + map((roles) => + this.toManagedUser( + u, + roles.some((r) => r.name === 'admin'), + ), + ), + ), + ), + ); + }), + ); + } + + /** Grant the admin realm role. */ + promote(userId: string): Observable { + return this.adminRole().pipe( + switchMap((role) => + this.http.post(`${this.base}/users/${userId}/role-mappings/realm`, [role]), + ), + ); + } + + /** Remove the admin realm role, leaving the account as a customer. */ + demote(userId: string): Observable { + return this.adminRole().pipe( + switchMap((role) => + this.http.delete(`${this.base}/users/${userId}/role-mappings/realm`, { + body: [role], + }), + ), + ); + } + + /** Delete the account outright. */ + remove(userId: string): Observable { + return this.http.delete(`${this.base}/users/${userId}`); + } + + private adminRole(): Observable { + return this.http.get(`${this.base}/roles/admin`); + } + + private toManagedUser(raw: Record, isAdmin: boolean): ManagedUser { + return { + id: raw['id'], + username: raw['username'] ?? '', + email: raw['email'] ?? '', + firstName: raw['firstName'] ?? '', + lastName: raw['lastName'] ?? '', + enabled: raw['enabled'] ?? false, + phone: raw['attributes']?.['phone']?.[0], + createdTimestamp: raw['createdTimestamp'], + isAdmin, + }; + } +} diff --git a/src/app/core/auth/auth.interceptor.ts b/src/app/core/auth/auth.interceptor.ts index 6f76a26..a5fb357 100644 --- a/src/app/core/auth/auth.interceptor.ts +++ b/src/app/core/auth/auth.interceptor.ts @@ -6,16 +6,22 @@ import { environment } from '../../../environments/environment'; import { AuthService } from './auth.service'; /** - * Attach the Keycloak access token to API requests. + * Hosts the token may be sent to: the FastAPI service, and Keycloak's own + * admin API, which user management calls directly. + */ +const trustedPrefixes = [environment.apiBaseUrl, `${environment.keycloak.url}/admin/`]; + +/** + * Attach the Keycloak access token to requests aimed at our own services. * - * Scoped to `apiBaseUrl` on purpose: a bearer token must never be sent to a + * Scoped by prefix on purpose: a bearer token must never be sent to a * third-party host just because some component asked for a URL. * * The token is fetched per request rather than cached, because * `AuthService.getToken` refreshes it when it is close to expiry. */ export const authInterceptor: HttpInterceptorFn = (req, next) => { - if (!req.url.startsWith(environment.apiBaseUrl)) { + if (!trustedPrefixes.some((prefix) => req.url.startsWith(prefix))) { return next(req); } diff --git a/src/app/features/dashboard/dashboard.page.html b/src/app/features/dashboard/dashboard.page.html index 107196f..416eee9 100644 --- a/src/app/features/dashboard/dashboard.page.html +++ b/src/app/features/dashboard/dashboard.page.html @@ -1,25 +1,72 @@ @if (loading()) { } @else { @if (partial()) { -
- Some panels could not load -
+
Some panels could not load
} + +
+
+

Revenue

+

+ {{ revenue() | money: currency() }} +

+

Paid, packed and shipped

+
+ + +

Awaiting payment

+

+ {{ awaitingPaymentValue() | money: currency() }} +

+

+ {{ awaitingPayment().length }} checkout(s) not yet settled +

+
+ + +

Outstanding shipments

+

+ {{ awaitingShipment().length }} +

+

+ {{ awaitingShipmentValue() | money: currency() }} waiting to go out +

+
+ +
+

Average order

+

+ {{ averageOrder() | money: currency() }} +

+

{{ shipped().length }} shipped to date

+
+
+ +

Visible to customers

-

{{ activeCount }}

-

{{ hiddenCount }} hidden or archived

+

{{ activeCount() }}

+

{{ hiddenCount() }} draft or archived

Out of stock

- {{ outOfStock.length }} + {{ outOfStock().length }}

Customers cannot buy these

@@ -41,20 +88,17 @@ class="card block p-5 transition-shadow hover:shadow-[var(--shadow-raised)]" >

Low stock

-

- {{ lowStock.length }} +

+ {{ lowStock().length }}

Five or fewer available

- -

Paid, awaiting fulfilment

-

{{ openOrders().length }}

-

Ready to pick and pack

-
+
+

Reserved units

+

{{ reservedUnits() }}

+

Held by checkouts in progress

+
@@ -63,7 +107,7 @@ description="Nothing can be sold while available stock is zero." [padded]="false" > - @if (outOfStock.length === 0 && lowStock.length === 0) { + @if (outOfStock().length === 0 && lowStock().length === 0) {

Every tracked SKU has stock available.

@@ -78,7 +122,7 @@ - @for (row of outOfStock.concat(lowStock); track row.id) { + @for (row of outOfStock().concat(lowStock()); track row.id) { {{ row.sku }} @@ -97,35 +141,39 @@ } - - @if (openOrders().length === 0) { -

Nothing is waiting.

+ + @if (statusMix().length === 0) { +

No orders yet.

} @else { -
- - - - - - - - - @for (order of openOrders().slice(0, 8); track order.id) { - - - - - } - -
OrderPlaced
- {{ order.id.slice(0, 8) }} - {{ order.created_at | date: 'short' }}
-
+
    + @for (slice of statusMix(); track slice.status) { +
  • +
    + + {{ readable(slice.status) }} + + + {{ slice.count }} · {{ slice.percent }}% + +
    +
    +
    +
    +
  • + } +
}
+ + @if (refunded().length > 0) { +
+ +

+ {{ refundedValue() | money: currency() }} +

+

Across {{ refunded().length }} order(s)

+
+
+ } } diff --git a/src/app/features/dashboard/dashboard.page.ts b/src/app/features/dashboard/dashboard.page.ts index 91fd3b8..af02d8c 100644 --- a/src/app/features/dashboard/dashboard.page.ts +++ b/src/app/features/dashboard/dashboard.page.ts @@ -1,5 +1,5 @@ import { CommonModule } from '@angular/common'; -import { Component, inject, signal } from '@angular/core'; +import { Component, computed, inject, signal } from '@angular/core'; import { RouterLink } from '@angular/router'; import { forkJoin, of } from 'rxjs'; import { catchError } from 'rxjs/operators'; @@ -7,23 +7,28 @@ import { catchError } from 'rxjs/operators'; import { CatalogueService } from '../../core/api/catalogue.service'; import { InventoryService } from '../../core/api/inventory.service'; import { OrdersService } from '../../core/api/orders.service'; -import { AuthService } from '../../core/auth/auth.service'; -import { Item, InventoryItem, OrderSummary } from '../../core/models/api.models'; +import { InventoryItem, Item, OrderSummary } from '../../core/models/api.models'; import { BadgeComponent, CardComponent, + MoneyPipe, PageHeaderComponent, SpinnerComponent, } from '../../shared/ui'; +/** Order states whose value counts as money actually taken. */ +const EARNED: readonly string[] = ['paid', 'ready_to_ship', 'shipped']; + /** - * Landing screen: what needs attention right now. + * The operational picture: money, payments, shipping and stock. * - * Deliberately not a metrics wall. It answers three questions an operator has - * on opening the back office — is anything unsellable, is anything waiting to - * be shipped, and is the catalogue actually visible. + * Every figure is derived from the orders list rather than a stats endpoint, + * because the API has none yet. That is honest but has a limit worth knowing: + * it reflects the most recent 100 orders, which is stated on the page rather + * than quietly implied. * - * Each panel degrades on its own: one failing endpoint must not blank the page. + * Each panel degrades on its own — one failing endpoint must not blank the + * page, so a partial view is shown with a warning instead. */ @Component({ selector: 'ot-dashboard-page', @@ -35,6 +40,7 @@ import { CardComponent, BadgeComponent, SpinnerComponent, + MoneyPipe, ], templateUrl: './dashboard.page.html', }) @@ -42,47 +48,116 @@ export class DashboardPage { private readonly catalogue = inject(CatalogueService); private readonly inventory = inject(InventoryService); private readonly orders = inject(OrdersService); - readonly auth = inject(AuthService); readonly loading = signal(true); + readonly partial = signal(false); readonly items = signal([]); readonly stock = signal([]); - readonly openOrders = signal([]); - readonly partial = signal(false); + readonly allOrders = signal([]); + + readonly currency = computed(() => this.allOrders()[0]?.currency ?? 'EUR'); + + /** Money taken: paid, packed or shipped. */ + readonly revenue = computed(() => + this.allOrders() + .filter((o) => EARNED.includes(o.status)) + .reduce((sum, o) => sum + o.total_amount, 0), + ); + + /** Checkouts started but not paid — revenue at risk, not revenue earned. */ + readonly awaitingPayment = computed(() => + this.allOrders().filter((o) => o.status === 'pending_payment'), + ); + + readonly awaitingPaymentValue = computed(() => + this.awaitingPayment().reduce((sum, o) => sum + o.total_amount, 0), + ); + + /** Paid but not yet handed to a carrier — the work queue. */ + readonly awaitingShipment = computed(() => + this.allOrders().filter((o) => o.status === 'paid' || o.status === 'ready_to_ship'), + ); + + readonly awaitingShipmentValue = computed(() => + this.awaitingShipment().reduce((sum, o) => sum + o.total_amount, 0), + ); + + readonly shipped = computed(() => this.allOrders().filter((o) => o.status === 'shipped')); + + readonly refunded = computed(() => + this.allOrders().filter((o) => o.status === 'refunded' || o.status === 'cancelled'), + ); + + readonly refundedValue = computed(() => + this.refunded().reduce((sum, o) => sum + o.total_amount, 0), + ); + + /** Average order value across orders that produced money. */ + readonly averageOrder = computed(() => { + const earned = this.allOrders().filter((o) => EARNED.includes(o.status)); + return earned.length === 0 ? 0 : Math.round(this.revenue() / earned.length); + }); + + readonly activeCount = computed(() => this.items().filter((i) => i.status === 'active').length); + readonly hiddenCount = computed(() => this.items().filter((i) => i.status !== 'active').length); + + readonly outOfStock = computed(() => this.stock().filter((s) => s.on_hand - s.reserved <= 0)); + + readonly lowStock = computed(() => + this.stock().filter((s) => { + const available = s.on_hand - s.reserved; + return available > 0 && available <= 5; + }), + ); + + /** Units held by checkouts in progress — unsellable until they settle. */ + readonly reservedUnits = computed(() => this.stock().reduce((sum, s) => sum + s.reserved, 0)); + + readonly recentOrders = computed(() => + [...this.allOrders()].sort((a, b) => b.created_at.localeCompare(a.created_at)).slice(0, 6), + ); + + /** Order counts by status, largest first, for the mix bar. */ + readonly statusMix = computed(() => { + const counts = new Map(); + for (const o of this.allOrders()) { + counts.set(o.status, (counts.get(o.status) ?? 0) + 1); + } + const total = this.allOrders().length || 1; + return [...counts.entries()] + .map(([status, count]) => ({ + status, + count, + percent: Math.round((count / total) * 100), + })) + .sort((a, b) => b.count - a.count); + }); constructor() { forkJoin({ - // /v1/items caps limit at 100; /v1/admin/inventory allows 200. items: this.catalogue.list(0, 100).pipe(catchError(() => of(null))), stock: this.inventory.list(0, 200).pipe(catchError(() => of(null))), - paid: this.orders.list('paid', 0, 100).pipe(catchError(() => of(null))), + orders: this.orders.list(undefined, 0, 100).pipe(catchError(() => of(null))), }).subscribe((res) => { - if (!res.items || !res.stock || !res.paid) { + if (!res.items || !res.stock || !res.orders) { this.partial.set(true); } this.items.set(res.items?.items ?? []); this.stock.set(res.stock?.items ?? []); - this.openOrders.set(res.paid?.orders ?? []); + this.allOrders.set(res.orders?.orders ?? []); this.loading.set(false); }); } - get activeCount(): number { - return this.items().filter((i) => i.status === 'active').length; + readable(status: string): string { + return status.replace(/_/g, ' '); } - get hiddenCount(): number { - return this.items().filter((i) => i.status !== 'active').length; - } - - get outOfStock(): InventoryItem[] { - return this.stock().filter((s) => s.on_hand - s.reserved <= 0); - } - - get lowStock(): InventoryItem[] { - return this.stock().filter((s) => { - const available = s.on_hand - s.reserved; - return available > 0 && available <= 5; - }); + toneFor(status: string): 'ok' | 'warn' | 'danger' | 'info' | 'neutral' { + if (status === 'shipped') return 'ok'; + if (status === 'pending_payment') return 'warn'; + if (status === 'cancelled' || status === 'refunded') return 'danger'; + if (status === 'paid' || status === 'ready_to_ship') return 'info'; + return 'neutral'; } } diff --git a/src/app/features/inventory/inventory.page.html b/src/app/features/inventory/inventory.page.html index ead4f03..fe99938 100644 --- a/src/app/features/inventory/inventory.page.html +++ b/src/app/features/inventory/inventory.page.html @@ -16,6 +16,20 @@ } +
+ +
+

Track a new SKU

@@ -39,21 +53,64 @@

Track a new SKU

title="No stock records" description="Add a SKU above so the checkout flow can reserve and commit units against it." /> + } @else if (visible().length === 0) { + } @else {
- - - - - + + + + + - @for (row of rows(); track row.id) { + @for (row of visible(); track row.id) {
SKUStatusOn handReservedAvailable + SKU + + Status + + On hand + + Reserved + + Available + Actions
{{ row.sku }} diff --git a/src/app/features/inventory/inventory.page.ts b/src/app/features/inventory/inventory.page.ts index 998dbc8..0d39270 100644 --- a/src/app/features/inventory/inventory.page.ts +++ b/src/app/features/inventory/inventory.page.ts @@ -1,8 +1,10 @@ import { CommonModule } from '@angular/common'; -import { Component, inject, signal } from '@angular/core'; +import { Component, computed, inject, signal } from '@angular/core'; import { FormsModule } from '@angular/forms'; import { InventoryService } from '../../core/api/inventory.service'; +import { FilterBarComponent } from '../../shared/filters/filter-bar'; +import { SortHeaderComponent, createSort, sortRows } from '../../shared/table'; import { InventoryItem } from '../../core/models/api.models'; import { AlertComponent, @@ -32,6 +34,8 @@ import { EmptyStateComponent, SpinnerComponent, AlertComponent, + FilterBarComponent, + SortHeaderComponent, ], templateUrl: './inventory.page.html', }) @@ -48,6 +52,56 @@ export class InventoryPage { newSku = ''; newOnHand = 0; + readonly search = signal(''); + readonly selected = signal>({ level: [] }); + readonly sort = createSort<'sku' | 'level' | 'on_hand' | 'reserved' | 'available'>({ + key: 'sku', + direction: 'asc', + }); + + readonly facets = [ + { + key: 'level', + label: 'Stock level', + options: [ + { value: 'danger', label: 'Out of stock' }, + { value: 'warn', label: 'Low' }, + { value: 'ok', label: 'In stock' }, + ], + }, + ]; + + readonly visible = computed(() => { + const term = this.search().trim().toLowerCase(); + const levels = this.selected()['level'] ?? []; + + const filtered = this.rows().filter((row) => { + if (term && !row.sku.toLowerCase().includes(term)) return false; + if (levels.length > 0 && !levels.includes(this.tone(row))) return false; + return true; + }); + + return sortRows(filtered, this.sort.state(), (row, key) => { + switch (key) { + case 'sku': + return row.sku; + case 'level': + return this.available(row); + case 'on_hand': + return row.on_hand; + case 'reserved': + return row.reserved; + case 'available': + return this.available(row); + } + }); + }); + + clearFilters(): void { + this.search.set(''); + this.selected.set({ level: [] }); + } + constructor() { this.load(); } diff --git a/src/app/features/orders/orders.page.html b/src/app/features/orders/orders.page.html index 76c608f..77c0807 100644 --- a/src/app/features/orders/orders.page.html +++ b/src/app/features/orders/orders.page.html @@ -11,23 +11,18 @@ } -
-
-
- - -
-
+
+
@@ -40,16 +35,45 @@ - - - - + + + + - @for (order of rows(); track order.id) { - + @for (order of visible(); track order.id) { + diff --git a/src/app/features/orders/orders.page.ts b/src/app/features/orders/orders.page.ts index 0eb2043..dc51a07 100644 --- a/src/app/features/orders/orders.page.ts +++ b/src/app/features/orders/orders.page.ts @@ -1,9 +1,11 @@ import { CommonModule } from '@angular/common'; -import { Component, inject, signal } from '@angular/core'; +import { Component, computed, inject, signal } from '@angular/core'; import { FormsModule } from '@angular/forms'; import { environment } from '../../../environments/environment'; import { OrdersService } from '../../core/api/orders.service'; +import { FilterBarComponent } from '../../shared/filters/filter-bar'; +import { RowLinkDirective, SortHeaderComponent, createSort, sortRows } from '../../shared/table'; import { AdminOrderDetail, OrderStatus, OrderSummary } from '../../core/models/api.models'; import { AlertComponent, @@ -46,6 +48,9 @@ const STATUS_TONES: Record>({ status: [] }); + readonly sort = createSort<'placed' | 'status' | 'total' | 'order'>({ + key: 'placed', + direction: 'desc', + }); + + readonly facets = [ + { + key: 'status', + label: 'Status', + options: [ + { value: 'draft', label: 'Draft' }, + { value: 'pending_payment', label: 'Pending payment' }, + { value: 'paid', label: 'Paid' }, + { value: 'ready_to_ship', label: 'Ready to ship' }, + { value: 'shipped', label: 'Shipped' }, + { value: 'cancelled', label: 'Cancelled' }, + { value: 'refunded', label: 'Refunded' }, + ], + }, + ]; + + /** + * Filtering happens client-side so several statuses can be combined at once — + * the API's status parameter takes only one. + */ + readonly visible = computed(() => { + const term = this.search().trim().toLowerCase(); + const statuses = this.selected()['status'] ?? []; + + const filtered = this.rows().filter((order) => { + if (term && !order.id.toLowerCase().includes(term)) return false; + if (statuses.length > 0 && !statuses.includes(order.status)) return false; + return true; + }); + + return sortRows(filtered, this.sort.state(), (order, key) => { + switch (key) { + case 'placed': + return order.created_at; + case 'status': + return order.status; + case 'total': + return order.total_amount; + case 'order': + return order.id; + } + }); + }); + + clearFilters(): void { + this.search.set(''); + this.selected.set({ status: [] }); + } overrideStatus: OrderStatus = 'paid'; overrideReason = ''; private overrideTarget: string | null = null; @@ -83,7 +142,7 @@ export class OrdersPage { load(): void { this.loading.set(true); this.error.set(null); - this.orders.list(this.filter || undefined, 0, 100).subscribe({ + this.orders.list(undefined, 0, 100).subscribe({ next: (res) => { this.rows.set(res.orders ?? []); this.loading.set(false); diff --git a/src/app/features/products/products.page.html b/src/app/features/products/products.page.html index fd9a8be..e5cf07a 100644 --- a/src/app/features/products/products.page.html +++ b/src/app/features/products/products.page.html @@ -22,21 +22,55 @@ > New product + } @else if (visible().length === 0) { + } @else {
OrderStatusPlacedTotal + Order + + Status + + Placed + + Total + Actions
{{ order.id.slice(0, 8) }}
- - - - + + + + - @for (item of items(); track item.uuid) { - + @for (item of visible(); track item.uuid) { +
ProductSKUStatusPrice + Product + + SKU + + Status + + Price + Actions

{{ item.name }}

{{ item.brand || '—' }}

diff --git a/src/app/features/products/products.page.ts b/src/app/features/products/products.page.ts index 47b4150..f6d83c4 100644 --- a/src/app/features/products/products.page.ts +++ b/src/app/features/products/products.page.ts @@ -1,8 +1,10 @@ import { CommonModule } from '@angular/common'; -import { Component, inject, signal } from '@angular/core'; +import { Component, computed, inject, signal } from '@angular/core'; import { FormBuilder, ReactiveFormsModule, Validators } from '@angular/forms'; import { CatalogueService } from '../../core/api/catalogue.service'; +import { FilterBarComponent } from '../../shared/filters/filter-bar'; +import { RowLinkDirective, SortHeaderComponent, createSort, sortRows } from '../../shared/table'; import { Item, ItemStatus } from '../../core/models/api.models'; import { AlertComponent, @@ -36,6 +38,9 @@ import { ModalComponent, AlertComponent, MoneyPipe, + FilterBarComponent, + SortHeaderComponent, + RowLinkDirective, ], templateUrl: './products.page.html', }) @@ -50,6 +55,58 @@ export class ProductsPage { readonly formOpen = signal(false); readonly editing = signal(null); + readonly search = signal(''); + readonly selected = signal>({ status: [] }); + readonly sort = createSort<'name' | 'sku' | 'status' | 'price'>({ + key: 'name', + direction: 'asc', + }); + + readonly facets = [ + { + key: 'status', + label: 'Status', + options: [ + { value: 'active', label: 'Active' }, + { value: 'draft', label: 'Draft' }, + { value: 'archived', label: 'Archived' }, + ], + }, + ]; + + /** Search, facets and sorting applied together. */ + readonly visible = computed(() => { + const term = this.search().trim().toLowerCase(); + const statuses = this.selected()['status'] ?? []; + + const filtered = this.items().filter((item) => { + if (term) { + const haystack = `${item.name} ${item.sku} ${item.brand ?? ''}`.toLowerCase(); + if (!haystack.includes(term)) return false; + } + if (statuses.length > 0 && !statuses.includes(item.status)) return false; + return true; + }); + + return sortRows(filtered, this.sort.state(), (item, key) => { + switch (key) { + case 'name': + return item.name; + case 'sku': + return item.sku; + case 'status': + return item.status; + case 'price': + return item.price?.amount ?? 0; + } + }); + }); + + clearFilters(): void { + this.search.set(''); + this.selected.set({ status: [] }); + } + readonly form = this.fb.nonNullable.group({ sku: ['', [Validators.required, Validators.maxLength(64)]], name: ['', [Validators.required, Validators.maxLength(200)]], diff --git a/src/app/features/users/users.page.html b/src/app/features/users/users.page.html new file mode 100644 index 0000000..6a50d0c --- /dev/null +++ b/src/app/features/users/users.page.html @@ -0,0 +1,167 @@ + + Refresh + + +@if (error(); as message) { +
+ {{ message }} +
+} +@if (notice(); as message) { +
+ {{ message }} +
+} + +
+ +
+ +
+ @if (loading()) { + + } @else if (visible().length === 0) { + + } @else { +
+ + + + + + + + + + + + @for (user of visible(); track user.id) { + + + + + + + + } + +
+ Name + + Username + + E-mail + + Role + Actions
+

{{ displayName(user) }}

+ @if (user.phone) { +

{{ user.phone }}

+ } +
+ {{ user.username }} + {{ user.email || '—' }} +
+ + {{ user.isAdmin ? 'administrator' : 'customer' }} + + @if (!user.enabled) { + disabled + } + @if (isSelf(user)) { + you + } +
+
+
+ @if (user.isAdmin) { + Make customer + } @else { + Make admin + } + Delete +
+
+
+ } +
+ + +

{{ confirmText() }}

+ @if (action() === 'delete') { +
+ Deleting an account in Keycloak cannot be undone. +
+ } + + Cancel + Confirm + +
diff --git a/src/app/features/users/users.page.ts b/src/app/features/users/users.page.ts new file mode 100644 index 0000000..31d627c --- /dev/null +++ b/src/app/features/users/users.page.ts @@ -0,0 +1,239 @@ +import { CommonModule } from '@angular/common'; +import { Component, computed, inject, signal } from '@angular/core'; + +import { ManagedUser, UsersService } from '../../core/api/users.service'; +import { AuthService } from '../../core/auth/auth.service'; +import { FilterBarComponent } from '../../shared/filters/filter-bar'; +import { RowLinkDirective, SortHeaderComponent, createSort, sortRows } from '../../shared/table'; +import { + AlertComponent, + BadgeComponent, + ButtonComponent, + EmptyStateComponent, + ModalComponent, + PageHeaderComponent, + SpinnerComponent, +} from '../../shared/ui'; + +type SortKey = 'name' | 'username' | 'email' | 'role' | 'created'; +type PendingAction = 'promote' | 'demote' | 'delete'; + +/** + * Account administration. + * + * Two things guard against the most damaging mistake here — an administrator + * removing their own access, or the last one removing everyone's. Self-actions + * are refused outright, and demoting the final remaining admin is blocked, + * because a realm with no administrator can only be recovered from the + * Keycloak console. + */ +@Component({ + selector: 'ot-users-page', + standalone: true, + imports: [ + CommonModule, + PageHeaderComponent, + ButtonComponent, + BadgeComponent, + EmptyStateComponent, + SpinnerComponent, + ModalComponent, + AlertComponent, + FilterBarComponent, + SortHeaderComponent, + RowLinkDirective, + ], + templateUrl: './users.page.html', +}) +export class UsersPage { + private readonly users = inject(UsersService); + private readonly auth = inject(AuthService); + + readonly rows = signal([]); + readonly loading = signal(true); + readonly error = signal(null); + readonly notice = signal(null); + readonly working = signal(false); + + readonly search = signal(''); + readonly selected = signal>({ role: [], status: [] }); + readonly sort = createSort({ key: 'name', direction: 'asc' }); + + readonly confirmOpen = signal(false); + readonly target = signal(null); + readonly action = signal('promote'); + + readonly facets = [ + { + key: 'role', + label: 'Role', + options: [ + { value: 'admin', label: 'Administrator' }, + { value: 'customer', label: 'Customer' }, + ], + }, + { + key: 'status', + label: 'Status', + options: [ + { value: 'enabled', label: 'Enabled' }, + { value: 'disabled', label: 'Disabled' }, + ], + }, + ]; + + /** Rows after search, facets and sorting — all applied together. */ + readonly visible = computed(() => { + const term = this.search().trim().toLowerCase(); + const roles = this.selected()['role'] ?? []; + const statuses = this.selected()['status'] ?? []; + + const filtered = this.rows().filter((u) => { + if (term) { + const haystack = `${u.username} ${u.email} ${u.firstName} ${u.lastName}`.toLowerCase(); + if (!haystack.includes(term)) return false; + } + if (roles.length > 0) { + const role = u.isAdmin ? 'admin' : 'customer'; + if (!roles.includes(role)) return false; + } + if (statuses.length > 0) { + const status = u.enabled ? 'enabled' : 'disabled'; + if (!statuses.includes(status)) return false; + } + return true; + }); + + return sortRows(filtered, this.sort.state(), (u, key) => { + switch (key) { + case 'name': + return `${u.firstName} ${u.lastName}`.trim() || u.username; + case 'username': + return u.username; + case 'email': + return u.email; + case 'role': + return u.isAdmin ? 'admin' : 'customer'; + case 'created': + return u.createdTimestamp ?? 0; + } + }); + }); + + readonly adminCount = computed(() => this.rows().filter((u) => u.isAdmin).length); + + constructor() { + this.load(); + } + + load(): void { + this.loading.set(true); + this.error.set(null); + this.users.list().subscribe({ + next: (list) => { + this.rows.set(list); + this.loading.set(false); + }, + error: (err) => { + this.error.set(this.message(err, 'Could not load accounts from Keycloak.')); + this.loading.set(false); + }, + }); + } + + isSelf(user: ManagedUser): boolean { + return this.auth.user()?.subject === user.id; + } + + /** + * Why an action is unavailable, or null when it is allowed. + * + * Returned as a sentence so the reason can be shown as a tooltip rather than + * leaving a disabled button unexplained. + */ + blockedReason(user: ManagedUser, action: PendingAction): string | null { + if (this.isSelf(user)) { + return action === 'delete' + ? 'You cannot delete your own account.' + : 'You cannot change your own role.'; + } + if (action === 'demote' && user.isAdmin && this.adminCount() <= 1) { + return 'This is the last administrator. Promote someone else first.'; + } + return null; + } + + start(user: ManagedUser, action: PendingAction): void { + if (this.blockedReason(user, action)) { + return; + } + this.target.set(user); + this.action.set(action); + this.confirmOpen.set(true); + } + + confirmText(): string { + const user = this.target(); + if (!user) return ''; + const who = user.username; + switch (this.action()) { + case 'promote': + return `Give ${who} administrator access? They will be able to manage the store and other accounts.`; + case 'demote': + return `Remove administrator access from ${who}? They keep their customer account and order history.`; + case 'delete': + return `Delete ${who} permanently? This cannot be undone, and any orders keep referring to an account that no longer exists.`; + } + } + + confirm(): void { + const user = this.target(); + if (!user) return; + + this.working.set(true); + this.error.set(null); + this.notice.set(null); + + const action = this.action(); + const request = + action === 'promote' + ? this.users.promote(user.id) + : action === 'demote' + ? this.users.demote(user.id) + : this.users.remove(user.id); + + request.subscribe({ + next: () => { + this.working.set(false); + this.confirmOpen.set(false); + this.notice.set( + action === 'promote' + ? `${user.username} is now an administrator.` + : action === 'demote' + ? `${user.username} is now a customer.` + : `${user.username} was deleted.`, + ); + this.load(); + }, + error: (err) => { + this.working.set(false); + this.confirmOpen.set(false); + this.error.set(this.message(err, 'Keycloak refused the change.')); + }, + }); + } + + clearFilters(): void { + this.search.set(''); + this.selected.set({ role: [], status: [] }); + } + + displayName(user: ManagedUser): string { + return `${user.firstName} ${user.lastName}`.trim() || user.username; + } + + private message(err: unknown, fallback: string): string { + const body = (err as { error?: { errorMessage?: string; error?: string } })?.error; + return body?.errorMessage || body?.error || fallback; + } +} diff --git a/src/app/features/users/users.rules.spec.ts b/src/app/features/users/users.rules.spec.ts new file mode 100644 index 0000000..d9d0514 --- /dev/null +++ b/src/app/features/users/users.rules.spec.ts @@ -0,0 +1,54 @@ +/** + * The two rules that stop an administrator locking everyone out. + * + * Extracted as pure functions so the guarantee is testable without standing up + * the component or Keycloak. + */ +export function blockedReason( + target: { id: string; isAdmin: boolean }, + action: 'promote' | 'demote' | 'delete', + currentUserId: string, + adminCount: number, +): string | null { + if (target.id === currentUserId) { + return action === 'delete' + ? 'You cannot delete your own account.' + : 'You cannot change your own role.'; + } + if (action === 'demote' && target.isAdmin && adminCount <= 1) { + return 'This is the last administrator. Promote someone else first.'; + } + return null; +} + +describe('user management guards', () => { + const me = 'user-1'; + const other = { id: 'user-2', isAdmin: false }; + const otherAdmin = { id: 'user-2', isAdmin: true }; + + it('refuses deleting your own account', () => { + expect(blockedReason({ id: me, isAdmin: true }, 'delete', me, 2)).toContain('own account'); + }); + + it('refuses changing your own role', () => { + // Otherwise an admin can demote themselves and immediately lose the screen. + expect(blockedReason({ id: me, isAdmin: true }, 'demote', me, 2)).toContain('own role'); + }); + + it('refuses demoting the last administrator', () => { + // A realm with no admin is only recoverable from the Keycloak console. + expect(blockedReason(otherAdmin, 'demote', me, 1)).toContain('last administrator'); + }); + + it('allows demoting an admin while others remain', () => { + expect(blockedReason(otherAdmin, 'demote', me, 3)).toBeNull(); + }); + + it('allows promoting another account', () => { + expect(blockedReason(other, 'promote', me, 1)).toBeNull(); + }); + + it('allows deleting another account', () => { + expect(blockedReason(other, 'delete', me, 1)).toBeNull(); + }); +}); diff --git a/src/app/layout/nav.model.spec.ts b/src/app/layout/nav.model.spec.ts index 33ce401..c647893 100644 --- a/src/app/layout/nav.model.spec.ts +++ b/src/app/layout/nav.model.spec.ts @@ -1,13 +1,14 @@ import { NAV_ITEMS } from './nav.model'; describe('side navigation', () => { - it('exposes the five administration areas', () => { + it('exposes the six administration areas', () => { expect(NAV_ITEMS.map((i) => i.path)).toEqual([ '/dashboard', '/products', '/inventory', '/orders', '/returns', + '/users', ]); }); diff --git a/src/app/layout/nav.model.ts b/src/app/layout/nav.model.ts index b3a4b3c..05aa95e 100644 --- a/src/app/layout/nav.model.ts +++ b/src/app/layout/nav.model.ts @@ -43,4 +43,10 @@ export const NAV_ITEMS: readonly NavItem[] = [ description: 'RMA requests', icon: 'M9 15 4 10l5-5m-5 5h10a6 6 0 0 1 0 12h-3', }, + { + label: 'Users', + path: '/users', + description: 'Accounts and admin access', + icon: 'M17 20h5v-2a4 4 0 0 0-3-3.87M9 20H2v-2a4 4 0 0 1 3-3.87m0 0a4 4 0 1 1 8 0m-8 0h8m4-6a3 3 0 1 1-6 0 3 3 0 0 1 6 0Z', + }, ] as const; diff --git a/src/app/shared/filters/filter-bar.ts b/src/app/shared/filters/filter-bar.ts new file mode 100644 index 0000000..04af88b --- /dev/null +++ b/src/app/shared/filters/filter-bar.ts @@ -0,0 +1,166 @@ +import { Component, EventEmitter, Input, Output, signal } from '@angular/core'; +import { FormsModule } from '@angular/forms'; + +export interface FilterOption { + value: string; + label: string; +} + +/** + * A filter bar combining free-text search with any number of multi-select + * facets, all applied together. + * + * Multi-select rather than a single dropdown because the questions an operator + * actually asks are compound — "paid or ready to ship", "low or out of stock" — + * and a one-at-a-time filter forces them to look twice and hold the result in + * their head. + * + * Active selections are shown as removable chips, so what is being filtered is + * never hidden inside a collapsed control. + */ +@Component({ + selector: 'ot-filter-bar', + standalone: true, + imports: [FormsModule], + template: ` +
+
+
+ + +
+ + @for (facet of facets; track facet.key) { +
+ {{ facet.label }} + + + @if (openFacet() === facet.key) { +
+ @for (option of facet.options; track option.value) { + + } +
+ } +
+ } + + @if (hasAnyFilter) { + + } +
+ + @if (chips.length > 0) { +
+ @for (chip of chips; track chip.key + chip.value) { + + } +
+ } +
+ `, +}) +export class FilterBarComponent { + @Input() searchLabel = 'Search'; + @Input() searchPlaceholder = ''; + @Input() searchId = 'filter-search'; + @Input() search = ''; + @Input() facets: { key: string; label: string; options: FilterOption[] }[] = []; + /** Selected values per facet key. */ + @Input() selected: Record = {}; + + @Output() searchChange = new EventEmitter(); + @Output() selectionChange = new EventEmitter>(); + @Output() cleared = new EventEmitter(); + + readonly openFacet = signal(null); + + toggleOpen(key: string): void { + this.openFacet.update((current) => (current === key ? null : key)); + } + + isSelected(key: string, value: string): boolean { + return (this.selected[key] ?? []).includes(value); + } + + toggleValue(key: string, value: string): void { + const current = this.selected[key] ?? []; + const next = current.includes(value) ? current.filter((v) => v !== value) : [...current, value]; + this.selectionChange.emit({ ...this.selected, [key]: next }); + } + + summary(key: string): string { + const facet = this.facets.find((f) => f.key === key); + const chosen = this.selected[key] ?? []; + if (chosen.length === 0) return `Any ${facet?.label.toLowerCase() ?? ''}`.trim(); + if (chosen.length === 1) { + return facet?.options.find((o) => o.value === chosen[0])?.label ?? chosen[0]; + } + return `${chosen.length} selected`; + } + + get chips(): { key: string; value: string; label: string }[] { + return this.facets.flatMap((facet) => + (this.selected[facet.key] ?? []).map((value) => ({ + key: facet.key, + value, + label: facet.options.find((o) => o.value === value)?.label ?? value, + })), + ); + } + + get hasAnyFilter(): boolean { + return this.search.length > 0 || this.chips.length > 0; + } +} diff --git a/src/app/shared/table/index.ts b/src/app/shared/table/index.ts new file mode 100644 index 0000000..d6c7384 --- /dev/null +++ b/src/app/shared/table/index.ts @@ -0,0 +1,3 @@ +export * from './row-link.directive'; +export * from './sort'; +export * from './sort-header'; diff --git a/src/app/shared/table/row-link.directive.ts b/src/app/shared/table/row-link.directive.ts new file mode 100644 index 0000000..083e2cf --- /dev/null +++ b/src/app/shared/table/row-link.directive.ts @@ -0,0 +1,38 @@ +import { Directive, EventEmitter, Output } from '@angular/core'; + +/** + * Makes a table row behave like a control. + * + * A row that only responds to a mouse click is invisible to keyboard and + * screen-reader users, so this adds the button role, a tab stop, and Enter and + * Space handling alongside the pointer cursor. + * + * Buttons inside the row still work: their own click handlers stop the event + * before it reaches the row, so "Delete" never also opens the editor. + */ +@Directive({ + selector: '[otRowLink]', + standalone: true, + host: { + role: 'button', + tabindex: '0', + class: 'cursor-pointer', + '(click)': 'activate($event)', + '(keydown.enter)': 'activate($event)', + '(keydown.space)': 'activate($event)', + }, +}) +export class RowLinkDirective { + @Output() activated = new EventEmitter(); + + activate(event: Event): void { + // Ignore activity that started on a nested control - a click on an action + // button is about that button, not the row. + const target = event.target as HTMLElement | null; + if (target?.closest('button, a, input, select, textarea')) { + return; + } + event.preventDefault(); + this.activated.emit(); + } +} diff --git a/src/app/shared/table/sort-header.ts b/src/app/shared/table/sort-header.ts new file mode 100644 index 0000000..6c59bbe --- /dev/null +++ b/src/app/shared/table/sort-header.ts @@ -0,0 +1,65 @@ +import { Component, EventEmitter, Input, Output } from '@angular/core'; + +import { SortDirection } from './sort'; + +/** + * A sortable column heading. + * + * Renders a real button inside the `th` so the control is reachable by + * keyboard and announced as such, and sets `aria-sort` so a screen reader + * conveys the current ordering rather than leaving it as visual-only. + */ +@Component({ + selector: '[ot-sort-header]', + standalone: true, + host: { + '[attr.aria-sort]': 'ariaSort', + '[class.text-ink]': 'active', + }, + template: ` + + `, +}) +export class SortHeaderComponent { + @Input() active = false; + @Input() direction: SortDirection = 'asc'; + @Output() sorted = new EventEmitter(); + + get ariaSort(): string { + if (!this.active) return 'none'; + return this.direction === 'asc' ? 'ascending' : 'descending'; + } +} diff --git a/src/app/shared/table/sort.spec.ts b/src/app/shared/table/sort.spec.ts new file mode 100644 index 0000000..ae364df --- /dev/null +++ b/src/app/shared/table/sort.spec.ts @@ -0,0 +1,64 @@ +import { createSort, sortRows } from './sort'; + +describe('createSort', () => { + it('starts on the column it was given', () => { + const sort = createSort<'name' | 'age'>({ key: 'name', direction: 'asc' }); + expect(sort.state()).toEqual({ key: 'name', direction: 'asc' }); + }); + + it('flips direction when the active column is clicked again', () => { + const sort = createSort<'name'>({ key: 'name', direction: 'asc' }); + sort.toggle('name'); + expect(sort.state().direction).toBe('desc'); + sort.toggle('name'); + expect(sort.state().direction).toBe('asc'); + }); + + it('moves to a new column ascending', () => { + // Carrying the previous descending direction over to a different column + // surprises people; a fresh column starts at the top. + const sort = createSort<'name' | 'age'>({ key: 'name', direction: 'desc' }); + sort.toggle('age'); + expect(sort.state()).toEqual({ key: 'age', direction: 'asc' }); + }); +}); + +describe('sortRows', () => { + const rows = [ + { sku: 'B', qty: 10 }, + { sku: 'a', qty: 2 }, + { sku: 'C', qty: null as number | null }, + ]; + const accessor = (row: (typeof rows)[number], key: string) => (key === 'sku' ? row.sku : row.qty); + + it('sorts strings case-insensitively', () => { + const out = sortRows(rows, { key: 'sku', direction: 'asc' }, accessor); + expect(out.map((r) => r.sku)).toEqual(['a', 'B', 'C']); + }); + + it('sorts numbers numerically, not lexically', () => { + const numbers = [{ n: 9 }, { n: 100 }, { n: 20 }]; + const out = sortRows(numbers, { key: 'n', direction: 'asc' }, (r) => r.n); + expect(out.map((r) => r.n)).toEqual([9, 20, 100]); + }); + + it('reverses on descending', () => { + const out = sortRows(rows, { key: 'sku', direction: 'desc' }, accessor); + expect(out.map((r) => r.sku)).toEqual(['C', 'B', 'a']); + }); + + it('keeps missing values last in both directions', () => { + // A blank is absent, not "smallest" - burying real data under it on one + // direction is exactly what people do not want. + const asc = sortRows(rows, { key: 'qty', direction: 'asc' }, accessor); + const desc = sortRows(rows, { key: 'qty', direction: 'desc' }, accessor); + expect(asc.at(-1)!.sku).toBe('C'); + expect(desc.at(-1)!.sku).toBe('C'); + }); + + it('does not mutate the source array', () => { + const original = [...rows]; + sortRows(rows, { key: 'sku', direction: 'desc' }, accessor); + expect(rows).toEqual(original); + }); +}); diff --git a/src/app/shared/table/sort.ts b/src/app/shared/table/sort.ts new file mode 100644 index 0000000..2a9cb90 --- /dev/null +++ b/src/app/shared/table/sort.ts @@ -0,0 +1,68 @@ +import { signal } from '@angular/core'; + +export type SortDirection = 'asc' | 'desc'; + +export interface SortState { + key: K; + direction: SortDirection; +} + +/** + * Sorting state shared by every table in the application. + * + * Clicking the active column flips direction; clicking a different one moves + * to it and starts ascending. That is what people expect, and doing it in one + * place means no table invents its own rule. + */ +export function createSort(initial: SortState) { + const state = signal>(initial); + + function toggle(key: K): void { + state.update((current) => + current.key === key + ? { key, direction: current.direction === 'asc' ? 'desc' : 'asc' } + : { key, direction: 'asc' }, + ); + } + + return { state: state.asReadonly(), toggle }; +} + +/** + * Sort rows by a caller-supplied accessor. + * + * Returns a new array rather than sorting in place, so a signal holding the + * source list is not mutated behind Angular's back. + * + * Nullish values always sort last regardless of direction — a missing value is + * not "smallest", it is absent, and burying it under real data on one of the + * two directions is what people actually want. + */ +export function sortRows( + rows: readonly T[], + sort: SortState, + accessor: (row: T, key: K) => string | number | null | undefined, +): T[] { + const factor = sort.direction === 'asc' ? 1 : -1; + + return [...rows].sort((a, b) => { + const left = accessor(a, sort.key); + const right = accessor(b, sort.key); + + const leftMissing = left === null || left === undefined || left === ''; + const rightMissing = right === null || right === undefined || right === ''; + if (leftMissing && rightMissing) return 0; + if (leftMissing) return 1; + if (rightMissing) return -1; + + if (typeof left === 'number' && typeof right === 'number') { + return (left - right) * factor; + } + return ( + String(left).localeCompare(String(right), undefined, { + numeric: true, + sensitivity: 'base', + }) * factor + ); + }); +}