diff --git a/.claude/agent-memory/backend-developer/story_1030.md b/.claude/agent-memory/backend-developer/story_1030.md
new file mode 100644
index 000000000..f9a981b09
--- /dev/null
+++ b/.claude/agent-memory/backend-developer/story_1030.md
@@ -0,0 +1,121 @@
+---
+name: Story #1030 Implementation
+description: Migration 0028 + shared types foundation for EPIC-18 (Areas & Trades)
+type: project
+---
+
+## Story #1030 Complete: Migration + Shared Types Foundation
+
+**Status**: Implemented and pushed to `feat/1030-migration-shared-types`
+**Commit**: 647acbe — feat(epic-18): Migration 0028 + shared types foundation (Story #1030)
+
+### What Was Done
+
+#### 1. SQL Migration (0028_areas_trades_rework.sql)
+
+- Created `trades` table with 15 default trades (Plumbing, HVAC, Electrical, etc.)
+- Migrated `vendor.specialty` string values to new trades lookup table
+- Recreated `vendors` table: removed specialty column, added trade_id FK
+- Recreated `vendor_contacts` table (dropped and recreated to maintain data)
+- Created `areas` table with hierarchical support (name + parent_id self-reference)
+- Added `area_id` and `assigned_vendor_id` columns to `work_items`
+- Replaced `room` column with `area_id` on `household_items`
+- Dropped all tag-related tables (`tags`, `work_item_tags`, `household_item_tags`)
+- Updated default budget categories (added Waste, removed Equipment/Landscaping/Utilities/Insurance/Contingency if unused)
+
+#### 2. New Shared Type Files
+
+- **shared/src/types/area.ts** — AreaSummary, AreaResponse, AreaListResponse, CreateAreaRequest, UpdateAreaRequest, AreaListQuery
+- **shared/src/types/trade.ts** — TradeSummary, TradeResponse, TradeListResponse, CreateTradeRequest, UpdateTradeRequest, TradeListQuery
+
+#### 3. Updated Shared Types
+
+**workItem.ts**:
+
+- Removed `import type { TagResponse } from './tag.js'`
+- Added `VendorSummary` interface (id + name + trade)
+- WorkItemSummary: removed `tags`, added `assignedVendor` + `area`
+- WorkItemDetail: removed `tags`, added `assignedVendor` + `area`
+- CreateWorkItemRequest/UpdateWorkItemRequest: removed `tagIds`, added `assignedVendorId` + `areaId`
+- WorkItemListQuery: removed `tagId`, added `assignedVendorId` + `areaId`
+
+**householdItem.ts**:
+
+- Updated HouseholdItemVendorSummary: `specialty: string | null` → `trade: TradeSummary | null`
+- HouseholdItemSummary: removed `room` + `tagIds`, added `area`
+- HouseholdItemDetail: removed `tags` field
+- CreateHouseholdItemRequest/UpdateHouseholdItemRequest: removed `room` + `tagIds`, added `areaId`
+- HouseholdItemListQuery: removed `room` + `tagId`, added `areaId`, removed 'room' from sortBy
+
+**vendor.ts**:
+
+- Updated Vendor interface: `specialty: string | null` → `trade: TradeSummary | null`
+- CreateVendorRequest/UpdateVendorRequest: `specialty` → `tradeId`
+- VendorListQuery: `specialty` → `trade` in sortBy, added `tradeId` filter
+
+**timeline.ts**:
+
+- Added `assignedVendor: VendorSummary | null` to TimelineWorkItem
+- Added `area: AreaSummary | null` to TimelineWorkItem
+- Removed `tags: TagResponse[]` from TimelineWorkItem
+
+**budget.ts**:
+
+- Removed duplicate VendorSummary definition
+- Now imports VendorSummary from workItem.ts
+
+**errors.ts**:
+
+- Added `AREA_IN_USE` error code (409)
+- Added `TRADE_IN_USE` error code (409)
+
+**schema.ts** (Drizzle ORM):
+
+- Added `trades` table definition with indexes
+- Added `areas` table definition with hierarchical support (self-referencing parent_id)
+- Updated `vendors` table: removed specialty, added tradeId FK
+- Updated `workItems` table: added areaId and assignedVendorId FKs
+- Updated `householdItems` table: removed room, added areaId FK
+- Removed tags, workItemTags, householdItemTags table definitions
+- Imported `type AnySQLiteColumn` for the self-referencing areas.parent_id type
+
+**index.ts**:
+
+- Removed tag exports (Tag, TagResponse, CreateTagRequest, UpdateTagRequest, TagListResponse)
+- Added area exports (AreaSummary, AreaResponse, AreaListResponse, AreaSingleResponse, CreateAreaRequest, UpdateAreaRequest, AreaListQuery)
+- Added trade exports (TradeSummary, TradeResponse, TradeListResponse, TradeSingleResponse, CreateTradeRequest, UpdateTradeRequest, TradeListQuery)
+- Added VendorSummary to WorkItems exports
+- Removed VendorSummary from WorkItemBudgets exports (no duplicate)
+
+### Type Validation
+
+All production type definitions compile cleanly (verified individually):
+
+- area.ts ✓
+- trade.ts ✓
+- workItem.ts ✓
+- householdItem.ts ✓
+- vendor.ts ✓
+- timeline.ts ✓
+- budget.ts ✓
+- errors.ts ✓
+- workItemBudget.ts ✓
+
+Test files in shared have expected failures (using old properties: room, tags, tagIds, specialty) — these will be fixed by QA when updating tests.
+
+### Downstream Dependencies
+
+Subsequent stories depend on these foundation types:
+
+- **Story #1031 + #1032** (Areas + Trades Backend CRUD): Will implement GET/POST/PATCH/DELETE endpoints
+- **Story #1033 + #1034** (Work Item + HI Rework): Will update service/route handlers to use new fields
+- **Story #1035** (Frontend Manage Page): Will create UI for areas/trades management
+- **Story #1037** (Frontend Entity Integration): Will add area/vendor selection to work item/HI forms
+
+### Important Notes
+
+- The migration uses the vendor table recreation pattern from migration 0026 (drop old, create new, copy data, rename)
+- Areas support hierarchical structure via parent_id self-reference using AnySQLiteColumn type annotation
+- VendorSummary is now unified (defined once in workItem.ts, imported by budget.ts) to avoid duplication
+- Tags are completely removed from the database schema and API types
+- No routes or services are implemented in this story — pure foundation work
diff --git a/.claude/agent-memory/e2e-test-engineer/MEMORY.md b/.claude/agent-memory/e2e-test-engineer/MEMORY.md
index 2ac2d1cc4..8c273803a 100644
--- a/.claude/agent-memory/e2e-test-engineer/MEMORY.md
+++ b/.claude/agent-memory/e2e-test-engineer/MEMORY.md
@@ -3,6 +3,42 @@
> Detailed notes live in topic files. This index links to them.
> See: `e2e-pom-patterns.md`, `e2e-parallel-isolation.md`, `story-epic08-e2e.md`, `story-933-dav-vendor-contacts.md`
+## i18n German Locale: page.reload() Required After setLanguage() + page.goto() (2026-03-23)
+
+After `setLanguage(page, 'de')` + `page.goto(targetUrl)`, always add `page.reload()` before
+asserting German text. Pattern from "Key page headings render in German" test (passing) confirms.
+Applied in i18n.spec.ts "German sidebar" test and all three i18n-categories.spec.ts German tests.
+**The FIRST German locale switch in a test file needs `test.setTimeout(30000)` and a 20s expect
+timeout** for the heading assertion — i18next cold-start initialization takes 10-15s on CI.
+Pattern: `test.setTimeout(30000); setLanguage(de); goto(URL); reload(); expect(heading).toBeVisible({ timeout: 20000 })`.
+Extra warm-up navigations (goto('/') to confirm 'Projekt') consume the 30s budget — avoid them.
+
+**Known flaky test**: "German locale: Manage trades tab shows 'Sanitär' instead of 'Plumbing'"
+(`i18n-categories.spec.ts`) fails intermittently on CI — locale doesn't initialize before the
+English page renders. Was failing before PR #1186 too (run 23429182196). Not blocking for beta PRs.
+
+## WorkItemsPage.search(): URL-based Wait Prevents Stale-DOM Race (2026-03-23)
+
+After `fill(query)`, add `page.waitForURL(url => url.searchParams.get('q') === query)` BEFORE
+awaiting the `waitForResponse`. This confirms the debounce fired and React committed search state.
+Do NOT call `waitForLoaded()` after the response — it resolves on stale DOM rows from the WebKit
+clear-event response and creates a race where betaTitle stays visible for 10s. The test's own
+`expect().not.toBeVisible()` retry handles DOM convergence. Same pattern for `clearSearch()`.
+
+## Dashboard Card Dismiss Reload: Use networkidle, Not waitForResponse (2026-03-23)
+
+For "dismissed card stays hidden after page reload" test: register `waitForResponse(GET preferences)`
+before reload failed — LocaleContext fires FIRST GET and resolves the promise, but usePreferences
+hook's second GET (which applies hiddenCards) arrives later. Fix: use `page.waitForLoadState('networkidle')`
+AFTER `heading.waitFor({ state: 'visible', timeout: 10000 })` to ensure BOTH preference fetches
+complete. The heading waitFor needs 10s timeout (not 5s actionTimeout) since SPA reinit takes time.
+
+## Vendor Count Assertions Are Fragile (2026-03-23)
+
+`getVendorNames().length` assertions are unreliable with parallel workers sharing the same DB.
+Use `not.toContain(specificName)` instead of exact count equality. Remove `namesBefore`/
+`namesAfter` length comparisons in cancel/no-create tests.
+
## E2E Parallel Isolation (2026-02-20)
`testPrefix` fixture in `e2e/fixtures/auth.ts` — use `async (_fixtures, use, testInfo)` (NOT `{}` — ESLint `no-empty-pattern`).
@@ -21,6 +57,25 @@ See `e2e-pom-patterns.md` for full details on:
3. **Mobile CSS-hidden table** — `display:none` elements still in DOM; `textContent()` works,
clicks fail — check `tableContainer.isVisible()` before using table rows
+## DataTable Migration (EPIC-18, PR #1177) POM Fixes (2026-03-22)
+
+After DataTable migration, three POM fix patterns applied:
+
+- **Modal `useId()` IDs broken**: `#create-modal-title`/`#delete-modal-title` don't exist.
+ Always use `getByRole('dialog', { name: ... })` + `getByRole('heading', { level: 2 })` inside.
+- **`confirmDeleteButton` → `btnConfirmDelete`**: WorkItems + HouseholdItems use
+ `sharedStyles.btnConfirmDelete` from `shared.module.css`. Selector: `[class*="btnConfirmDelete"]`.
+- **Mobile card name lookup**: DataTableCard has NO `cardName` class. The render() function
+ runs identically for table cells AND cards. Name column with `styles.vendorLink` → use
+ `[class*="vendorLink"]` inside `cardsContainer`. Applied in both getVendorNames() and
+ openDeleteModal() mobile paths in VendorsPage.
+- **HouseholdItems actions menu**: buttons are `role="button"` (default), NOT `role="menuitem"`.
+ Use `[class*="menuItemDanger"]:visible` filtered by text "Delete".
+- **Production bug #1178**: DateRangePicker phase resets after clicking start date.
+ DateFilter.handleChange only fires when both dates set; DateRangePicker useEffect resets
+ phase when startDate stays ''. Affects datatable-date-range-picker.spec.ts and
+ datatable-ux-fixes.spec.ts — PRODUCTION BUG, not a test issue.
+
## E2E Wait Patterns: waitForResponse BEFORE the action (2026-02-23)
`page.waitForResponse(pred)` must ALWAYS be registered BEFORE the action that triggers the request.
diff --git a/.claude/agent-memory/e2e-test-engineer/e2e-pom-patterns.md b/.claude/agent-memory/e2e-test-engineer/e2e-pom-patterns.md
index 0fcac958a..186ce3206 100644
--- a/.claude/agent-memory/e2e-test-engineer/e2e-pom-patterns.md
+++ b/.claude/agent-memory/e2e-test-engineer/e2e-pom-patterns.md
@@ -260,3 +260,59 @@ wraps responses in `{ budgetSource: {...} }` / `{ subsidyProgram: {...} }`.
**Not fixable in test code**. Fix must be applied to `client/src/lib/budgetSourcesApi.ts`
and `client/src/lib/subsidyProgramsApi.ts`. Tracked in GitHub issue #175.
+
+## HTML5 Drag Events via Synthetic Dispatch (2026-03-23, PR #1177)
+
+`page.mouse.down/move` does NOT fire HTML5 drag events (dragstart/dragover/drop).
+Use `page.evaluate()` with `new DragEvent(...)` for full control.
+
+**Critical**: `effectAllowed` can only be set in **trusted** (user-initiated) drag events.
+Synthetic events via `dispatchEvent()` are untrusted — the setter is a no-op. Never test
+`effectAllowed` via synthetic events. Test `draggable="true"` attribute instead.
+
+**Working pattern** for insertion line test (tests CSS class after dragover):
+
+```typescript
+const firstHandle = await firstItem.elementHandle();
+const secondHandle = await secondItem.elementHandle();
+await page.evaluate(
+ ({ source, target }) => {
+ const dataTransfer = new DataTransfer();
+ source.dispatchEvent(
+ new DragEvent('dragstart', { bubbles: true, cancelable: true, dataTransfer }),
+ );
+ target.dispatchEvent(
+ new DragEvent('dragover', {
+ bubbles: true,
+ cancelable: true,
+ dataTransfer,
+ clientY: target.getBoundingClientRect().top + 1,
+ }),
+ );
+ },
+ { source: firstHandle!, target: secondHandle! },
+);
+await expect(dropAboveItem.or(dropBelowItem).first()).toBeVisible(); // use retry
+```
+
+Use `expect().toBeVisible()` (NOT `.count()`) for React state changes — retry handles async.
+
+## Multiple Preferences GET on Page Load (2026-03-23, PR #1177)
+
+On dashboard load, TWO components independently call `GET /api/users/me/preferences`:
+
+1. `LocaleContext` — fetches for locale preference
+2. `usePreferences` hook — fetches all preferences (incl. `dashboard.hiddenCards`)
+
+A single `page.waitForResponse()` captures only the FIRST GET. If asserting on `hiddenCards`,
+you need React to process BOTH responses. Use `expect().toHaveCount(0)` with auto-retry instead
+of `waitForResponse` + immediate assertion.
+
+## waitForSearchParams After search() (2026-03-23, PR #1177)
+
+`WorkItemsPage.search()` was updated to call `waitForSearchParams(query)` AFTER the API
+response. This ensures the URL `?q=` param is updated AND React has committed new data.
+On mobile, `waitForLoaded()` resolves immediately (old cards visible) before re-render.
+The URL update is the reliable indicator that `setSearchParams` (React) has committed.
+
+`waitForSearchParams()` is now public on `WorkItemsPage` for direct use in tests.
diff --git a/.claude/agent-memory/frontend-developer/stylelint-setup.md b/.claude/agent-memory/frontend-developer/stylelint-setup.md
new file mode 100644
index 000000000..c8a102157
--- /dev/null
+++ b/.claude/agent-memory/frontend-developer/stylelint-setup.md
@@ -0,0 +1,58 @@
+---
+name: Stylelint Configuration
+description: Design token enforcement via stylelint - installed and configured
+type: reference
+---
+
+## Stylelint Setup (Commit: 1d9986a)
+
+Stylelint configured in `.stylelintrc.json` (project root) with design token enforcement.
+
+### Versions
+
+- stylelint: 16.13.0
+- stylelint-config-standard: 37.0.0
+- Installed as root workspace devDependencies (shared)
+
+### Key Enforcement Rules
+
+**1. color-no-hex** — Prevents hardcoded hex colors
+
+- Catches: `color: #333`
+- Enforce: Use `var(--color-*)` instead
+- Disabled for: `tokens.css`, `docs/`
+
+**2. function-disallowed-list** — Prevents raw color functions
+
+- Catches: `rgb()`, `rgba()`, `hsl()`, `hsla()`
+- Enforce: Use `var(--color-*)` instead
+- Disabled for: `tokens.css`, `docs/`
+
+**3. declaration-property-value-disallowed-list** — Prevents numeric font-weight/z-index
+
+- Catches: `font-weight: 600` or `z-index: 50`
+- Enforce: Use `var(--font-weight-*)` and `var(--z-index-*)` instead
+- Disabled for: `tokens.css`, `docs/`
+
+### npm Scripts
+
+```bash
+npm run stylelint # Check for violations
+npm run stylelint:fix # Auto-fix (limited rules only)
+npx stylelint path/to/file.css # Test specific file
+```
+
+### Expected Behavior
+
+- **Badge.module.css**: Passes (fully tokenized)
+- **DependencySentenceDisplay.module.css**: 2 violations (numeric font-weights)
+- **print.css**: 5 violations (hardcoded hex colors)
+- **tokens.css**: Passes (hex colors allowed in token definitions)
+- **docs/**: Passes (has separate CSS conventions)
+
+### Integration Notes
+
+- Not yet integrated into CI (violations don't block builds)
+- Ready for manual checks and future CI integration
+- When migrating existing code, run `npm run stylelint` to identify violations
+- Use `var()` references from `client/src/styles/tokens.css` for all values
diff --git a/.claude/agent-memory/product-architect/MEMORY.md b/.claude/agent-memory/product-architect/MEMORY.md
index c2fc1e93a..198c34ec6 100644
--- a/.claude/agent-memory/product-architect/MEMORY.md
+++ b/.claude/agent-memory/product-architect/MEMORY.md
@@ -16,15 +16,14 @@
- All API endpoints under `/api/` prefix, error shape: `{ error: { code, message, details? } }`
- Offset pagination: page (1-indexed), pageSize (default 25, max 100)
-- Tags/users NOT paginated (small collections)
-- PATCH with tagIds replaces entire tag set (set-semantics)
+- Areas/trades/users NOT paginated (small collections)
- Junction tables use composite PKs (no surrogate id) -- EXCEPT invoice_budget_lines which uses surrogate UUID (carries itemized_amount, needs individual CRUD)
## Naming Conventions
- DB: snake_case | TS vars: camelCase | TS types: PascalCase | Files: camelCase.ts (React: PascalCase.tsx) | API: kebab-case | Env: UPPER_SNAKE_CASE
-## Migrations (17 total)
+## Migrations (18 total)
- 0001-0009: Auth, work items, budget, milestones, deps, actual dates, document_links
- 0010: household_items + 5 supporting tables (EPIC-04)
@@ -32,8 +31,9 @@
- 0012: household_item_deps + delivery date columns
- 0013-0016: HI dep cleanup, status rename, delivery date redesign, HI categories
- 0017: invoice_budget_lines junction table (EPIC-15, ADR-018)
+- 0018: areas + trades, vendor trade_id, WI area_id + assigned_vendor_id, HI area_id, drop tags (EPIC-18, ADR-028)
-## ADRs (ADR-001 through ADR-018)
+## ADRs (ADR-001 through ADR-028)
- ADR-001-009: Tech stack + error handling
- ADR-010: Auth (sessions + OIDC + scrypt)
@@ -44,6 +44,7 @@
- ADR-015: Paperless-ngx integration (proxy + polymorphic links)
- ADR-016: Household items (separate entity with parallel structure)
- ADR-018: Invoice-budget-line junction table (M:N with XOR CHECK, ON DELETE CASCADE)
+- ADR-028: Areas & Trades (structured dimensions replacing tags)
## EPIC Status
@@ -53,12 +54,13 @@
- EPIC-06 Timeline/Gantt: Complete (promoted to main, v1.10.0)
- EPIC-08 Documents: Complete (promoted to main, v1.11.0)
- EPIC-04 Household Items: Complete (promoted to main, v1.12.0)
-- EPIC-15 Budget-Line Invoice Linking: In progress. Story 15.1 schema (PR #612, request changes)
+- EPIC-15 Budget-Line Invoice Linking: Complete (promoted to main, v1.14.0)
+- EPIC-18 Areas & Trades: In progress (ADR-028, Schema, API Contract designed)
## GitHub Wiki
- Wiki is git submodule at `wiki/`. Sync: `git submodule update --init wiki && git -C wiki pull origin master`
-- ADR-001 through ADR-016, Architecture, Schema, API-Contract, Home, ADR-Index, Style-Guide, Security-Audit
+- ADR-001 through ADR-028, Architecture, Schema, API-Contract, Home, ADR-Index, Style-Guide, Security-Audit
- **Always push wiki before creating PR** -- submodule ref must be committed in feature branch
### Wiki Update Discipline (CRITICAL)
@@ -116,6 +118,7 @@ See `epic04-household-items.md` for full details.
- `epic03-refinement.md` -- 40 consolidated refinement items from EPIC-03
- `epic05-budget.md` -- EPIC-05 budget management details
- `epic04-household-items.md` -- EPIC-04 household items architecture
+- `epic18-areas-trades.md` -- EPIC-18 areas & trades structured dimensions
## EPIC-04 Review Summary (see story-reviews.md for details)
@@ -150,3 +153,14 @@ Invoice-budget-line junction table (migration 0017). Request changes:
### Key Lesson: XOR CHECK + ON DELETE SET NULL Incompatibility (Bug #611)
SQLite enforces CHECK constraints during FK SET NULL actions. If a table has `CHECK((a IS NOT NULL AND b IS NULL) OR ...)` and `ON DELETE SET NULL` on column `a`, deleting the referenced row triggers SET NULL which violates the XOR CHECK. Fix: use ON DELETE CASCADE instead.
+
+## PR #1150 Review (2026-03-22) -- EPIC-19 Backup & Restore
+
+Request changes (2 critical, 3 high, 2 medium):
+
+- CRITICAL: Wiki not updated (API-Contract.md, Architecture.md) -- zero wiki changes in PR
+- CRITICAL: CLAUDE.md env var table missing BACKUP_DIR, BACKUP_CADENCE, BACKUP_RETENTION
+- HIGH: stopScheduler() never called on app close -- needs onClose hook
+- HIGH: Module-level mutable state (operationInProgress, cronTask) -- testing concern
+- MEDIUM: process.exit(0) bypasses Fastify graceful shutdown
+- MEDIUM: createError state set but never rendered in UI (bug #1164)
diff --git a/.claude/agent-memory/product-architect/epic17-i18n.md b/.claude/agent-memory/product-architect/epic17-i18n.md
new file mode 100644
index 000000000..58f6d737f
--- /dev/null
+++ b/.claude/agent-memory/product-architect/epic17-i18n.md
@@ -0,0 +1,22 @@
+---
+name: EPIC-17 i18n Architecture
+description: Internationalization architecture decisions for EPIC-17 (English + German, currency env var, react-i18next)
+type: project
+---
+
+EPIC-17 adds i18n support with English and German. ADR-021.
+
+**Why:** Cornerstone targets German homeowners; UI must be available in German with locale-aware formatting.
+
+**How to apply:**
+
+- Library: react-i18next (pure JS, no native binaries)
+- Translations: statically bundled in `client/src/i18n/{en,de}/*.json` (11 namespaces per language)
+- Locale stored as user preference (key: `locale`, values: `en|de|system`) -- no schema changes
+- LocaleContext mirrors ThemeContext pattern (localStorage + server sync)
+- CURRENCY env var (default EUR) exposed via `GET /api/config` (public, no auth)
+- Formatters gain locale/currency params; `useFormatters()` hook wraps them
+- Error translation: client-side from ErrorCode using `errors` namespace
+- ADR-021 documents full design including namespace structure, key conventions, detection order
+- API Contract updated: GET /api/config + added to unprotected routes
+- Architecture.md updated: i18n section + CURRENCY in Core env vars table
diff --git a/.claude/agent-memory/product-architect/epic18-areas-trades.md b/.claude/agent-memory/product-architect/epic18-areas-trades.md
new file mode 100644
index 000000000..f68a077d2
--- /dev/null
+++ b/.claude/agent-memory/product-architect/epic18-areas-trades.md
@@ -0,0 +1,47 @@
+---
+name: EPIC-18 Areas & Trades Architecture
+description: Schema, API contract, and ADR details for EPIC-18 structured dimensions replacing tags
+type: project
+---
+
+## EPIC-18: Area & Trade Structured Dimensions
+
+**ADR-028** (Accepted) — replaces generic tags with Areas (hierarchical) and Trades (flat).
+
+### Schema Changes (Migration 0018)
+
+- NEW: `areas` table (self-ref parent_id, ON DELETE CASCADE, UNIQUE(name, parent_id))
+- NEW: `trades` table (UNIQUE name, 15 seeded defaults)
+- MOD: `vendors` — add `trade_id` FK (ON DELETE SET NULL), ignore `specialty` (not dropped due to SQLite ALTER limitations)
+- MOD: `work_items` — add `area_id` (ON DELETE SET NULL), add `assigned_vendor_id` (ON DELETE SET NULL), CHECK(assigned_user_id IS NULL OR assigned_vendor_id IS NULL) enforced at app layer
+- MOD: `household_items` — add `area_id` (ON DELETE SET NULL), ignore `room` (not dropped)
+- DROP: `tags`, `work_item_tags`, `household_item_tags`
+- Budget category defaults: ADD bc-waste, conditionally DELETE bc-equipment/landscaping/utilities/insurance/contingency
+- HI category defaults: ADD hic-equipment, conditionally DELETE hic-outdoor/hic-storage
+
+### API Changes
+
+- NEW: `/api/areas` CRUD (no pagination, search filter, tree built client-side)
+- NEW: `/api/trades` CRUD (no pagination, search filter)
+- REMOVED: All `/api/tags/*`
+- MOD: Work items — `tags[]` → `area: {id, name, color} | null`, added `assignedVendor`, removed `tagIds`
+- MOD: Household items — `tags[]` → `area`, removed `room`, removed `tagIds`
+- MOD: Vendors — `specialty` → `trade: {id, name, color} | null`, added `tradeId` filter
+- MOD: Timeline — work items include `area` and `assignedVendor` instead of `tags`
+- MOD: CalDAV feeds — vendor TITLE uses trade name instead of specialty
+- New error codes: `AREA_IN_USE`, `TRADE_IN_USE`
+
+### Key Design Decisions
+
+- D1: Area hierarchy via self-referencing parent_id (arbitrary depth)
+- D2: Area cardinality M:1 (one area per item, nullable)
+- D3: Trade transitivity via JOINs (work_item -> vendor -> trade)
+- D4: Assignment mutually exclusive (user XOR vendor) via CHECK
+- D5: Room field dropped without data migration
+- D6: Specialty → Trade migration not automated (users reassign manually)
+- D7: Category default changes conditional (only delete unused)
+
+### Stories
+
+#1030 Migration + Shared Types, #1031 Areas Backend, #1032 Trades Backend + Vendor Update,
+#1033 Work Item Rework, #1034 HI Rework, #1035 Frontend Manage Page, #1037 Frontend Entity Integration
diff --git a/.claude/agent-memory/product-owner/epic-17-planning.md b/.claude/agent-memory/product-owner/epic-17-planning.md
new file mode 100644
index 000000000..006478e6d
--- /dev/null
+++ b/.claude/agent-memory/product-owner/epic-17-planning.md
@@ -0,0 +1,59 @@
+---
+name: EPIC-17 i18n Planning
+description: Planning details for EPIC-17 i18n Support (English + German) — story numbers, dependencies, and key decisions
+type: project
+---
+
+## EPIC-17: i18n Support (English + German)
+
+**Epic Issue**: #915
+**Priority**: Should Have
+**Created**: 2026-03-16
+
+### Stories
+
+| Story | Issue | Title | Priority | Blocked By |
+| ----- | ----- | ---------------------------------------------- | ----------- | --------------- |
+| 17.1 | #916 | i18n Infrastructure & Shared Components | Must Have | — |
+| 17.2 | #917 | Auth & Settings Pages (incl. Language Setting) | Must Have | #916 |
+| 17.3 | #918 | Dashboard & Work Items Pages | Must Have | #916 |
+| 17.4 | #919 | Household Items & Milestones Pages | Must Have | #916 |
+| 17.5 | #920 | Budget Pages | Must Have | #916 |
+| 17.6 | #921 | Schedule & Diary Pages | Must Have | #916 |
+| 17.7 | #922 | Documents & Remaining Components | Must Have | #916 |
+| 17.8 | #923 | Docs Site German Translation | Should Have | — (independent) |
+| 17.9 | #924 | E2E Test Updates & Final Validation | Must Have | #916-#922 |
+
+### Dependency Graph
+
+- Story 17.1 (#916) is the foundation — blocks Stories 17.2-17.7
+- Story 17.8 (#923) is independent (Docusaurus has its own i18n system)
+- Story 17.9 (#924) is blocked by all app stories (17.1-17.7)
+
+### Key Design Decisions
+
+- **i18next + react-i18next**: Static JSON imports, no async loading
+- **LocaleContext**: Mirrors ThemeContext pattern (localStorage + server sync + system detection)
+- **Namespace per domain**: common, errors, auth, settings, dashboard, workItems, householdItems, budget, schedule, diary, documents
+- **CURRENCY env var**: Server exposes via GET /api/config, formatCurrency() reads from it, default EUR
+- **Formatters**: Read locale from i18n.language, no call signature changes
+- **Enum display**: API enum values stay English, only display labels translated
+- **Badge variant maps**: Must be refactored to use t() (move inside component or factory pattern)
+- **Docs site**: Docusaurus built-in i18n, separate from app i18n
+
+### Board Status
+
+All stories set to **Todo** on the Cornerstone Backlog board.
+
+### Node IDs
+
+- EPIC-17: `I_kwDORK8WYc7zmbsG`
+- Story 17.1: `I_kwDORK8WYc7zmcTo`
+- Story 17.2: `I_kwDORK8WYc7zmcjW`
+- Story 17.3: `I_kwDORK8WYc7zmcw4`
+- Story 17.4: `I_kwDORK8WYc7zmc9T`
+- Story 17.5: `I_kwDORK8WYc7zmdNJ`
+- Story 17.6: `I_kwDORK8WYc7zmdcu`
+- Story 17.7: `I_kwDORK8WYc7zmdqc`
+- Story 17.8: `I_kwDORK8WYc7zmd4y`
+- Story 17.9: `I_kwDORK8WYc7zmeHS`
diff --git a/.claude/agent-memory/qa-integration-tester/MEMORY.md b/.claude/agent-memory/qa-integration-tester/MEMORY.md
index d4c63ad61..8aa5830ae 100644
--- a/.claude/agent-memory/qa-integration-tester/MEMORY.md
+++ b/.claude/agent-memory/qa-integration-tester/MEMORY.md
@@ -3,6 +3,38 @@
> Detailed notes live in topic files. This index links to them.
> See: `budget-categories-story-142.md`, `e2e-pom-patterns.md`, `e2e-parallel-isolation.md`, `story-358-document-linking.md`, `story-360-document-a11y.md`, `story-epic08-e2e.md`, `story-509-manage-page.md`, `story-471-dashboard.md`
+## Story #1146 Backup/Restore Tests (2026-03-22)
+
+**Test files**: `backupService.test.ts`, `backups.test.ts` (routes), `backupsApi.test.ts`, `BackupsPage.test.tsx`.
+
+**Key patterns**:
+
+- **BACKUP_DIR must be outside app data dir**: config.ts validates `BACKUP_DIR` is not the same as or a subdirectory of `dirname(DATABASE_URL)`. In route tests: use TWO separate `mkdtempSync` calls — one for the DB (`tempDir`) and one for backups (`backupTempDir`). Using `join(tempDir, 'backups')` as `BACKUP_DIR` fails with config validation error.
+- **AppError has `.code` property, not the code in `.message`**: `BackupNotFoundError.message = 'Backup not found: filename'`, `BackupNotFoundError.code = 'BACKUP_NOT_FOUND'`. Use `rejects.toMatchObject({ code: 'BACKUP_NOT_FOUND' })`, NOT `stringContaining('BACKUP_NOT_FOUND')` on `.message`.
+- **`createError` state in BackupsPage not rendered**: `createError` state is set in `handleCreateBackup()` catch block but never displayed in JSX. Bug filed as #1164. Test for re-enabled button state instead of `role="alert"`.
+- **Delete modal opens showing filename in two places**: after clicking Delete, filename appears in both table `
` and modal ``. Use `getAllByText(filename)` not `getByText`.
+- **Config snapshot tests break on new AppConfig fields**: The 4 `toEqual` snapshot tests in `config.test.ts` fail whenever new fields are added to `AppConfig`. Backup feature added `backupDir/backupCadence/backupRetention/backupEnabled`. Add all 4 to each of the 4 full snapshot tests.
+- **German translations left uncommitted**: translator agent generated German translations but they were uncommitted in working tree, causing `errorTranslation.test.ts` failures (empty string translations). Commit them with translator co-author trailer.
+
+## useSearchParams + Debounce Testing Anti-Patterns (2026-03-21)
+
+**Context**: Testing `useTableState` hook — combines `useSearchParams` (MemoryRouter) with debounced state updates.
+
+**Key patterns — what FAILS:**
+
+- `jest.useFakeTimers()` + `waitFor()` — `waitFor` uses real `setInterval` for polling; blocked by fake timers
+- `jest.useFakeTimers()` + `await act(async () => { await jest.runAllTimersAsync(); })` — still fails because the URL sync `useEffect` in `useTableState` has `searchInput` in its dep array; it fires after `setSearch()` and reads stale `searchParams` (no `q` yet), resetting `searchInput` back to `''`
+- Testing `searchInput` immediately after `setSearch()` — the URL sync effect fires synchronously and overwrites it
+
+**Root cause**: `useTableState`'s URL sync effect (`useEffect(fn, [searchParams, ..., searchInput])`) runs whenever `searchInput` changes. After `setSearch('hello')`, `searchInput='hello'` triggers the effect, which reads `searchParams.get('q')=''` and calls `setSearchInput('')`, undoing the update before the debounce timer fires.
+
+**What WORKS:**
+
+- Test URL-initialized state: `makeWrapper(['/?q=hello'])` → assert `tableState.search === 'hello'`
+- Test the "not yet fired" side: `jest.useFakeTimers()` + `advanceTimersByTime(299)` + assert `tableState.search === ''`
+- Test URL params combos: `makeWrapper(['/?q=myquery&page=3'])` — reliable synchronous assertions
+- `user.type()` on controlled inputs without state propagation → use `fireEvent.change(input, { target: { value: 'hello' } })` instead
+
## Story #1035 ManagePage Rewrite — Areas + Trades Tabs (2026-03-19)
**File rewritten**: `client/src/pages/ManagePage/ManagePage.test.tsx`
diff --git a/.claude/agent-memory/qa-integration-tester/drag-drop-jsdom-patterns.md b/.claude/agent-memory/qa-integration-tester/drag-drop-jsdom-patterns.md
new file mode 100644
index 000000000..b1d34177b
--- /dev/null
+++ b/.claude/agent-memory/qa-integration-tester/drag-drop-jsdom-patterns.md
@@ -0,0 +1,29 @@
+---
+name: drag-drop-jsdom-patterns
+description: Patterns and anti-patterns for testing drag-and-drop logic in React components under jsdom
+type: feedback
+---
+
+## getBoundingClientRect in Drag-Over Handlers
+
+**Rule**: In jsdom, `getBoundingClientRect()` always returns all zeros. Mocking `Element.prototype.getBoundingClientRect` or `HTMLElement.prototype.getBoundingClientRect` does NOT intercept calls made through React's delegated event system (React 19 uses event delegation from the root, and `e.currentTarget` in the handler does not resolve through the normal prototype chain lookup — it bypasses both `Element.prototype` and `HTMLElement.prototype` mocks).
+
+**Why**: The call in `(e.currentTarget as HTMLElement).getBoundingClientRect()` during a React 19 synthetic event handler always returns zeros regardless of prototype mocks. Neither `jest.spyOn(element, 'getBoundingClientRect')` (instance mock) nor `Element.prototype.getBoundingClientRect = ...` (prototype mock) are intercepted.
+
+**How to apply**:
+
+- Do NOT write tests that assert "above vs below" drop position based on `clientY` relative to `getBoundingClientRect()` output — they will always resolve to "below" (since `clientY > 0 >= 0 + 0/2`).
+- Instead, assert that SOME drop indicator class is applied (use `.toMatch(/columnCheckboxItemDrop(Above|Below)/)`).
+- For the "below" direction specifically: any `clientY > 0` will always produce "below" — this IS testable.
+- For the "above" direction: not testable in jsdom unit tests. Cover via E2E tests instead.
+- For dragLeave clearing: test that both `Above` and `Below` classes are absent after `fireEvent.dragLeave(element)` — this IS reliable.
+
+## Auto-focus with setTimeout in jsdom
+
+**Rule**: `jest.useFakeTimers()` + `act(() => { jest.runAllTimers(); })` works for testing `setTimeout(() => element.focus(), 0)` in React event handlers.
+
+**How to apply**:
+
+- Use synchronous `act(() => { jest.runAllTimers(); })` (not async) — `runAllTimers` inside a synchronous `act` correctly flushes the timer and React state updates.
+- Always restore real timers with `jest.useRealTimers()` in cleanup (or in the test body after assertions).
+- `document.activeElement` correctly reflects `element.focus()` called inside a flushed `setTimeout`.
diff --git a/.claude/agent-memory/qa-integration-tester/story-1143-translation-keys.md b/.claude/agent-memory/qa-integration-tester/story-1143-translation-keys.md
new file mode 100644
index 000000000..e4b00b29e
--- /dev/null
+++ b/.claude/agent-memory/qa-integration-tester/story-1143-translation-keys.md
@@ -0,0 +1,35 @@
+---
+name: Story #1143 — translationKey field testing patterns
+description: Patterns for testing the translationKey field added to trades, budgetCategories, householdItemCategories
+type: project
+---
+
+## Story #1143 translationKey Testing (2026-03-22)
+
+**Migration 0030**: Adds nullable `translation_key` column to `trades`, `budget_categories`, `household_item_categories`. Predefined rows get keys (e.g. `trades.plumbing`); user-created rows always get `null`.
+
+**Test files created/modified**:
+
+- `client/src/lib/categoryUtils.test.ts` (NEW) — unit tests for `getCategoryDisplayName` + i18n key coverage
+- `server/src/services/tradeService.test.ts` — added `translationKey field` describe block
+- `server/src/services/budgetCategoryService.test.ts` — added `translationKey field` describe block
+- `server/src/services/householdItemCategoryService.test.ts` — added `translationKey field` describe block
+- `server/src/routes/trades.test.ts` — added route-level `translationKey` tests
+- `server/src/routes/budgetCategories.test.ts` — added route-level `translationKey` tests
+- `server/src/routes/householdItemCategories.test.ts` — added route-level `translationKey` tests
+
+**Key patterns**:
+
+- `getCategoryDisplayName(t, name, translationKey)`: when key is non-null, calls `t(key, { defaultValue: name })`; when null/empty string (falsy), returns name without calling t()
+- Empty string `translationKey` is falsy in JS — treated same as null in the implementation
+- Seeded trades table is cleared in `beforeEach` in route tests (`app.db.delete(trades).run()`). To test predefined translationKey in route tests, re-insert the row manually with the `translationKey` set.
+- Service tests for seeded categories (budget/HI): these tables are NOT cleared in beforeEach, so predefined rows are available directly after `runMigrations()`.
+- `createTrade()`, `createBudgetCategory()`, `createHouseholdItemCategory()` always write `translationKey: null` — verified by unit test.
+- i18n coverage tests: import JSON directly (`import en from '../i18n/en/settings.json'`), no assert { type: 'json' } needed (resolveJsonModule: true in client tsconfig).
+- Key parity test pattern: `expect(Object.keys(de.section).sort()).toEqual(Object.keys(en.section).sort())`
+
+**Seeded predefined IDs verified**:
+
+- Trades (15): trade-plumbing, trade-hvac, trade-electrical, trade-drywall, trade-carpentry, trade-masonry, trade-painting, trade-roofing, trade-flooring, trade-tiling, trade-landscaping, trade-excavation, trade-general-contractor, trade-architect-design, trade-other
+- BudgetCategories (7 surviving on fresh DB): bc-materials, bc-labor, bc-permits, bc-design, bc-household-items, bc-waste, bc-other
+- HI Categories (7 surviving on fresh DB): hic-furniture, hic-appliances, hic-fixtures, hic-decor, hic-electronics, hic-equipment, hic-other
diff --git a/.claude/agent-memory/security-engineer/MEMORY.md b/.claude/agent-memory/security-engineer/MEMORY.md
index 33ca93c36..d12bd172a 100644
--- a/.claude/agent-memory/security-engineer/MEMORY.md
+++ b/.claude/agent-memory/security-engineer/MEMORY.md
@@ -91,6 +91,7 @@ See `review-history.md` for detailed findings per PR.
| #936 | #933 CalDAV/CardDAV server with DAV token auth and vendor contacts | COMMENTED (1 low: WWW-Authenticate; 3 informational) | 2026-03-17 |
| #1047 | EPIC-18 Story #1030 — Migration + Shared Types (Areas & Trades foundation) | COMMENTED (2 informational: XOR CHECK deferred, UNIQUE(name,parent_id) NULL gap) | 2026-03-19 |
| #1054 | EPIC-18 Stories #1031+#1032 — Areas CRUD, Trades CRUD, Vendor trade linking | COMMENTED (3 informational: search no maxLength, parentId no minLength:1, sql.raw safe) | 2026-03-19 |
+| #1150 | EPIC-19 Story #1146 — Backup & Restore | COMMENTED (3 informational: restore mid-op orphan dir, filename in 404 msg, node-cron 3.x not 4.x) | 2026-03-22 |
## Known Open Recommendations (Low Priority)
@@ -163,3 +164,5 @@ These have been noted in previous reviews. **GitHub Issue #315** tracks items 1-
- **EPIC-18 tags fully removed (PR #1047)**: Migration 0028 drops `tags`, `work_item_tags`, `household_item_tags` tables atomically. `toTagResponse`/`validateTagIds` removed from shared converters/validators. Tag route removed from app.ts. Attack surface reduced.
- **EPIC-18 specialty→trade migration (PR #1047)**: Deduplicates via `SELECT DISTINCT trim(specialty)`, creates custom trades with `randomblob(4)` IDs. Falls back to `trade-other` for NULL/empty specialty values. Vendors table recreated (pattern from migration 0026).
- **EPIC-18 room column dropped (PR #1047)**: `ALTER TABLE household_items DROP COLUMN room` — no data migration (D5 intentional). room index dropped first. New `area_id` FK column added with ON DELETE SET NULL.
+- **EPIC-19 backup filename validation (PR #1150)**: `validateBackupFilename()` enforces strict regex `^cornerstone-backup-\d{4}-\d{2}-\d{2}T\d{6}Z\.tar\.gz$` plus explicit `/` and `\` rejection. `execFile` (not `exec`) used for tar — no shell injection. BACKUP_DIR isolation enforced at startup via `startsWith(resolvedDataDir + path.sep)` in config.ts. `node-cron` 3.0.3 — operator-controlled cron expression, no user input reaches it. `operationInProgress` singleton guard prevents concurrent operations. `process.exit(0)` on restore is deliberate Docker restart pattern.
+- **Wiki virtiofs workaround**: Token URL for push is in main repo at `.git/worktrees//modules/wiki/config` (not `wiki/.git/config` — that's a pointer file). Use `git clone .git/worktrees//modules/wiki /tmp/wiki-tmp` then set identity + remote URL there.
diff --git a/.claude/agent-memory/translator/MEMORY.md b/.claude/agent-memory/translator/MEMORY.md
index 95cde51ac..118246479 100644
--- a/.claude/agent-memory/translator/MEMORY.md
+++ b/.claude/agent-memory/translator/MEMORY.md
@@ -40,8 +40,19 @@ Action labels in German follow the pattern: `{Noun} {Verb}` with capitalised fir
- `de/budget.json` was missing `overview.actions` entirely at initial rollout — added 2026-03-19
- `de/common.json` was missing `aria.noArea`, `aria.noTrade`, `aria.selectArea`, `aria.selectTrade`, `aria.selectAssignment`, `aria.unassigned`, and `assignmentPicker.*` — added 2026-03-19 (Story #1035)
- `de/settings.json` had `manage.tags` which was replaced by `manage.areas` + `manage.trades` in Story #1035
+- `de/common.json` was missing `subnav.settings.backups` — added 2026-03-22 (Issue #1146)
+- `de/settings.json` was missing `backups` section entirely — added 2026-03-22 (Issue #1146)
+- `de/errors.json` had four backup/restore keys with empty placeholder values (left by frontend-developer) — filled in 2026-03-22 (Issue #1146)
- Always check key parity when picking up a new translator spec
+## Backup/Restore Terminology (2026-03-22)
+
+- "Backup" → "Sicherung" (noun, e.g. "Sicherung erstellen", "Sicherungen")
+- "Restore" / "Restore operation" → "Wiederherstellung" / "Wiederherstellungsoperation"
+- "Backup & Restore" (page title) → "Sicherung & Wiederherstellung"
+- "Restore & Restart" (button) → "Wiederherstellen & Neu starten"
+- Frontend-developer may leave empty placeholder values in error keys when adding new error codes — the translator must fill these in.
+
## Initial Cleanup (2026-03-17)
Fixed terminology inconsistencies from EPIC-17 i18n rollout:
diff --git a/.claude/agent-memory/ux-designer/MEMORY.md b/.claude/agent-memory/ux-designer/MEMORY.md
index 9471c91a9..79ae97225 100644
--- a/.claude/agent-memory/ux-designer/MEMORY.md
+++ b/.claude/agent-memory/ux-designer/MEMORY.md
@@ -197,18 +197,24 @@ HI Gantt: amber circle marker (r=7px). Add Dep modal: 36rem wide.
`role="listbox"` requires arrow-key nav — use `role="list"` + `role="button"` items instead.
`--color-primary-text` on `--color-primary-bg` chip = contrast failure; use `var(--color-primary)` for text.
-## PR #936 Review Findings — DAV Access Card + Vendor Contacts
-
-Recurring misses to watch for in settings-card and sub-entity-list PRs:
-
-- Both CSS modules used hardcoded literals for ALL spacing/font/radius/transition — zero token usage
-- `--color-danger-active` misused as text color; correct token is `--color-danger-text-on-light` or `--color-danger`
-- `--color-border` as hover bg (recurring from PR #398) — always use `var(--color-bg-hover)`
-- Edit/Delete action buttons missing `aria-label` with entity name ("Edit contact {name}") — screen reader blocker
-- Missing `aria-live` announcement region for CRUD mutations
-- Missing `prefers-reduced-motion` guard in both CSS modules
-- Token reveal panel missing `role="status" aria-atomic="true"`
-- `copyButton` (primary surface) used `--shadow-focus-subtle` — primary buttons need `--shadow-focus`
+## DataTable Core (Issue #1099)
+
+See `datatable-spec.md` for full token map. Key decisions:
+
+- FilterPopover uses `position: fixed` + `getBoundingClientRect()` — avoids `overflow-x: auto` clipping
+- Sort indicator: none=`var(--color-text-placeholder)`, active=`var(--color-primary)`; `aria-sort` on ``
+- Active filter icon: `color: var(--color-primary)` + `background: var(--color-primary-bg)` (no new tokens)
+- Pagination min touch target: `min-width: 44px; min-height: 44px`
+- Mobile (< 768px): cards layout, no column visibility toggle, simplified pagination (Prev/Next only)
+- All strings under `common:dataTable.*` namespace
+- No new design tokens needed
+
+## DataTable Bug Fix Specs (#1135–#1140)
+
+- Date range (#1135): `.filterDateInputConfirmed` = `border: var(--color-primary); bg: var(--color-primary-bg)`; range bridge pill uses `--color-primary-bg` + `--color-primary-badge-text`; auto-focus advance to "to" input
+- Toolbar height (#1136): three controls (search, reset, col-settings) all set to `height: 36px; box-sizing: border-box`; column icon = 3-bar vertical SVG (16×16), NOT gear emoji; `min-height: 44px` only on mobile
+- Number filter (#1139): existing `NumberFilter.tsx` visual is correct; fix is behavioral (`numberMin/Max/Step` props); compare mode uses existing `.filterSegmentedControl` classes
+- Drag indicator (#1140): replace full-row `--color-primary` bg with `::before` pseudo-element insertion line (2px, `--color-primary`, `--radius-full`); add `e.dataTransfer.effectAllowed = 'move'` on dragstart; drag handle needs `tabIndex={0}` + arrow-key keyboard reorder for a11y
## Story 4.11 — HI Detail Inline Edit (Issue #467)
diff --git a/.claude/agent-memory/ux-designer/datatable-spec.md b/.claude/agent-memory/ux-designer/datatable-spec.md
new file mode 100644
index 000000000..bf5ae663e
--- /dev/null
+++ b/.claude/agent-memory/ux-designer/datatable-spec.md
@@ -0,0 +1,79 @@
+---
+name: DataTable Core Spec (Issue #1099)
+description: Visual spec decisions for the shared DataTable component and all sub-components
+type: project
+---
+
+# DataTable Core — Visual Spec Summary
+
+Spec posted at: https://github.com/steilerDev/cornerstone/issues/1099#issuecomment-4101514019
+
+## Key Design Decisions
+
+### Table Container
+
+- `background: var(--color-bg-primary); border: 1px solid var(--color-border); border-radius: var(--radius-lg); box-shadow: var(--shadow-md); overflow-x: auto`
+- `overflow-x: auto` (not hidden) — wide tables scroll, not clip
+
+### Table Header (thead)
+
+- `background-color: var(--color-bg-secondary); border-bottom: 1px solid var(--color-border)`
+- `th`: `padding: var(--spacing-3) var(--spacing-4)`, `font-size: var(--font-size-xs)`, `font-weight: var(--font-weight-semibold)`, `color: var(--color-text-muted)`, uppercase, `letter-spacing: 0.05em`
+- Sortable headers: `` inside ``, flex row (label + icon), hover = `var(--color-primary-hover)`, active (sorted) = `var(--color-text-primary)`
+- Sort icons: 16px SVG, none=`var(--color-text-placeholder)`, asc/desc=`var(--color-primary)`, `aria-hidden="true"`, `aria-sort` on ` `
+- Filter icon button: 20×20px, `color: var(--color-text-placeholder)` default, active filter = `color: var(--color-primary)` + `background: var(--color-primary-bg)`
+
+### Table Rows
+
+- `td`: `padding: var(--spacing-4)`, `font-size: var(--font-size-sm)`, `color: var(--color-text-secondary)`
+- Title cell: `font-weight: var(--font-weight-medium)`, `color: var(--color-text-primary)`
+- Hover: `background-color: var(--color-bg-secondary)`; selected: `var(--color-primary-bg)`
+- Focus-visible: `inset box-shadow: 0 0 0 2px var(--color-primary)` (inset avoids layout shift)
+
+### Popovers (ColumnSettings + FilterPopover)
+
+- `background: var(--color-bg-primary); border: 1px solid var(--color-border-strong); border-radius: var(--radius-lg); box-shadow: var(--shadow-lg); z-index: var(--z-dropdown)`
+- ColumnSettings: `position: absolute; right: 0; min-width: 200px`
+- FilterPopover: `position: fixed` (with JS-computed coords from `getBoundingClientRect()`) to avoid `overflow-x: auto` clipping
+- Entrance animation: `popoverEnter` (opacity 0→1, translateY -4px→0, `var(--transition-normal)`), disabled under `prefers-reduced-motion`
+
+### Pagination
+
+- Pattern matches HouseholdItemsPage exactly: `border-top: 1px solid var(--color-border); padding-top: var(--spacing-4); margin-top: var(--spacing-6)`
+- Button: `min-width: 44px; min-height: 44px` (WCAG 2.5.5 touch target)
+- Active page: `background: var(--color-primary-hover); color: var(--color-primary-text)`
+- Mobile (< 768px): Previous/Next only + "Page N of M" text; page numbers hidden
+
+### Mobile Cards (< 768px)
+
+- `tableContainer` hidden, `.cardsContainer` shown
+- Card: `border: 1px solid var(--color-border); border-radius: var(--radius-lg); padding: var(--spacing-4); box-shadow: var(--shadow-md)`, hover = `var(--shadow-lg)`
+- Card header: `border-bottom: 1px solid var(--color-border); margin-bottom: var(--spacing-3)`
+- Card title: `font-size: var(--font-size-base); font-weight: var(--font-weight-semibold)`
+- Card rows: label `var(--color-text-muted); min-width: 80px`, value `var(--color-text-secondary)`
+
+### Filter Components
+
+- All inputs compose from `shared.module.css` `.input`
+- BooleanFilter: segmented control (flex row, shared outer border, dividers), `role="group"`, active = `var(--color-primary-bg)` + `var(--color-primary)`
+- EnumFilter: checkbox list, `accent-color: var(--color-primary)`, max-height 200px scrollable
+- EntityFilter: thin wrapper around existing `SearchPicker` — no custom CSS needed
+
+### Dark Mode
+
+- All tokens flip automatically — no component-level `[data-theme="dark"]` blocks needed
+- Date inputs: `color-scheme: light dark` needed globally for `[data-theme="dark"] input[type="date"]`
+
+### No New Tokens Required
+
+All visual properties map to existing Layer 2 semantic tokens.
+
+## Component Reuse
+
+- `Skeleton`: wrap `pageSize` rows inside `tableContainer`
+- `EmptyState`: for "no data" state; separate custom `.filteredEmptyState` for "no results" state
+- `SearchPicker`: used inside `EntityFilter` unchanged
+
+## i18n Namespace
+
+All strings under `common:dataTable.*` namespace. See full key map in spec comment.
diff --git a/.claude/agent-memory/ux-designer/pr-1158-date-range-picker.md b/.claude/agent-memory/ux-designer/pr-1158-date-range-picker.md
new file mode 100644
index 000000000..2d84f1599
--- /dev/null
+++ b/.claude/agent-memory/ux-designer/pr-1158-date-range-picker.md
@@ -0,0 +1,27 @@
+---
+name: PR #1158 — DateRangePicker design review findings
+description: Key ARIA and token findings from DateRangePicker calendar component review
+type: project
+---
+
+## PR #1158 Review Findings — DateRangePicker (DateFilter)
+
+### High finding
+
+- `role="application"` on outer picker wrapper is wrong. `role="grid"` on the inner grid container is the correct and sufficient composite widget landmark. `role="application"` strips virtual cursor navigation from all sibling elements (nav buttons, phase label) outside the grid.
+- Fix: remove `role="application"` from outer div; optionally route `ariaLabel` prop to the grid's `aria-label` as `ariaLabel ?? t('...')`.
+
+### Medium findings
+
+- `32px` hardcoded → `var(--spacing-8)`, `28px` → `var(--spacing-7)`, `40px` → `var(--spacing-10)`. `36px` and `44px` have no spacing token — keep as literals.
+- Mobile day button touch target at 40px; WCAG 2.5.5 recommends 44px. Constrained by 7-column grid in compact picker.
+
+### Informational
+
+- `aria-pressed` on buttons inside `role="gridcell"` is pragmatic but `aria-selected` on the gridcell is more semantically correct for grid selection.
+- linear-gradient approach for range start/end half-cells (`transparent 50%, var(--color-primary-bg) 50%`) is correct and dark-mode-safe.
+- Component is visually consistent with `MonthGrid.module.css` patterns.
+
+**Why:** `role="application"` is a recurring ARIA misuse pattern — developers apply it to calendar widgets thinking it helps keyboard navigation, but it removes browse mode and harms non-keyboard screen reader users.
+
+**How to apply:** Flag `role="application"` on any component that already has a `role="grid"`, `role="listbox"`, `role="menu"`, or other composite widget role inside it. The composite widget role is sufficient.
diff --git a/.claude/agents/backend-developer.md b/.claude/agents/backend-developer.md
index cbe6d0e28..547f71c7a 100644
--- a/.claude/agents/backend-developer.md
+++ b/.claude/agents/backend-developer.md
@@ -133,7 +133,7 @@ For each piece of work, follow this order:
Before considering any task complete, verify:
- [ ] Pre-commit hook passes (triggers on commit: selective tests, typecheck, build, audit)
-- [ ] CI checks pass after push (use the **CI Gate Polling** pattern from `CLAUDE.md`)
+- [ ] PR is mergeable (no conflicts) and CI checks pass after push (verify mergeability first, then use the **CI Gate Polling** pattern from `CLAUDE.md`)
- [ ] New code is structured for testability (clear interfaces, injectable dependencies)
- [ ] API responses match the contract shapes exactly
- [ ] Error responses use correct HTTP status codes and error shapes from the contract
@@ -175,7 +175,7 @@ Before considering any task complete, verify:
3. Commit with conventional commit message and your Co-Authored-By trailer (the pre-commit hook runs all quality gates automatically — selective lint/format/tests on staged files + full typecheck/build/audit)
4. Push: `git push -u origin `
5. Create a PR targeting `beta`: `gh pr create --base beta --title "..." --body "..."`
-6. Wait for CI using the **CI Gate Polling** pattern from `CLAUDE.md` (beta variant)
+6. **Wait 5 seconds**, then check mergeability: `gh pr view --repo steilerDev/cornerstone --json mergeable -q '.mergeable'`. **Only continue if `MERGEABLE`.** If `CONFLICTING`, rebase onto `beta`, force-push, and re-check. Once confirmed, wait for CI using the **CI Gate Polling** pattern from `CLAUDE.md` (beta variant)
7. **Request review**: After CI passes, the orchestrator launches `product-architect` and `security-engineer` to review the PR. Both must approve before merge.
8. **Address feedback**: If a reviewer requests changes, fix the issues on the same branch and push. The orchestrator will re-request review from the reviewer(s) that requested changes.
9. After merge, clean up: `git checkout beta && git pull && git branch -d `
diff --git a/.claude/agents/dev-team-lead.md b/.claude/agents/dev-team-lead.md
index a62d84bfc..8664b8a34 100644
--- a/.claude/agents/dev-team-lead.md
+++ b/.claude/agents/dev-team-lead.md
@@ -373,7 +373,7 @@ For multi-item batches, include per-item summary bullets and one `Fixes #N` line
### CI Monitoring
-Watch CI checks after pushing using the **CI Gate Polling** pattern from `CLAUDE.md` (use the beta or main variant based on the PR's target branch).
+After pushing, **wait 5 seconds** for GitHub to compute merge status, then check mergeability: `gh pr view --repo steilerDev/cornerstone --json mergeable -q '.mergeable'`. **Only continue if the result is `MERGEABLE`.** If `CONFLICTING`, rebase onto the target branch, force-push, and re-check. If `UNKNOWN`, wait a few more seconds and retry. Once mergeability is confirmed, watch CI checks using the **CI Gate Polling** pattern from `CLAUDE.md` (use the beta or main variant based on the PR's target branch).
If CI fails:
@@ -437,7 +437,7 @@ In `[MODE: commit]`:
2. Stage specific files and commit with conventional message + all contributing agent trailers
3. Push: `git push -u origin `
4. Create PR targeting `beta` (if not already created)
-5. Watch CI using the **CI Gate Polling** pattern from `CLAUDE.md` (beta variant)
+5. **Wait 5 seconds**, then check mergeability: `gh pr view --repo steilerDev/cornerstone --json mergeable -q '.mergeable'`. **Only continue if `MERGEABLE`.** If `CONFLICTING`, rebase onto `beta`, force-push, and re-check. Once confirmed, watch CI using the **CI Gate Polling** pattern from `CLAUDE.md` (beta variant)
6. If CI fails, return a fix spec (do NOT fix directly)
7. Return PR URL with CI status to orchestrator
diff --git a/.claude/agents/e2e-test-engineer.md b/.claude/agents/e2e-test-engineer.md
index 19baeeb3a..5c83f3fd7 100644
--- a/.claude/agents/e2e-test-engineer.md
+++ b/.claude/agents/e2e-test-engineer.md
@@ -280,7 +280,7 @@ If you discover something that requires a fix, write a bug report. If you need c
## E2E Smoke Tests
-E2E smoke tests run automatically in CI (see `e2e-smoke` job in `.github/workflows/ci.yml`) — **do not run them locally**. After pushing your branch and creating a PR, wait for CI using the **CI Gate Polling** pattern from `CLAUDE.md` (beta variant). If CI E2E smoke tests fail, investigate and fix before proceeding.
+E2E smoke tests run automatically in CI (see `e2e-smoke` job in `.github/workflows/ci.yml`) — **do not run them locally**. After pushing your branch and creating a PR, **wait 5 seconds**, then check mergeability: `gh pr view --repo steilerDev/cornerstone --json mergeable -q '.mergeable'`. **Only continue if `MERGEABLE`.** If `CONFLICTING`, rebase onto `beta`, force-push, and re-check. Once confirmed, wait for CI using the **CI Gate Polling** pattern from `CLAUDE.md` (beta variant). If CI E2E smoke tests fail, investigate and fix before proceeding.
## Quality Assurance Self-Checks
@@ -298,7 +298,7 @@ Before considering your work complete, verify:
- [ ] Dependent systems are tested via real containers (not only mocked)
- [ ] Smoke tests expanded if new major capabilities were added
- [ ] Bug reports have complete reproduction steps
-- [ ] CI checks pass after push (use the **CI Gate Polling** pattern from `CLAUDE.md`) — includes E2E smoke tests
+- [ ] PR is mergeable (no conflicts) and CI checks pass after push (verify mergeability first, then use the **CI Gate Polling** pattern from `CLAUDE.md`) — includes E2E smoke tests
---
diff --git a/.claude/agents/frontend-developer.md b/.claude/agents/frontend-developer.md
index b3db295c3..042c809c0 100644
--- a/.claude/agents/frontend-developer.md
+++ b/.claude/agents/frontend-developer.md
@@ -153,7 +153,7 @@ Before building any UI element, check if a shared component exists. Using shared
Before considering any task complete:
1. **Commit** your changes — the pre-commit hook runs all quality gates (lint, format, tests, typecheck, build, audit)
-2. **Wait for CI** after pushing (use the **CI Gate Polling** pattern from `CLAUDE.md`) — do not proceed until green
+2. **Wait for CI** after pushing — first **wait 5 seconds**, then verify mergeability (`gh pr view --repo steilerDev/cornerstone --json mergeable -q '.mergeable'`), **only continue if `MERGEABLE`**. If `CONFLICTING`, rebase onto `beta`, force-push, and re-check. Once confirmed, use the **CI Gate Polling** pattern from `CLAUDE.md` — do not proceed until green
3. **Verify** that all new components handle loading, error, and empty states
4. **Check** that TypeScript types are properly defined (no `any` types without justification)
5. **Ensure** new API client functions match the contract on the GitHub Wiki API Contract page
@@ -194,7 +194,7 @@ Before considering any task complete:
3. Commit with conventional commit message and your Co-Authored-By trailer (the pre-commit hook runs all quality gates automatically — selective lint/format/tests on staged files + full typecheck/build/audit)
4. Push: `git push -u origin `
5. Create a PR targeting `beta`: `gh pr create --base beta --title "..." --body "..."`
-6. Wait for CI using the **CI Gate Polling** pattern from `CLAUDE.md` (beta variant)
+6. **Wait 5 seconds**, then check mergeability: `gh pr view --repo steilerDev/cornerstone --json mergeable -q '.mergeable'`. **Only continue if `MERGEABLE`.** If `CONFLICTING`, rebase onto `beta`, force-push, and re-check. Once confirmed, wait for CI using the **CI Gate Polling** pattern from `CLAUDE.md` (beta variant)
7. **Request review**: After CI passes, the orchestrator launches `product-architect` and `security-engineer` to review the PR. Both must approve before merge.
8. **Address feedback**: If a reviewer requests changes, fix the issues on the same branch and push. The orchestrator will re-request review from the reviewer(s) that requested changes.
9. After merge, clean up: `git checkout beta && git pull && git branch -d `
diff --git a/.claude/agents/product-architect.md b/.claude/agents/product-architect.md
index e49d9a294..016c3d159 100644
--- a/.claude/agents/product-architect.md
+++ b/.claude/agents/product-architect.md
@@ -236,7 +236,7 @@ Your verdict must match the severity of your findings. Use `approve` or `request
3. Commit with conventional commit message and your Co-Authored-By trailer (the pre-commit hook runs all quality gates automatically — selective lint/format/tests on staged files + full typecheck/build/audit)
4. Push: `git push -u origin `
5. Create a PR targeting `beta`: `gh pr create --base beta --title "..." --body "..."`
-6. Wait for CI using the **CI Gate Polling** pattern from `CLAUDE.md` (beta variant)
+6. **Wait 5 seconds**, then check mergeability: `gh pr view --repo steilerDev/cornerstone --json mergeable -q '.mergeable'`. **Only continue if `MERGEABLE`.** If `CONFLICTING`, rebase onto `beta`, force-push, and re-check. Once confirmed, wait for CI using the **CI Gate Polling** pattern from `CLAUDE.md` (beta variant)
7. **Request review**: After CI passes, the orchestrator launches `product-owner`, `product-architect`, and `security-engineer` to review the PR. All must approve before merge.
8. **Address feedback**: If a reviewer requests changes, fix the issues on the same branch and push. The orchestrator will re-request review from the reviewer(s) that requested changes.
9. After merge, clean up: `git checkout beta && git pull && git branch -d `
diff --git a/.claude/agents/qa-integration-tester.md b/.claude/agents/qa-integration-tester.md
index d440a3184..de3a37157 100644
--- a/.claude/agents/qa-integration-tester.md
+++ b/.claude/agents/qa-integration-tester.md
@@ -231,7 +231,7 @@ Before considering your work complete, verify:
- [ ] Performance metrics validated against baselines (bundle size, load time, API response time)
- [ ] Docker deployment tested if applicable
- [ ] i18n coverage: new translation keys exist in both `en` and `de`, no hardcoded user-facing strings
-- [ ] CI checks pass after push (use the **CI Gate Polling** pattern from `CLAUDE.md`)
+- [ ] PR is mergeable (no conflicts) and CI checks pass after push (verify mergeability first, then use the **CI Gate Polling** pattern from `CLAUDE.md`)
---
diff --git a/.claude/metrics/review-metrics.jsonl b/.claude/metrics/review-metrics.jsonl
index 19e0254c6..3639d6ba4 100644
--- a/.claude/metrics/review-metrics.jsonl
+++ b/.claude/metrics/review-metrics.jsonl
@@ -174,3 +174,22 @@
{"pr":1078,"issues":[1074],"epic":916,"type":"feat","createdAt":"2026-03-19T21:47:17Z","mergedAt":"2026-03-19T23:30:00Z","filesChanged":12,"linesChanged":750,"touchesClient":true,"touchesServer":false,"fixLoopCount":0,"internalFixLoopCount":2,"fixLoopTriggers":[{"round":1,"agents":["frontend-developer","qa-integration-tester","e2e-test-engineer"]},{"round":2,"agents":["qa-integration-tester","e2e-test-engineer"]}],"reviews":[{"agent":"product-owner","verdict":"approve","findings":{"critical":0,"high":0,"medium":0,"low":1,"informational":0},"round":1},{"agent":"ux-designer","verdict":"approve","findings":{"critical":0,"high":0,"medium":1,"low":0,"informational":0},"round":1},{"agent":"product-architect","verdict":"approve","findings":{"critical":0,"high":0,"medium":1,"low":0,"informational":0},"round":1}],"totalFindings":{"critical":0,"high":0,"medium":2,"low":1,"informational":0}}
{"pr":1080,"issues":[1079],"epic":null,"type":"feat","createdAt":"2026-03-19T22:35:54Z","mergedAt":"2026-03-19T23:10:00Z","filesChanged":6,"linesChanged":221,"touchesClient":false,"touchesServer":true,"fixLoopCount":0,"internalFixLoopCount":0,"fixLoopTriggers":[],"reviews":[{"agent":"product-architect","verdict":"approve","findings":{"critical":0,"high":0,"medium":1,"low":0,"informational":0},"round":1},{"agent":"product-owner","verdict":"approve","findings":{"critical":0,"high":0,"medium":0,"low":0,"informational":0},"round":1}],"totalFindings":{"critical":0,"high":0,"medium":1,"low":0,"informational":0}}
{"pr":1081,"issues":[1073],"epic":null,"type":"fix","createdAt":"2026-03-19T23:41:54Z","mergedAt":"2026-03-20T00:30:00Z","filesChanged":2,"linesChanged":59,"touchesClient":true,"touchesServer":false,"fixLoopCount":0,"internalFixLoopCount":0,"fixLoopTriggers":[],"reviews":[{"agent":"product-architect","verdict":"approve","findings":{"critical":0,"high":0,"medium":0,"low":0,"informational":0},"round":1},{"agent":"ux-designer","verdict":"approve","findings":{"critical":0,"high":0,"medium":0,"low":0,"informational":0},"round":1}],"totalFindings":{"critical":0,"high":0,"medium":0,"low":0,"informational":0}}
+{"pr":1094,"issues":[1093],"epic":null,"type":"fix","createdAt":"2026-03-20T22:53:51Z","mergedAt":null,"filesChanged":3,"linesChanged":197,"touchesClient":true,"touchesServer":false,"fixLoopCount":0,"internalFixLoopCount":0,"fixLoopTriggers":[],"reviews":[{"agent":"product-architect","verdict":"approve","findings":{"critical":0,"high":0,"medium":0,"low":0,"informational":0},"round":1},{"agent":"ux-designer","verdict":"approve","findings":{"critical":0,"high":0,"medium":0,"low":0,"informational":0},"round":1}],"totalFindings":{"critical":0,"high":0,"medium":0,"low":0,"informational":0}}
+{"pr":1097,"issues":[1096],"epic":null,"type":"fix","createdAt":"2026-03-20T23:17:38Z","mergedAt":null,"filesChanged":3,"linesChanged":501,"touchesClient":false,"touchesServer":true,"fixLoopCount":0,"internalFixLoopCount":0,"fixLoopTriggers":[],"reviews":[{"agent":"product-architect","verdict":"approve","findings":{"critical":0,"high":0,"medium":0,"low":0,"informational":1},"round":1}],"totalFindings":{"critical":0,"high":0,"medium":0,"low":0,"informational":1}}
+{"pr":1101,"issues":[1100],"epic":null,"type":"fix","createdAt":"2026-03-20T23:58:13Z","mergedAt":null,"filesChanged":1,"linesChanged":67,"touchesClient":true,"touchesServer":false,"fixLoopCount":0,"internalFixLoopCount":0,"fixLoopTriggers":[],"reviews":[{"agent":"product-architect","verdict":"approve","findings":{"critical":0,"high":0,"medium":0,"low":0,"informational":0},"round":1},{"agent":"ux-designer","verdict":"approve","findings":{"critical":0,"high":0,"medium":0,"low":0,"informational":0},"round":1}],"totalFindings":{"critical":0,"high":0,"medium":0,"low":0,"informational":0}}
+{"pr":1104,"issues":[1103],"epic":null,"type":"fix","createdAt":"2026-03-21T00:14:18Z","mergedAt":null,"filesChanged":2,"linesChanged":23,"touchesClient":true,"touchesServer":false,"fixLoopCount":0,"internalFixLoopCount":0,"fixLoopTriggers":[],"reviews":[{"agent":"product-architect","verdict":"approve","findings":{"critical":0,"high":0,"medium":0,"low":0,"informational":0},"round":1},{"agent":"ux-designer","verdict":"approve","findings":{"critical":0,"high":0,"medium":0,"low":0,"informational":0},"round":1}],"totalFindings":{"critical":0,"high":0,"medium":0,"low":0,"informational":0}}
+{"pr":1102,"issues":[1099],"epic":null,"type":"feat","createdAt":"2026-03-21T00:10:57Z","mergedAt":null,"filesChanged":36,"linesChanged":5307,"touchesClient":true,"touchesServer":false,"fixLoopCount":1,"internalFixLoopCount":0,"fixLoopTriggers":[{"round":1,"agents":["product-architect","ux-designer","product-owner"]}],"reviews":[{"agent":"product-architect","verdict":"approve","findings":{"critical":0,"high":0,"medium":4,"low":0,"informational":0},"round":1},{"agent":"ux-designer","verdict":"approve","findings":{"critical":0,"high":0,"medium":3,"low":3,"informational":4},"round":1},{"agent":"product-owner","verdict":"request-changes","findings":{"critical":0,"high":3,"medium":0,"low":0,"informational":0},"round":1}],"totalFindings":{"critical":0,"high":3,"medium":7,"low":3,"informational":4}}
+{"pr":1107,"issues":[1105],"epic":null,"type":"feat","createdAt":"2026-03-21T08:24:21Z","mergedAt":null,"filesChanged":5,"linesChanged":909,"touchesClient":true,"touchesServer":false,"fixLoopCount":0,"internalFixLoopCount":1,"fixLoopTriggers":[{"round":1,"agents":["dev-team-lead"]}],"reviews":[{"agent":"ux-designer","verdict":"approve","findings":{"critical":0,"high":0,"medium":0,"low":2,"informational":1},"round":1},{"agent":"product-architect","verdict":"approve","findings":{"critical":0,"high":0,"medium":0,"low":0,"informational":1},"round":1},{"agent":"product-owner","verdict":"approve","findings":{"critical":0,"high":0,"medium":0,"low":0,"informational":1},"round":1}],"totalFindings":{"critical":0,"high":0,"medium":0,"low":2,"informational":3}}
+{"pr":1110,"issues":[1108],"epic":null,"type":"feat","createdAt":"2026-03-21T09:14:37Z","mergedAt":null,"filesChanged":11,"linesChanged":2178,"touchesClient":true,"touchesServer":false,"fixLoopCount":0,"internalFixLoopCount":0,"fixLoopTriggers":[],"reviews":[{"agent":"product-architect","verdict":"approve","findings":{"critical":0,"high":0,"medium":1,"low":1,"informational":2},"round":1},{"agent":"ux-designer","verdict":"approve","findings":{"critical":0,"high":0,"medium":1,"low":2,"informational":1},"round":1},{"agent":"product-owner","verdict":"approve","findings":{"critical":0,"high":0,"medium":0,"low":2,"informational":2},"round":1}],"totalFindings":{"critical":0,"high":0,"medium":2,"low":5,"informational":5}}
+{"pr":1112,"issues":[1111],"epic":null,"type":"feat","createdAt":"2026-03-21T09:47:39Z","mergedAt":null,"filesChanged":5,"linesChanged":2325,"touchesClient":true,"touchesServer":false,"fixLoopCount":0,"internalFixLoopCount":0,"fixLoopTriggers":[],"reviews":[{"agent":"product-architect","verdict":"approve","findings":{"critical":0,"high":0,"medium":0,"low":0,"informational":0},"round":1},{"agent":"ux-designer","verdict":"approve","findings":{"critical":0,"high":0,"medium":0,"low":0,"informational":0},"round":1},{"agent":"product-owner","verdict":"approve","findings":{"critical":0,"high":0,"medium":0,"low":0,"informational":0},"round":1}],"totalFindings":{"critical":0,"high":0,"medium":0,"low":0,"informational":0}}
+{"pr":1114,"issues":[1113],"epic":null,"type":"feat","createdAt":"2026-03-21T10:20:28Z","mergedAt":null,"filesChanged":6,"linesChanged":2281,"touchesClient":true,"touchesServer":false,"fixLoopCount":0,"internalFixLoopCount":0,"fixLoopTriggers":[],"reviews":[{"agent":"product-architect","verdict":"approve","findings":{"critical":0,"high":0,"medium":0,"low":0,"informational":0},"round":1},{"agent":"ux-designer","verdict":"approve","findings":{"critical":0,"high":0,"medium":0,"low":0,"informational":0},"round":1},{"agent":"product-owner","verdict":"approve","findings":{"critical":0,"high":0,"medium":0,"low":0,"informational":0},"round":1}],"totalFindings":{"critical":0,"high":0,"medium":0,"low":0,"informational":0}}
+{"pr":1116,"issues":[1115],"epic":null,"type":"feat","createdAt":"2026-03-21T10:49:31Z","mergedAt":null,"filesChanged":5,"linesChanged":2679,"touchesClient":true,"touchesServer":false,"fixLoopCount":0,"internalFixLoopCount":0,"fixLoopTriggers":[],"reviews":[{"agent":"product-architect","verdict":"approve","findings":{"critical":0,"high":0,"medium":0,"low":0,"informational":0},"round":1},{"agent":"ux-designer","verdict":"approve","findings":{"critical":0,"high":0,"medium":0,"low":0,"informational":0},"round":1},{"agent":"product-owner","verdict":"approve","findings":{"critical":0,"high":0,"medium":0,"low":0,"informational":0},"round":1}],"totalFindings":{"critical":0,"high":0,"medium":0,"low":0,"informational":0}}
+{"pr":1119,"issues":[1118],"epic":null,"type":"feat","createdAt":"2026-03-21T11:21:00Z","mergedAt":null,"filesChanged":5,"linesChanged":2315,"touchesClient":true,"touchesServer":false,"fixLoopCount":0,"internalFixLoopCount":0,"fixLoopTriggers":[],"reviews":[{"agent":"product-architect","verdict":"approve","findings":{"critical":0,"high":0,"medium":0,"low":0,"informational":0},"round":1},{"agent":"ux-designer","verdict":"approve","findings":{"critical":0,"high":0,"medium":0,"low":0,"informational":0},"round":1},{"agent":"product-owner","verdict":"approve","findings":{"critical":0,"high":0,"medium":0,"low":0,"informational":0},"round":1}],"totalFindings":{"critical":0,"high":0,"medium":0,"low":0,"informational":0}}
+{"pr":1141,"issues":[1135,1136,1137,1138,1139,1140],"epic":null,"type":"fix","createdAt":"2026-03-22T10:59:20Z","mergedAt":"2026-03-22T12:30:00Z","filesChanged":17,"linesChanged":865,"touchesClient":true,"touchesServer":false,"fixLoopCount":0,"internalFixLoopCount":1,"fixLoopTriggers":[{"round":1,"agents":["frontend-developer"]}],"reviews":[{"agent":"product-architect","verdict":"approve","findings":{"critical":0,"high":0,"medium":0,"low":0,"informational":2},"round":1},{"agent":"ux-designer","verdict":"approve","findings":{"critical":0,"high":0,"medium":0,"low":2,"informational":1},"round":1}],"totalFindings":{"critical":0,"high":0,"medium":0,"low":2,"informational":3}}
+{"pr":1144,"issues":[1142],"epic":null,"type":"feat","createdAt":"2026-03-22T11:13:49Z","mergedAt":"2026-03-22T13:00:00Z","filesChanged":30,"linesChanged":1788,"touchesClient":true,"touchesServer":false,"fixLoopCount":0,"internalFixLoopCount":1,"fixLoopTriggers":[{"round":1,"agents":["frontend-developer","translator","e2e-test-engineer"]}],"reviews":[{"agent":"product-architect","verdict":"approve","findings":{"critical":0,"high":0,"medium":0,"low":0,"informational":0},"round":1},{"agent":"ux-designer","verdict":"approve","findings":{"critical":0,"high":0,"medium":0,"low":0,"informational":0},"round":1},{"agent":"product-owner","verdict":"approve","findings":{"critical":0,"high":0,"medium":0,"low":0,"informational":2},"round":1}],"totalFindings":{"critical":0,"high":0,"medium":0,"low":0,"informational":2}}
+{"pr":1163,"issues":[1162],"epic":null,"type":"fix","createdAt":"2026-03-22T13:23:33Z","mergedAt":"2026-03-22T14:00:00Z","filesChanged":6,"linesChanged":44,"touchesClient":true,"touchesServer":false,"fixLoopCount":0,"internalFixLoopCount":0,"fixLoopTriggers":[],"reviews":[{"agent":"product-architect","verdict":"approve","findings":{"critical":0,"high":0,"medium":0,"low":0,"informational":0},"round":1}],"totalFindings":{"critical":0,"high":0,"medium":0,"low":0,"informational":0}}
+{"pr":1165,"issues":[1161],"epic":null,"type":"fix","createdAt":"2026-03-22T13:39:25Z","mergedAt":"2026-03-22T14:30:00Z","filesChanged":7,"linesChanged":292,"touchesClient":true,"touchesServer":false,"fixLoopCount":0,"internalFixLoopCount":0,"fixLoopTriggers":[],"reviews":[{"agent":"product-architect","verdict":"approve","findings":{"critical":0,"high":0,"medium":0,"low":0,"informational":1},"round":1},{"agent":"ux-designer","verdict":"approve","findings":{"critical":0,"high":0,"medium":1,"low":1,"informational":0},"round":1}],"totalFindings":{"critical":0,"high":0,"medium":1,"low":1,"informational":1}}
+{"pr":1168,"issues":[1166],"epic":null,"type":"fix","createdAt":"2026-03-22T15:18:22Z","mergedAt":"2026-03-22T15:30:00Z","filesChanged":4,"linesChanged":105,"touchesClient":true,"touchesServer":false,"fixLoopCount":0,"internalFixLoopCount":0,"fixLoopTriggers":[],"reviews":[{"agent":"product-architect","verdict":"approve","findings":{"critical":0,"high":0,"medium":0,"low":0,"informational":0},"round":1},{"agent":"ux-designer","verdict":"approve","findings":{"critical":0,"high":0,"medium":0,"low":0,"informational":0},"round":1}],"totalFindings":{"critical":0,"high":0,"medium":0,"low":0,"informational":0}}
+{"pr":1150,"issues":[1146,1164],"epic":null,"type":"feat","createdAt":"2026-03-22T12:16:28Z","mergedAt":"2026-03-22T16:00:00Z","filesChanged":35,"linesChanged":3564,"touchesClient":true,"touchesServer":true,"fixLoopCount":1,"internalFixLoopCount":1,"fixLoopTriggers":[{"round":1,"agents":["product-architect"]}],"reviews":[{"agent":"product-architect","verdict":"request-changes","findings":{"critical":2,"high":3,"medium":2,"low":0,"informational":0},"round":1},{"agent":"security-engineer","verdict":"approve","findings":{"critical":0,"high":0,"medium":0,"low":0,"informational":3},"round":1},{"agent":"product-owner","verdict":"approve","findings":{"critical":0,"high":0,"medium":0,"low":0,"informational":2},"round":1},{"agent":"ux-designer","verdict":"approve","findings":{"critical":0,"high":0,"medium":1,"low":2,"informational":1},"round":1}],"totalFindings":{"critical":2,"high":3,"medium":3,"low":2,"informational":6}}
+{"pr":1186,"issues":[1185],"epic":null,"type":"fix","createdAt":"2026-03-23T21:18:16Z","mergedAt":"","filesChanged":26,"linesChanged":623,"touchesClient":true,"touchesServer":false,"fixLoopCount":0,"internalFixLoopCount":0,"fixLoopTriggers":[],"reviews":[{"agent":"product-architect","verdict":"approve","findings":{"critical":0,"high":0,"medium":0,"low":0,"informational":1},"round":1},{"agent":"ux-designer","verdict":"approve","findings":{"critical":0,"high":0,"medium":0,"low":0,"informational":2},"round":1}],"totalFindings":{"critical":0,"high":0,"medium":0,"low":0,"informational":3}}
+{"pr":1188,"issues":[1187],"epic":null,"type":"refactor","createdAt":"2026-03-24T15:19:49Z","mergedAt":"2026-03-24T18:30:00Z","filesChanged":52,"linesChanged":3932,"touchesClient":true,"touchesServer":false,"fixLoopCount":2,"internalFixLoopCount":1,"fixLoopTriggers":[{"round":1,"agents":["ux-designer"]},{"round":2,"agents":["product-owner"]}],"reviews":[{"agent":"product-owner","verdict":"approve","findings":{"critical":0,"high":0,"medium":0,"low":0,"informational":0},"round":1},{"agent":"product-architect","verdict":"approve","findings":{"critical":0,"high":0,"medium":2,"low":0,"informational":0},"round":1},{"agent":"ux-designer","verdict":"approve","findings":{"critical":0,"high":0,"medium":1,"low":0,"informational":4},"round":1}],"totalFindings":{"critical":0,"high":0,"medium":3,"low":0,"informational":4}}
diff --git a/.claude/skills/develop/SKILL.md b/.claude/skills/develop/SKILL.md
index 9b8a464fb..81ca32f93 100644
--- a/.claude/skills/develop/SKILL.md
+++ b/.claude/skills/develop/SKILL.md
@@ -391,7 +391,7 @@ If any reviewer identifies blocking issues:
Once all reviews are clean, wait for CI to go green:
-Use the **CI Gate Polling** pattern from `CLAUDE.md` (beta variant — wait for `Quality Gates` only).
+After pushing, **wait 5 seconds** for GitHub to compute merge status, then check mergeability: `gh pr view --repo steilerDev/cornerstone --json mergeable -q '.mergeable'`. **Only continue if the result is `MERGEABLE`.** If `CONFLICTING`, rebase onto `beta`, force-push, and re-check. If `UNKNOWN`, wait a few more seconds and retry. Once mergeability is confirmed, use the **CI Gate Polling** pattern from `CLAUDE.md` (beta variant — wait for `Quality Gates` + `CLA`).
After CI is green, present the user with:
diff --git a/.claude/skills/epic-close/SKILL.md b/.claude/skills/epic-close/SKILL.md
index b8b0d8247..9036d63fa 100644
--- a/.claude/skills/epic-close/SKILL.md
+++ b/.claude/skills/epic-close/SKILL.md
@@ -114,7 +114,7 @@ If there are refinement items to address:
```
gh pr create --base beta --title "chore: address refinement items for epic #" --body "..."
```
-8. Wait for CI using the **CI Gate Polling** pattern from `CLAUDE.md` (beta variant — wait for `Quality Gates` only)
+8. **Wait 5 seconds** after creating the PR, then check mergeability: `gh pr view --repo steilerDev/cornerstone --json mergeable -q '.mergeable'`. **Only continue if the result is `MERGEABLE`.** If `CONFLICTING`, rebase onto `beta`, force-push, and re-check. If `UNKNOWN`, wait a few more seconds and retry. Once mergeability is confirmed, wait for CI using the **CI Gate Polling** pattern from `CLAUDE.md` (beta variant — wait for `Quality Gates` + `CLA`)
9. Squash merge: `gh pr merge --squash `
If no refinement items exist, skip to step 5.
diff --git a/.claude/skills/release/SKILL.md b/.claude/skills/release/SKILL.md
index 508d0ede6..727562243 100644
--- a/.claude/skills/release/SKILL.md
+++ b/.claude/skills/release/SKILL.md
@@ -147,7 +147,7 @@ EOF
### 3. CI Gate
-Wait for all required CI gates to pass on the promotion PR using the **CI Gate Polling** pattern from `CLAUDE.md` (main variant — wait for both `Quality Gates` + `E2E Gates`).
+After creating/pushing the promotion PR, **wait 5 seconds** for GitHub to compute merge status, then check mergeability: `gh pr view --repo steilerDev/cornerstone --json mergeable -q '.mergeable'`. **Only continue if the result is `MERGEABLE`.** If `CONFLICTING`, rebase onto `main`, force-push, and re-check. If `UNKNOWN`, wait a few more seconds and retry. Once mergeability is confirmed, use the **CI Gate Polling** pattern from `CLAUDE.md` (main variant — wait for `Quality Gates` + `E2E Gates` + `CLA`).
If any gate fails, investigate and resolve before proceeding.
@@ -224,7 +224,7 @@ After all fix groups are merged to `beta`:
#### 4g. CI Gate
-Wait for all required CI gates to pass on the new promotion PR using the **CI Gate Polling** pattern from `CLAUDE.md` (main variant — wait for both `Quality Gates` + `E2E Gates`).
+After creating/pushing the new promotion PR, **wait 5 seconds** for GitHub to compute merge status, then check mergeability: `gh pr view --repo steilerDev/cornerstone --json mergeable -q '.mergeable'`. **Only continue if the result is `MERGEABLE`.** If `CONFLICTING`, rebase onto `main`, force-push, and re-check. If `UNKNOWN`, wait a few more seconds and retry. Once mergeability is confirmed, use the **CI Gate Polling** pattern from `CLAUDE.md` (main variant — wait for `Quality Gates` + `E2E Gates` + `CLA`).
If any gate fails, investigate and resolve before proceeding.
diff --git a/.claude/skills/review-pr/SKILL.md b/.claude/skills/review-pr/SKILL.md
index 0776cce09..92b61beb8 100644
--- a/.claude/skills/review-pr/SKILL.md
+++ b/.claude/skills/review-pr/SKILL.md
@@ -129,7 +129,7 @@ Present the blocking findings to the user. **Do NOT wait for CI.**
Post a consolidated `gh pr review --approve` comment on the PR summarizing the review outcome.
-Wait for CI using the **CI Gate Polling** pattern from `CLAUDE.md` (use the beta or main variant based on the PR's target branch).
+**Wait 5 seconds**, then check mergeability: `gh pr view --repo steilerDev/cornerstone --json mergeable -q '.mergeable'`. **Only continue if `MERGEABLE`.** If `CONFLICTING`, report the conflict to the user — do not attempt to resolve. Once mergeability is confirmed, wait for CI using the **CI Gate Polling** pattern from `CLAUDE.md` (use the beta or main variant based on the PR's target branch).
If CI fails, report the specific failures to the user. **Do NOT merge.**
diff --git a/.env.example b/.env.example
index 388bc5a65..bc248c545 100644
--- a/.env.example
+++ b/.env.example
@@ -31,6 +31,11 @@ SECURE_COOKIES=true
# OIDC_CLIENT_ID=cornerstone
# OIDC_CLIENT_SECRET=your-client-secret
+# ─── Backup ───────────────────────────────────────────
+# BACKUP_DIR=/backups # Backup destination directory (must be outside app data directory)
+# BACKUP_CADENCE=0 2 * * * # Cron expression for automatic backups (e.g., daily at 2 AM)
+# BACKUP_RETENTION=7 # Maximum number of backup archives to retain
+
# ─── Paperless-ngx ─────────────────────────────────────
# PAPERLESS_URL=http://paperless-ngx:8000
# PAPERLESS_API_TOKEN=your-paperless-api-token
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 0a66364ed..2eb40ede1 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -220,7 +220,7 @@ jobs:
# --- Restore caches ---
- name: Restore browser cache
id: browser-cache
- uses: actions/cache/restore@cdf6c1fa76f9f475f3d7449005a359c84ca0f306 # v5
+ uses: actions/cache/restore@668228422ae6a00e4ad889ee87cd7109ec5666a7 # v5
with:
path: ~/.cache/ms-playwright
key: playwright-v3-${{ steps.playwright-version.outputs.version }}-${{ runner.os }}
@@ -230,7 +230,7 @@ jobs:
- name: Restore apt cache
id: apt-cache
- uses: actions/cache/restore@cdf6c1fa76f9f475f3d7449005a359c84ca0f306 # v5
+ uses: actions/cache/restore@668228422ae6a00e4ad889ee87cd7109ec5666a7 # v5
with:
path: /var/cache/apt/archives
key: apt-v3-playwright-${{ steps.playwright-version.outputs.version }}-${{ runner.os }}
@@ -243,7 +243,7 @@ jobs:
- name: Save browser cache
if: steps.browser-cache.outputs.cache-hit != 'true'
- uses: actions/cache/save@cdf6c1fa76f9f475f3d7449005a359c84ca0f306 # v5
+ uses: actions/cache/save@668228422ae6a00e4ad889ee87cd7109ec5666a7 # v5
with:
path: ~/.cache/ms-playwright
key: playwright-v3-${{ steps.playwright-version.outputs.version }}-${{ runner.os }}
@@ -280,7 +280,7 @@ jobs:
- name: Save apt cache
if: steps.apt-cache.outputs.cache-hit != 'true'
- uses: actions/cache/save@cdf6c1fa76f9f475f3d7449005a359c84ca0f306 # v5
+ uses: actions/cache/save@668228422ae6a00e4ad889ee87cd7109ec5666a7 # v5
with:
path: /var/cache/apt/archives
key: apt-v3-playwright-${{ steps.playwright-version.outputs.version }}-${{ runner.os }}
@@ -340,7 +340,7 @@ jobs:
run: npm ci -w e2e
- name: Restore browser cache
- uses: actions/cache/restore@cdf6c1fa76f9f475f3d7449005a359c84ca0f306 # v5
+ uses: actions/cache/restore@668228422ae6a00e4ad889ee87cd7109ec5666a7 # v5
with:
path: ~/.cache/ms-playwright
key: playwright-v3-${{ needs.e2e-warmup.outputs.playwright-version }}-${{ runner.os }}
@@ -349,7 +349,7 @@ jobs:
run: sudo chown -R $(id -u):$(id -g) /var/cache/apt/archives
- name: Restore apt cache
- uses: actions/cache/restore@cdf6c1fa76f9f475f3d7449005a359c84ca0f306 # v5
+ uses: actions/cache/restore@668228422ae6a00e4ad889ee87cd7109ec5666a7 # v5
with:
path: /var/cache/apt/archives
key: apt-v3-playwright-${{ needs.e2e-warmup.outputs.playwright-version }}-${{ runner.os }}
@@ -421,7 +421,7 @@ jobs:
run: npm ci -w e2e
- name: Restore browser cache
- uses: actions/cache/restore@cdf6c1fa76f9f475f3d7449005a359c84ca0f306 # v5
+ uses: actions/cache/restore@668228422ae6a00e4ad889ee87cd7109ec5666a7 # v5
with:
path: ~/.cache/ms-playwright
key: playwright-v3-${{ needs.e2e-warmup.outputs.playwright-version }}-${{ runner.os }}
@@ -430,7 +430,7 @@ jobs:
run: sudo chown -R $(id -u):$(id -g) /var/cache/apt/archives
- name: Restore apt cache
- uses: actions/cache/restore@cdf6c1fa76f9f475f3d7449005a359c84ca0f306 # v5
+ uses: actions/cache/restore@668228422ae6a00e4ad889ee87cd7109ec5666a7 # v5
with:
path: /var/cache/apt/archives
key: apt-v3-playwright-${{ needs.e2e-warmup.outputs.playwright-version }}-${{ runner.os }}
diff --git a/.github/workflows/cla.yml b/.github/workflows/cla.yml
index 6ccd01b6b..80cc6f124 100644
--- a/.github/workflows/cla.yml
+++ b/.github/workflows/cla.yml
@@ -28,7 +28,7 @@ jobs:
private-key: ${{ secrets.BOT_PRIVATE_KEY }}
- uses: contributor-assistant/github-action@ca4a40a7d1004f18d9960b404b97e5f30a505a08 # v2.6.1
env:
- # Only use app token (PAT) — omit GITHUB_TOKEN so the action uses PAT for all operations
+ GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
PERSONAL_ACCESS_TOKEN: ${{ steps.app-token.outputs.token }}
with:
remote-organization-name: steilerDev
@@ -44,4 +44,4 @@ jobs:
To sign, please comment on this PR with:
**I have read the CLA Document and I hereby sign the CLA**
custom-pr-sign-comment: 'I have read the CLA Document and I hereby sign the CLA'
- signed-commit-message: 'chore: record CLA signature for $contributorName (#$pullRequestNo) [skip ci]'
+ signed-commit-message: 'chore: record CLA signature for $contributorName [skip ci]'
\ No newline at end of file
diff --git a/.github/workflows/design-review-screenshots.yml b/.github/workflows/design-review-screenshots.yml
deleted file mode 100644
index 8d38f6091..000000000
--- a/.github/workflows/design-review-screenshots.yml
+++ /dev/null
@@ -1,157 +0,0 @@
-# Design Review Screenshot Capture
-#
-# Manually triggered workflow that captures screenshots of every application view
-# across desktop, tablet, and mobile viewports in both light and dark themes.
-#
-# The output artifact (design-review-screenshots) contains:
-# {viewport}/{theme}/{NN}-{view-name}.png
-#
-# Usage:
-# 1. Go to Actions → "Design Review Screenshots" → Run workflow
-# 2. Choose branch and optionally a Docker image tag
-# 3. Download the artifact when complete
-# 4. Feed screenshots to a design reviewer for analysis
-#
-name: Design Review Screenshots
-
-on:
- workflow_dispatch:
- inputs:
- docker-image:
- description: >
- Docker image to screenshot (e.g. steilerdev/cornerstone:beta).
- Leave empty to build from the current branch.
- required: false
- default: ''
- ref:
- description: 'Git ref to checkout (branch, tag, or SHA). Defaults to the branch selected above.'
- required: false
- default: ''
-
-permissions:
- contents: read
-
-jobs:
- # ─── Build or pull the Docker image ──────────────────────────────────────
- prepare-image:
- name: Prepare Docker Image
- runs-on: ubuntu-latest
- outputs:
- image-source: ${{ steps.decide.outputs.source }}
-
- steps:
- - name: Decide image source
- id: decide
- run: |
- if [ -n "${{ inputs.docker-image }}" ]; then
- echo "source=pull" >> "$GITHUB_OUTPUT"
- else
- echo "source=build" >> "$GITHUB_OUTPUT"
- fi
-
- - name: Checkout
- if: steps.decide.outputs.source == 'build'
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
- with:
- ref: ${{ inputs.ref || github.ref }}
-
- - name: Login to DHI registry
- if: steps.decide.outputs.source == 'build'
- uses: docker/login-action@b45d80f862d83dbcd57f89517bcf500b2ab88fb2 # v4
- with:
- registry: dhi.io
- username: ${{ vars.DOCKERHUB_USERNAME }}
- password: ${{ secrets.DOCKERHUB_TOKEN }}
-
- - name: Set up Docker Buildx
- if: steps.decide.outputs.source == 'build'
- uses: docker/setup-buildx-action@4d04d5d9486b7bd6fa91e7baf45bbb4f8b9deedd # v4
-
- - name: Build image
- if: steps.decide.outputs.source == 'build'
- uses: docker/build-push-action@d08e5c354a6adb9ed34480a06d141179aa583294 # v7
- with:
- context: .
- load: true
- tags: cornerstone:e2e
- build-args: APP_VERSION=design-review
- cache-from: type=gha,scope=linux/amd64
-
- - name: Login to Docker Hub (for pull)
- if: steps.decide.outputs.source == 'pull'
- uses: docker/login-action@b45d80f862d83dbcd57f89517bcf500b2ab88fb2 # v4
- with:
- username: ${{ vars.DOCKERHUB_USERNAME }}
- password: ${{ secrets.DOCKERHUB_TOKEN }}
-
- - name: Pull and retag image
- if: steps.decide.outputs.source == 'pull'
- run: |
- docker pull ${{ inputs.docker-image }}
- docker tag ${{ inputs.docker-image }} cornerstone:e2e
-
- - name: Save image
- run: docker save cornerstone:e2e -o cornerstone-e2e.tar
-
- - name: Upload image artifact
- uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7
- with:
- name: cornerstone-docker-image
- path: cornerstone-e2e.tar
- retention-days: 1
-
- # ─── Capture screenshots ─────────────────────────────────────────────────
- capture:
- name: Capture Screenshots
- runs-on: ubuntu-latest
- needs: [prepare-image]
- timeout-minutes: 30
-
- steps:
- - name: Checkout
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
- with:
- ref: ${{ inputs.ref || github.ref }}
-
- - name: Download image artifact
- uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8
- with:
- name: cornerstone-docker-image
-
- - name: Load image
- run: docker load -i cornerstone-e2e.tar
-
- - name: Setup Node.js
- uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6
- with:
- node-version-file: .nvmrc
- cache: npm
-
- - name: Install E2E dependencies
- run: npm ci -w e2e
-
- - name: Install Playwright browsers
- run: npx playwright install chromium webkit --with-deps
- working-directory: e2e
-
- - name: Capture design review screenshots
- run: npx playwright test --config=design-review.config.ts
- working-directory: e2e
-
- - name: Upload screenshots artifact
- if: ${{ always() }}
- uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7
- with:
- name: design-review-screenshots
- path: e2e/design-review-screenshots/
- retention-days: 30
-
- - name: Upload test output (on failure)
- if: ${{ failure() }}
- uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7
- with:
- name: design-review-test-output
- path: |
- e2e/design-review-output/
- e2e/test-results/
- retention-days: 7
diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml
index fbdd1d9bd..a445c26da 100644
--- a/.github/workflows/release.yml
+++ b/.github/workflows/release.yml
@@ -300,7 +300,7 @@ jobs:
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Docker Scout CVE scan
- uses: docker/scout-action@1128f02d1e60f339af7306e0e62b9fdc13d9fab9 # v1.20.2
+ uses: docker/scout-action@8910519cee8ac046f3ee99686b0dc6654d5ba1a7 # v1.20.3
with:
command: cves
image: steilerdev/cornerstone:${{ needs.release.outputs.new-release-version }}
@@ -308,7 +308,7 @@ jobs:
summary: true
- name: Upload SARIF to GitHub Security
- uses: github/codeql-action/upload-sarif@b1bff81932f5cdfc8695c7752dcee935dcd061c8 # v4
+ uses: github/codeql-action/upload-sarif@38697555549f1db7851b81482ff19f1fa5c4fedc # v4
if: always()
with:
sarif_file: scout-results.sarif
diff --git a/.gitignore b/.gitignore
index 497a3d133..841f8e17e 100644
--- a/.gitignore
+++ b/.gitignore
@@ -55,3 +55,6 @@ data/
tmp/
temp/
.claude/worktrees/
+
+# DS Store (sandbox)
+.ds/
diff --git a/CLAUDE.md b/CLAUDE.md
index 171c81bc1..4e3f05391 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -442,6 +442,9 @@ Hand-written SQL files in `server/src/db/migrations/` with a numeric prefix (e.g
| `PAPERLESS_API_TOKEN` | (none) | Paperless-ngx API authentication token |
| `PAPERLESS_EXTERNAL_URL` | (none) | Browser-facing URL for Paperless-ngx links (falls back to `PAPERLESS_URL` if unset) |
| `PAPERLESS_FILTER_TAG` | (none) | Tag name for automatic document pre-filtering |
+| `BACKUP_DIR` | (none) | Backup destination directory (must be outside app data directory) |
+| `BACKUP_CADENCE` | (none) | Cron expression for automatic backups (e.g., `0 2 * * *` for daily at 2 AM) |
+| `BACKUP_RETENTION` | (none) | Maximum number of backup archives to retain (oldest deleted when exceeded) |
Production images use Docker Hardened Images (DHI). See `Dockerfile` and `docker-compose.yml` for build/deploy details.
diff --git a/README.md b/README.md
index 1132fd90b..e9470d91e 100644
--- a/README.md
+++ b/README.md
@@ -45,7 +45,9 @@ A self-hosted home building project management tool for homeowners. Track work i
- **Project Dashboard** -- At-a-glance project health with budget, timeline, invoice, and subsidy cards, mini Gantt preview, and customizable layout
- **Construction Diary** -- Daily logs, site visits, delivery records, automatic system events, photo attachments, and digital signature capture
- **Document Integration** -- Browse and link documents from Paperless-ngx to work items, household items, and invoices
-- **Internationalization** -- English and German language support with automatic locale detection
+- **Advanced List Views** -- Filter, sort, paginate, and customize columns across all list pages with the shared DataTable system
+- **Backup & Restore** -- Manual and scheduled backups with configurable retention, restore from the settings UI
+- **Internationalization** -- English and German language support with automatic locale detection, including translated category names
- **Authentication** -- Local accounts with setup wizard, OIDC single sign-on
- **User Management** -- Admin and Member roles, admin panel
- **Dark Mode** -- Light, Dark, or System theme
@@ -83,6 +85,9 @@ Open `http://localhost:3000` -- the setup wizard will guide you through creating
- [x] **EPIC-13**: Construction Diary
- [x] **EPIC-17**: Internationalization (English + German)
- [x] **EPIC-18**: Areas & Trades
+- [x] DataTable & List View Overhaul
+- [x] Backup & Restore
+- [ ] **EPIC-16**: Floor Plans & Utility Tracking
Track live progress on the [GitHub Projects board](https://github.com/users/steilerDev/projects/4).
diff --git a/RELEASE_SUMMARY.md b/RELEASE_SUMMARY.md
index aa465df59..72000cd53 100644
--- a/RELEASE_SUMMARY.md
+++ b/RELEASE_SUMMARY.md
@@ -1,18 +1,12 @@
## What's New
-This release introduces Areas & Trades -- a structured replacement for the flat tag system that lets you organize your project by physical location and professional specialty. It also ships budget enhancements including quotation tracking, subsidy caps, and improved filtering, alongside numerous UX refinements across the Gantt chart, dashboard, and overview pages.
+This release overhauls every list page in the application with a powerful DataTable system -- bringing filtering, sorting, pagination, and customizable columns to Work Items, Milestones, Vendors, Invoices, Household Items, and User Management. It also adds a full backup and restore capability with scheduled backups and retention policies, configurable from the Settings page.
### Highlights
-- **Hierarchical Areas** -- Organize work items and household items by location (rooms, floors, zones) with parent-child nesting. Filtering by a parent area automatically includes all children.
-- **Trades** -- Define professional specialties (Electrical, Plumbing, Carpentry, etc.) and link vendors to their trade for easy identification.
-- **Quotation Invoice Status** -- Track vendor quotes alongside actual invoices. Quotation amounts use a +/- 5% margin in budget projections.
-- **Subsidy Caps** -- Set a maximum payout amount on percentage-based subsidies to prevent uncapped growth. The budget overview flags capped subsidies.
-- **Budget Filtering** -- Filter work items and household items by budget status ("no budget lines") and see budget line counts directly in list views.
-- **Action Button Dropdowns** -- Dashboard, Budget Overview, and Timeline pages now include quick-action dropdown menus for creating new entities without navigating away.
-- **Gantt Improvements** -- Ahead-of-schedule milestone display, bidirectional scroll sync between sidebar and chart, and responsive sidebar widths.
-- **Line Break Preservation** -- Descriptions and notes now preserve line breaks as entered.
-
-### Breaking Changes
-
-- The **tag system has been replaced** by areas and trades. Existing tags are migrated to areas automatically. Vendors previously associated with tags are now linked via trades. The tag management page has been replaced by the Manage page with separate Areas and Trades sections.
+- **DataTable across all list pages** -- Every list view now supports column-level filtering (text, enum, boolean, date range, number range, and entity filters), multi-column sorting, pagination, column reordering, and persistent column settings.
+- **Backup & Restore** -- Create manual backups or schedule automatic backups with a cron expression. Set a retention policy to automatically prune old backups. Restore from any backup directly in the Settings UI.
+- **Consistent page layout** -- All list pages share a standardized layout with unified navigation, giving the app a more cohesive feel.
+- **Translated category names** -- Predefined categories (trades, budget categories, household item categories) are now displayed in the user's language.
+- **DateRangePicker** -- Date range filters use a purpose-built calendar picker with range highlighting instead of native date inputs.
+- **UI improvements** -- Floating menu button, iPadOS Safari sidebar fix, visual cleanup across multiple pages.
diff --git a/client/package.json b/client/package.json
index 2714d6351..8a52ecfbf 100644
--- a/client/package.json
+++ b/client/package.json
@@ -11,7 +11,7 @@
},
"dependencies": {
"@cornerstone/shared": "*",
- "i18next": "25.8.18",
+ "i18next": "25.8.20",
"react": "19.2.4",
"react-dom": "19.2.4",
"react-i18next": "16.5.8",
@@ -31,7 +31,7 @@
"mini-css-extract-plugin": "2.10.1",
"style-loader": "4.0.0",
"webpack": "5.105.4",
- "webpack-cli": "7.0.0",
+ "webpack-cli": "7.0.2",
"webpack-dev-server": "5.2.3"
}
}
diff --git a/client/src/App.test.tsx b/client/src/App.test.tsx
index faf01461c..a6db1a14d 100644
--- a/client/src/App.test.tsx
+++ b/client/src/App.test.tsx
@@ -353,7 +353,7 @@ describe('App', () => {
});
});
- it('renders the AppShell layout with sidebar and header', async () => {
+ it('renders the AppShell layout with sidebar and floating menu button', async () => {
render( );
// Wait for auth loading to complete
@@ -365,9 +365,9 @@ describe('App', () => {
const sidebar = screen.getByRole('complementary');
expect(sidebar).toBeInTheDocument();
- // Header should be present
- const header = screen.getByRole('banner');
- expect(header).toBeInTheDocument();
+ // Floating menu button (FAB) should be present
+ const fab = screen.getByTestId('menu-fab');
+ expect(fab).toBeInTheDocument();
// Main content area should be present
const main = screen.getByRole('main');
@@ -402,14 +402,10 @@ describe('App', () => {
render( );
// /budget/categories now redirects to /settings/manage?tab=budget-categories
- // ManagePage renders an h1 heading of "Manage"
+ // ManagePage no longer renders an h1 heading — verify by the presence of the tab buttons
// Extended timeout: requires lazy-load of ManagePage after redirect from /budget/categories
- const heading = await screen.findByRole(
- 'heading',
- { name: /^manage$/i, level: 1 },
- { timeout: 5000 },
- );
- expect(heading).toBeInTheDocument();
+ const tab = await screen.findByRole('tab', { name: 'Budget Categories' }, { timeout: 5000 });
+ expect(tab).toBeInTheDocument();
});
it('navigates to Schedule page when /schedule/gantt path is accessed', async () => {
diff --git a/client/src/App.tsx b/client/src/App.tsx
index 8e5295092..0ed568239 100644
--- a/client/src/App.tsx
+++ b/client/src/App.tsx
@@ -82,6 +82,7 @@ const MilestoneDetailPage = lazy(() => import('./pages/MilestoneDetailPage/Miles
const ManagePage = lazy(() => import('./pages/ManagePage/ManagePage.js'));
const ProfilePage = lazy(() => import('./pages/ProfilePage/ProfilePage'));
const UserManagementPage = lazy(() => import('./pages/UserManagementPage/UserManagementPage'));
+const BackupsPage = lazy(() => import('./pages/BackupsPage/BackupsPage'));
const InvoicesPage = lazy(() => import('./pages/InvoicesPage/InvoicesPage'));
const InvoiceDetailPage = lazy(() => import('./pages/InvoiceDetailPage/InvoiceDetailPage'));
const DiaryPage = lazy(() => import('./pages/DiaryPage/DiaryPage'));
@@ -209,6 +210,7 @@ export function App() {
} />
} />
} />
+ } />
{/* Legacy redirects — preserve old bookmarks */}
diff --git a/client/src/components/AppShell/AppShell.module.css b/client/src/components/AppShell/AppShell.module.css
index ca077dd1c..f298a9ad5 100644
--- a/client/src/components/AppShell/AppShell.module.css
+++ b/client/src/components/AppShell/AppShell.module.css
@@ -38,3 +38,48 @@
display: block;
}
}
+
+.menuFab {
+ display: none;
+ position: fixed;
+ bottom: var(--spacing-6);
+ right: var(--spacing-6);
+ min-width: 44px;
+ min-height: 44px;
+ width: var(--spacing-12);
+ height: var(--spacing-12);
+ border-radius: var(--radius-full);
+ background-color: var(--color-primary);
+ color: var(--color-primary-text);
+ font-size: var(--font-size-2xl);
+ border: none;
+ cursor: pointer;
+ z-index: calc(var(--z-overlay) + 10);
+ box-shadow: var(--shadow-md);
+ transition: var(--transition-button);
+}
+
+.menuFab:hover {
+ background-color: var(--color-primary-hover);
+ box-shadow: var(--shadow-lg);
+}
+
+.menuFab:focus-visible {
+ outline: none;
+ box-shadow: var(--shadow-focus);
+}
+
+/* Mobile and tablet: show floating menu button */
+@media (max-width: 1024px) {
+ .menuFab {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ }
+}
+
+@media (prefers-reduced-motion: reduce) {
+ .menuFab {
+ transition: none;
+ }
+}
diff --git a/client/src/components/AppShell/AppShell.test.tsx b/client/src/components/AppShell/AppShell.test.tsx
index b3e21031d..a5e011636 100644
--- a/client/src/components/AppShell/AppShell.test.tsx
+++ b/client/src/components/AppShell/AppShell.test.tsx
@@ -3,7 +3,7 @@
*/
import { jest, describe, it, expect, beforeEach } from '@jest/globals';
import type React from 'react';
-import { screen, waitFor, within } from '@testing-library/react';
+import { screen, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { lazy } from 'react';
import { Route, Routes } from 'react-router-dom';
@@ -50,7 +50,7 @@ describe('AppShell', () => {
}
});
- it('renders sidebar, header, and outlet area', () => {
+ it('renders sidebar, floating menu button, and outlet area', () => {
renderWithRouter(
} path="*">
@@ -63,9 +63,9 @@ describe('AppShell', () => {
const sidebar = screen.getByRole('complementary');
expect(sidebar).toBeInTheDocument();
- // Header should be present
- const header = screen.getByRole('banner');
- expect(header).toBeInTheDocument();
+ // Floating menu button (FAB) should be present
+ const fab = screen.getByTestId('menu-fab');
+ expect(fab).toBeInTheDocument();
// Main content area should be present
const main = screen.getByRole('main');
@@ -133,7 +133,7 @@ describe('AppShell', () => {
expect(screen.getByRole('button', { name: /^settings$/i })).toBeInTheDocument();
});
- it('renders header with menu toggle button', () => {
+ it('renders floating menu button for mobile sidebar toggle', () => {
renderWithRouter(
} path="*">
@@ -142,8 +142,24 @@ describe('AppShell', () => {
,
);
- const menuButton = screen.getByRole('button', { name: /open menu/i });
- expect(menuButton).toBeInTheDocument();
+ const fab = screen.getByTestId('menu-fab');
+ expect(fab).toBeInTheDocument();
+ expect(fab).toHaveAttribute('type', 'button');
+ });
+
+ it('floating menu button has data-testid and type="button"', () => {
+ renderWithRouter(
+
+ } path="*">
+ Test Content} />
+
+ ,
+ );
+
+ const fab = screen.getByTestId('menu-fab');
+ expect(fab).toBeInTheDocument();
+ expect(fab).toHaveAttribute('type', 'button');
+ expect(fab).toHaveAttribute('aria-label', 'Open menu');
});
it('overlay is not visible initially (sidebar starts closed)', () => {
@@ -270,9 +286,9 @@ describe('AppShell', () => {
let overlay = document.querySelector('[data-testid="sidebar-overlay"]');
expect(overlay).toBeInTheDocument();
- // Header button should now say "Close menu"
- const header = screen.getByRole('banner');
- menuButton = within(header).getByRole('button', { name: /close menu/i });
+ // FAB button should now say "Close menu"
+ menuButton = screen.getByTestId('menu-fab');
+ expect(menuButton).toHaveAttribute('aria-label', 'Close menu');
// Close
await user.click(menuButton);
@@ -298,47 +314,16 @@ describe('AppShell', () => {
,
);
- // Initially button shows hamburger icon
- let menuButton = screen.getByRole('button', { name: /open menu/i });
- expect(menuButton).toHaveTextContent('☰');
+ // Initially FAB shows hamburger icon
+ const fab = screen.getByTestId('menu-fab');
+ expect(fab).toHaveTextContent('☰');
// Click to open
- await user.click(menuButton);
+ await user.click(fab);
- // Header button should now show close icon
- const header = screen.getByRole('banner');
- menuButton = within(header).getByRole('button', { name: /close menu/i });
- expect(menuButton).toHaveTextContent('✕');
- });
-
- it('clicking close button inside sidebar closes the sidebar', async () => {
- const user = userEvent.setup();
- renderWithRouter(
-
- } path="*">
- Test Content} />
-
- ,
- );
-
- // Open sidebar
- const menuButton = screen.getByRole('button', { name: /open menu/i });
- await user.click(menuButton);
-
- // Sidebar should be open
- const sidebar = screen.getByRole('complementary');
- expect(sidebar.className).toMatch(/open/);
-
- // Click the close button inside the sidebar
- const closeButton = within(sidebar).getByRole('button', { name: /close menu/i });
- await user.click(closeButton);
-
- // Sidebar should be closed
- expect(sidebar.className).not.toMatch(/open/);
-
- // Overlay should be removed
- const overlay = document.querySelector('[data-testid="sidebar-overlay"]');
- expect(overlay).not.toBeInTheDocument();
+ // FAB button should now show close icon
+ const fabAfterOpen = screen.getByTestId('menu-fab');
+ expect(fabAfterOpen).toHaveTextContent('✕');
});
it('menu button aria-label changes from "Open menu" to "Close menu" when sidebar opens', async () => {
@@ -351,16 +336,15 @@ describe('AppShell', () => {
,
);
- // Initially button has "Open menu" label
- let menuButton = screen.getByRole('button', { name: /open menu/i });
- expect(menuButton).toHaveAttribute('aria-label', 'Open menu');
+ // Initially FAB has "Open menu" label
+ const fab = screen.getByTestId('menu-fab');
+ expect(fab).toHaveAttribute('aria-label', 'Open menu');
// Click to open
- await user.click(menuButton);
+ await user.click(fab);
- // Header button should now have "Close menu" label
- const header = screen.getByRole('banner');
- menuButton = within(header).getByRole('button', { name: /close menu/i });
- expect(menuButton).toHaveAttribute('aria-label', 'Close menu');
+ // FAB button should now have "Close menu" label
+ const fabAfterOpen = screen.getByTestId('menu-fab');
+ expect(fabAfterOpen).toHaveAttribute('aria-label', 'Close menu');
});
});
diff --git a/client/src/components/AppShell/AppShell.tsx b/client/src/components/AppShell/AppShell.tsx
index c2c681d54..79875c1f0 100644
--- a/client/src/components/AppShell/AppShell.tsx
+++ b/client/src/components/AppShell/AppShell.tsx
@@ -1,10 +1,11 @@
import { Outlet } from 'react-router-dom';
import { Suspense, useState, useCallback, useEffect } from 'react';
-import { Header } from '../Header/Header';
+import { useTranslation } from 'react-i18next';
import { Sidebar } from '../Sidebar/Sidebar';
import styles from './AppShell.module.css';
export function AppShell() {
+ const { t } = useTranslation('common');
const [isSidebarOpen, setIsSidebarOpen] = useState(false);
const handleToggleSidebar = useCallback(() => {
@@ -39,8 +40,16 @@ export function AppShell() {
data-testid="sidebar-overlay"
/>
)}
+
+ {isSidebarOpen ? '✕' : '☰'}
+
-
Loading...
}>
diff --git a/client/src/components/Badge/Badge.module.css b/client/src/components/Badge/Badge.module.css
index 56edfad74..abd6f5ce3 100644
--- a/client/src/components/Badge/Badge.module.css
+++ b/client/src/components/Badge/Badge.module.css
@@ -86,6 +86,62 @@
color: var(--color-diary-severity-critical-text);
}
+/* Milestone status variants */
+.milestoneCompleted {
+ background-color: var(--color-status-completed-bg);
+ color: var(--color-status-completed-text);
+}
+
+.milestonePending {
+ background-color: var(--color-status-not-started-bg);
+ color: var(--color-status-not-started-text);
+}
+
+/* Invoice status variants */
+.pending {
+ background-color: var(--color-status-not-started-bg);
+ color: var(--color-status-not-started-text);
+}
+
+.paid {
+ background-color: var(--color-status-completed-bg);
+ color: var(--color-status-completed-text);
+}
+
+.claimed {
+ background-color: var(--color-status-in-progress-bg);
+ color: var(--color-status-in-progress-text);
+}
+
+.quotation {
+ background-color: var(--color-status-blocked-bg);
+ color: var(--color-status-blocked-text);
+}
+
+/* Role badge: Admin */
+.roleAdmin {
+ background-color: var(--color-role-admin-bg);
+ color: var(--color-role-admin-text);
+}
+
+/* Role badge: Member */
+.roleMember {
+ background-color: var(--color-role-member-bg);
+ color: var(--color-role-member-text);
+}
+
+/* User status badge: Active */
+.userActive {
+ background-color: var(--color-user-active-bg);
+ color: var(--color-user-active-text);
+}
+
+/* User status badge: Deactivated/Inactive */
+.userDeactivated {
+ background-color: var(--color-user-inactive-bg);
+ color: var(--color-user-inactive-text);
+}
+
/* Responsive */
@media (max-width: 767px) {
.badge {
diff --git a/client/src/components/BudgetSubNav/BudgetSubNav.tsx b/client/src/components/BudgetSubNav/BudgetSubNav.tsx
deleted file mode 100644
index 8d7933f6c..000000000
--- a/client/src/components/BudgetSubNav/BudgetSubNav.tsx
+++ /dev/null
@@ -1,42 +0,0 @@
-import { NavLink } from 'react-router-dom';
-import { useTranslation } from 'react-i18next';
-import styles from './BudgetSubNav.module.css';
-
-const BUDGET_TABS = [
- { labelKey: 'subnav.budget.overview', to: '/budget/overview' },
- { labelKey: 'subnav.budget.invoices', to: '/budget/invoices' },
- { labelKey: 'subnav.budget.vendors', to: '/budget/vendors' },
- { labelKey: 'subnav.budget.sources', to: '/budget/sources' },
- { labelKey: 'subnav.budget.subsidies', to: '/budget/subsidies' },
-] as const;
-
-/**
- * BudgetSubNav — horizontal tab-style navigation for the Budget section.
- *
- * Renders a scrollable row of tab links for all budget sub-pages.
- * The currently active tab is highlighted using the primary design token.
- * On mobile the row scrolls horizontally so all tabs remain reachable.
- */
-export function BudgetSubNav() {
- const { t } = useTranslation('common');
-
- return (
-
-
- {BUDGET_TABS.map((tab) => (
- `${styles.tab} ${isActive ? styles.tabActive : ''}`}
- role="listitem"
- >
- {t(tab.labelKey)}
-
- ))}
-
-
- );
-}
-
-export default BudgetSubNav;
diff --git a/client/src/components/BudgetSummaryCard/BudgetSummaryCard.test.tsx b/client/src/components/BudgetSummaryCard/BudgetSummaryCard.test.tsx
index 4dd68bedf..6570545f8 100644
--- a/client/src/components/BudgetSummaryCard/BudgetSummaryCard.test.tsx
+++ b/client/src/components/BudgetSummaryCard/BudgetSummaryCard.test.tsx
@@ -108,13 +108,17 @@ describe('BudgetSummaryCard', () => {
// ── Test 1: Remaining Budget ──────────────────────────────────────────────
- it('renders remaining budget formatted as EUR currency', () => {
+ it('renders remaining budget as the average of remainingVsMinPlanned and remainingVsMaxPlanned', () => {
+ // mediumNetRemaining = (remainingVsMinPlanned + remainingVsMaxPlanned) / 2
+ // = (20000 + 10000) / 2 = 15000
renderWithRouter(
- ,
+ ,
);
const el = screen.getByTestId('remaining-budget');
- expect(el).toHaveTextContent('€50,000.00');
+ expect(el).toHaveTextContent('€15,000.00');
});
// ── Test 2: Planned Cost Range ────────────────────────────────────────────
diff --git a/client/src/components/BudgetSummaryCard/BudgetSummaryCard.tsx b/client/src/components/BudgetSummaryCard/BudgetSummaryCard.tsx
index 69aadc848..05c58ea77 100644
--- a/client/src/components/BudgetSummaryCard/BudgetSummaryCard.tsx
+++ b/client/src/components/BudgetSummaryCard/BudgetSummaryCard.tsx
@@ -19,11 +19,14 @@ export function BudgetSummaryCard({ overview }: BudgetSummaryCardProps) {
minPlanned,
maxPlanned,
actualCost,
- remainingVsActualCost,
+ remainingVsMinPlanned,
remainingVsMaxPlanned,
subsidySummary,
} = overview;
+ // Medium net remaining: average of min and max remaining
+ const mediumNetRemaining = (remainingVsMinPlanned + remainingVsMaxPlanned) / 2;
+
// Build budget bar segments
const segments: BudgetBarSegment[] = [
{
@@ -35,16 +38,16 @@ export function BudgetSummaryCard({ overview }: BudgetSummaryCardProps) {
];
// Add remaining segment or overflow
- if (remainingVsActualCost >= 0) {
+ if (mediumNetRemaining >= 0) {
segments.push({
key: 'remaining',
- value: remainingVsActualCost,
+ value: mediumNetRemaining,
color: 'var(--color-budget-track)',
label: t('cards.budgetSummary.remainingBudget'),
});
}
- const overflow = remainingVsActualCost < 0 ? Math.abs(remainingVsActualCost) : 0;
+ const overflow = mediumNetRemaining < 0 ? Math.abs(mediumNetRemaining) : 0;
return (
@@ -52,7 +55,7 @@ export function BudgetSummaryCard({ overview }: BudgetSummaryCardProps) {
{t('cards.budgetSummary.remainingBudget')}
- {formatCurrency(remainingVsActualCost)}
+ {formatCurrency(mediumNetRemaining)}
diff --git a/client/src/components/CostBreakdownTable/CostBreakdownTable.test.tsx b/client/src/components/CostBreakdownTable/CostBreakdownTable.test.tsx
index 518b1e745..fa78d811e 100644
--- a/client/src/components/CostBreakdownTable/CostBreakdownTable.test.tsx
+++ b/client/src/components/CostBreakdownTable/CostBreakdownTable.test.tsx
@@ -338,6 +338,7 @@ function buildBreakdownWithWI(
categoryId,
categoryName,
categoryColor: null,
+ categoryTranslationKey: null,
projectedMin,
projectedMax,
actualCost,
@@ -444,6 +445,8 @@ function buildBreakdownWithHI(
categories: [
{
hiCategory,
+ categoryName: hiCategory,
+ categoryTranslationKey: null,
projectedMin,
projectedMax,
actualCost,
@@ -841,6 +844,7 @@ describe('CostBreakdownTable', () => {
categoryId: catId,
categoryName: 'Materials',
categoryColor: null,
+ categoryTranslationKey: null,
projectedMin: 800,
projectedMax: 1200,
actualCost: 0,
@@ -854,6 +858,7 @@ describe('CostBreakdownTable', () => {
categoryId: 'cat-labor',
categoryName: 'Labor',
categoryColor: null,
+ categoryTranslationKey: null,
projectedMin: 1000,
projectedMax: 1500,
actualCost: 0,
@@ -913,6 +918,7 @@ describe('CostBreakdownTable', () => {
categoryId: 'cat-a',
categoryName: 'CategoryA',
categoryColor: null,
+ categoryTranslationKey: null,
projectedMin: 100,
projectedMax: 200,
actualCost: 0,
@@ -926,6 +932,7 @@ describe('CostBreakdownTable', () => {
categoryId: 'cat-b',
categoryName: 'CategoryB',
categoryColor: null,
+ categoryTranslationKey: null,
projectedMin: 300,
projectedMax: 400,
actualCost: 0,
@@ -986,6 +993,7 @@ describe('CostBreakdownTable', () => {
categoryId: 'cat-x',
categoryName: 'CategoryX',
categoryColor: null,
+ categoryTranslationKey: null,
projectedMin: 100,
projectedMax: 200,
actualCost: 0,
@@ -1010,6 +1018,8 @@ describe('CostBreakdownTable', () => {
categories: [
{
hiCategory: 'Living Room',
+ categoryName: 'Living Room',
+ categoryTranslationKey: null,
projectedMin: 300,
projectedMax: 500,
actualCost: 0,
@@ -1314,6 +1324,7 @@ describe('CostBreakdownTable', () => {
categoryId: 'cat-1',
categoryName: 'Materials',
categoryColor: null,
+ categoryTranslationKey: null,
projectedMin: 500,
projectedMax: 700,
actualCost: 0,
@@ -1338,6 +1349,8 @@ describe('CostBreakdownTable', () => {
categories: [
{
hiCategory: 'Living Room',
+ categoryName: 'Living Room',
+ categoryTranslationKey: null,
projectedMin: 200,
projectedMax: 300,
actualCost: 0,
@@ -2566,6 +2579,7 @@ describe('Bug #586 — item expand state is independent per category', () => {
rawProjectedMax: 700,
minSubsidyPayback: 0,
categoryColor: null as null,
+ categoryTranslationKey: null as null,
};
return {
@@ -2646,6 +2660,7 @@ describe('Bug #586 — item expand state is independent per category', () => {
rawProjectedMin: 300,
rawProjectedMax: 500,
minSubsidyPayback: 0,
+ categoryTranslationKey: null as null,
};
return {
@@ -2666,11 +2681,13 @@ describe('Bug #586 — item expand state is independent per category', () => {
{
...hiCategoryBase,
hiCategory: 'Furniture',
+ categoryName: 'Furniture',
items: [{ ...sharedHIItem }],
},
{
...hiCategoryBase,
hiCategory: 'Appliances',
+ categoryName: 'Appliances',
items: [{ ...sharedHIItem }],
},
],
diff --git a/client/src/components/CostBreakdownTable/CostBreakdownTable.tsx b/client/src/components/CostBreakdownTable/CostBreakdownTable.tsx
index 29aeceb3a..abad9988b 100644
--- a/client/src/components/CostBreakdownTable/CostBreakdownTable.tsx
+++ b/client/src/components/CostBreakdownTable/CostBreakdownTable.tsx
@@ -15,6 +15,7 @@ import type {
} from '@cornerstone/shared';
import { CONFIDENCE_MARGINS } from '@cornerstone/shared';
import { useFormatters } from '../../lib/formatters.js';
+import { getCategoryDisplayName } from '../../lib/categoryUtils.js';
import styles from './CostBreakdownTable.module.css';
// Context to pass formatCurrency down to sub-components that aren't React components (can't use hooks)
@@ -311,6 +312,7 @@ function WorkItemCategorySection({
onToggle: (key: string) => void;
perspective: CostPerspective;
}) {
+ const { t: tSettings } = useTranslation('settings');
const formatCurrencyFn = useFormatterContext();
const key = `wi-cat-${category.categoryId ?? 'null'}`;
const isExpanded = expandedKeys.has(key);
@@ -324,6 +326,11 @@ function WorkItemCategorySection({
category.subsidyPayback,
perspective,
);
+ const displayName = getCategoryDisplayName(
+ tSettings,
+ category.categoryName,
+ category.categoryTranslationKey,
+ );
return (
<>
@@ -334,12 +341,12 @@ function WorkItemCategorySection({
type="button"
className={styles.expandBtn}
aria-expanded={isExpanded}
- aria-label={`Expand ${category.categoryName}`}
+ aria-label={`Expand ${displayName}`}
onClick={() => onToggle(key)}
>
-
{category.categoryName}
+
{displayName}
-{formatCurrencyFn(resolvedRawCost)}
@@ -473,6 +480,7 @@ function HouseholdItemCategorySection({
onToggle: (key: string) => void;
perspective: CostPerspective;
}) {
+ const { t: tSettings } = useTranslation('settings');
const formatCurrencyFn = useFormatterContext();
const key = `hi-cat-${category.hiCategory}`;
const isExpanded = expandedKeys.has(key);
@@ -486,6 +494,11 @@ function HouseholdItemCategorySection({
category.subsidyPayback,
perspective,
);
+ const displayName = getCategoryDisplayName(
+ tSettings,
+ category.categoryName,
+ category.categoryTranslationKey,
+ );
return (
<>
@@ -496,12 +509,12 @@ function HouseholdItemCategorySection({
type="button"
className={styles.expandBtn}
aria-expanded={isExpanded}
- aria-label={`Expand ${category.hiCategory}`}
+ aria-label={`Expand ${displayName}`}
onClick={() => onToggle(key)}
>
- {category.hiCategory}
+ {displayName}
-{formatCurrencyFn(resolvedRawCost)}
diff --git a/client/src/components/DataTable/DataTable.module.css b/client/src/components/DataTable/DataTable.module.css
new file mode 100644
index 000000000..67a9154c7
--- /dev/null
+++ b/client/src/components/DataTable/DataTable.module.css
@@ -0,0 +1,710 @@
+/* DataTable Container */
+.dataTableContainer {
+ display: flex;
+ flex-direction: column;
+ gap: var(--spacing-4);
+}
+
+/* Toolbar */
+.toolbar {
+ display: flex;
+ flex-direction: column;
+ gap: var(--spacing-3);
+ padding: var(--spacing-4);
+ background-color: var(--color-bg-primary);
+ border: 1px solid var(--color-border);
+ border-radius: var(--radius-lg);
+ box-shadow: var(--shadow-sm);
+}
+
+.toolbarRow {
+ display: flex;
+ gap: var(--spacing-3);
+ align-items: center;
+ flex-wrap: wrap;
+}
+
+.searchBox {
+ flex: 1;
+ min-width: 200px;
+}
+
+.searchInput {
+ width: 100%;
+ height: 36px;
+ padding: var(--spacing-2) var(--spacing-3);
+ border: 1px solid var(--color-border-strong);
+ border-radius: var(--radius-md);
+ font-size: var(--font-size-sm);
+ color: var(--color-text-primary);
+ background-color: var(--color-bg-primary);
+ transition: var(--transition-input);
+ box-sizing: border-box;
+}
+
+.searchInput:focus-visible {
+ outline: none;
+ border-color: var(--color-primary);
+ box-shadow: var(--shadow-focus-subtle);
+}
+
+.toolbarButtons {
+ display: flex;
+ gap: var(--spacing-2);
+ align-items: center;
+}
+
+.resetButton {
+ height: 36px;
+ padding: 0 var(--spacing-3);
+ background-color: var(--color-bg-tertiary);
+ color: var(--color-text-secondary);
+ border: 1px solid var(--color-border-strong);
+ border-radius: var(--radius-md);
+ font-size: var(--font-size-xs);
+ font-weight: var(--font-weight-medium);
+ cursor: pointer;
+ transition: background-color var(--transition-normal);
+ box-sizing: border-box;
+}
+
+.resetButton:hover:not(:disabled) {
+ background-color: var(--color-bg-hover);
+}
+
+.resetButton:focus-visible {
+ outline: none;
+ box-shadow: var(--shadow-focus);
+}
+
+/* Column Settings Button */
+.columnSettingsButton {
+ height: 36px;
+ width: 36px;
+ padding: 0;
+ background-color: var(--color-bg-tertiary);
+ color: var(--color-text-secondary);
+ border: 1px solid var(--color-border-strong);
+ border-radius: var(--radius-md);
+ font-size: var(--font-size-sm);
+ cursor: pointer;
+ transition:
+ background-color var(--transition-normal),
+ color var(--transition-normal);
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ box-sizing: border-box;
+}
+
+.columnSettingsButton:hover:not(:disabled) {
+ background-color: var(--color-bg-hover);
+}
+
+.columnSettingsButton:focus-visible {
+ outline: none;
+ box-shadow: var(--shadow-focus);
+}
+
+.columnSettingsPopover {
+ background-color: var(--color-bg-primary);
+ border: 1px solid var(--color-border);
+ border-radius: var(--radius-lg);
+ box-shadow: var(--shadow-xl);
+ z-index: var(--z-modal);
+}
+
+.columnSettingsContent {
+ padding: var(--spacing-3);
+ display: flex;
+ flex-direction: column;
+ gap: var(--spacing-3);
+}
+
+.columnSettingsTitle {
+ font-size: var(--font-size-sm);
+ font-weight: var(--font-weight-semibold);
+ color: var(--color-text-primary);
+ margin: 0;
+}
+
+.columnCheckboxGroup {
+ display: flex;
+ flex-direction: column;
+ gap: var(--spacing-1-5);
+ max-height: 300px;
+ overflow-y: auto;
+}
+
+.columnCheckboxItem {
+ display: flex;
+ align-items: center;
+ gap: var(--spacing-2);
+ padding: var(--spacing-1) var(--spacing-2);
+ border-radius: var(--radius-sm);
+ cursor: pointer;
+ transition: background-color var(--transition-normal);
+}
+
+.columnCheckboxItem:hover {
+ background-color: var(--color-bg-secondary);
+}
+
+.columnCheckbox {
+ width: 18px;
+ height: 18px;
+ border: 1px solid var(--color-border-strong);
+ border-radius: var(--radius-sm);
+ cursor: pointer;
+ accent-color: var(--color-primary);
+}
+
+.columnCheckbox:focus-visible {
+ outline: none;
+ box-shadow: var(--shadow-focus);
+}
+
+.columnCheckboxLabel {
+ font-size: var(--font-size-sm);
+ color: var(--color-text-secondary);
+ flex: 1;
+ cursor: pointer;
+}
+
+.columnSettingsResetButton {
+ padding: var(--spacing-1-5) var(--spacing-3);
+ background-color: var(--color-bg-tertiary);
+ color: var(--color-text-secondary);
+ border: 1px solid var(--color-border-strong);
+ border-radius: var(--radius-md);
+ font-size: var(--font-size-xs);
+ font-weight: var(--font-weight-medium);
+ cursor: pointer;
+ transition: background-color var(--transition-normal);
+ width: 100%;
+}
+
+.columnSettingsResetButton:hover:not(:disabled) {
+ background-color: var(--color-bg-hover);
+}
+
+.columnSettingsResetButton:focus-visible {
+ outline: none;
+ box-shadow: var(--shadow-focus);
+}
+
+.columnDragHandle {
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ width: 24px;
+ height: 24px;
+ padding: 0;
+ background-color: transparent;
+ color: var(--color-text-muted);
+ border: none;
+ border-radius: var(--radius-sm);
+ cursor: grab;
+ font-size: var(--font-size-sm);
+ transition: background-color var(--transition-normal);
+}
+
+.columnDragHandle:hover {
+ background-color: var(--color-bg-tertiary);
+ color: var(--color-text-secondary);
+}
+
+.columnDragHandle:focus-visible {
+ outline: none;
+ box-shadow: var(--shadow-focus);
+}
+
+.columnDragHandle:active {
+ cursor: grabbing;
+}
+
+.columnCheckboxItemDragging {
+ opacity: 0.5;
+ background-color: var(--color-bg-secondary);
+}
+
+.columnCheckboxItemDropAbove {
+ position: relative;
+}
+
+.columnCheckboxItemDropAbove::before {
+ content: '';
+ position: absolute;
+ top: 0;
+ left: 0;
+ right: 0;
+ height: 2px;
+ background-color: var(--color-primary);
+ border-radius: var(--radius-sm);
+}
+
+.columnCheckboxItemDropBelow {
+ position: relative;
+}
+
+.columnCheckboxItemDropBelow::before {
+ content: '';
+ position: absolute;
+ bottom: 0;
+ left: 0;
+ right: 0;
+ height: 2px;
+ background-color: var(--color-primary);
+ border-radius: var(--radius-sm);
+}
+
+@media (prefers-reduced-motion: reduce) {
+ .columnDragHandle,
+ .columnCheckboxItem {
+ transition: none;
+ }
+}
+
+/* Filter Popover */
+.filterPopover {
+ background-color: var(--color-bg-primary);
+ border: 1px solid var(--color-border);
+ border-radius: var(--radius-lg);
+ box-shadow: var(--shadow-xl);
+ z-index: calc(var(--z-modal) + 1);
+}
+
+/* Table Container */
+.tableContainer {
+ display: block;
+ background-color: var(--color-bg-primary);
+ border: 1px solid var(--color-border);
+ border-radius: var(--radius-lg);
+ box-shadow: var(--shadow-sm);
+ overflow-x: auto;
+}
+
+/* Table */
+.table {
+ width: 100%;
+ border-collapse: collapse;
+ font-size: var(--font-size-sm);
+}
+
+.table thead {
+ background-color: var(--color-bg-secondary);
+ border-bottom: 1px solid var(--color-border);
+}
+
+.tableHeader {
+ padding: var(--spacing-3) var(--spacing-4);
+ text-align: left;
+ font-size: var(--font-size-xs);
+ font-weight: var(--font-weight-semibold);
+ color: var(--color-text-muted);
+ text-transform: uppercase;
+ letter-spacing: 0.05em;
+ white-space: nowrap;
+ position: relative;
+}
+
+.tableHeaderContent {
+ display: flex;
+ align-items: center;
+ gap: var(--spacing-2);
+}
+
+.tableHeaderLabel {
+ flex: 1;
+}
+
+.tableHeaderSortable {
+ cursor: pointer;
+ user-select: none;
+ transition: color var(--transition-normal);
+}
+
+.tableHeaderSortable:hover {
+ color: var(--color-primary);
+}
+
+.tableHeaderFilterButton {
+ padding: var(--spacing-1) var(--spacing-1-5);
+ background-color: transparent;
+ color: var(--color-text-muted);
+ border: 1px solid var(--color-border);
+ border-radius: var(--radius-sm);
+ font-size: var(--font-size-xs);
+ cursor: pointer;
+ transition:
+ background-color var(--transition-normal),
+ color var(--transition-normal),
+ border-color var(--transition-normal);
+ flex-shrink: 0;
+}
+
+.tableHeaderFilterButton:hover {
+ background-color: var(--color-bg-tertiary);
+ color: var(--color-text-primary);
+}
+
+.tableHeaderFilterButton:focus-visible {
+ outline: none;
+ box-shadow: var(--shadow-focus);
+}
+
+.tableHeaderFilterButton svg {
+ display: block;
+ width: 12px;
+ height: 12px;
+}
+
+.tableHeaderFilterButtonActive {
+ background-color: var(--color-primary-bg);
+ color: var(--color-primary);
+ border-color: var(--color-primary);
+}
+
+/* Table Rows */
+.table tbody tr {
+ border-bottom: 1px solid var(--color-border);
+ transition: background-color var(--transition-normal);
+}
+
+.table tbody tr:last-child {
+ border-bottom: none;
+}
+
+.tableRow {
+ cursor: pointer;
+}
+
+.tableRow:hover {
+ background-color: var(--color-bg-secondary);
+}
+
+.tableRow:focus-visible {
+ outline: none;
+ box-shadow: var(--shadow-focus);
+}
+
+.tableRowSelected {
+ background-color: var(--color-primary-bg);
+}
+
+.tableRowSelected:hover {
+ background-color: var(--color-primary-bg-hover);
+}
+
+/* Table Cells */
+.table td {
+ padding: var(--spacing-3) var(--spacing-4);
+ color: var(--color-text-secondary);
+ vertical-align: middle;
+}
+
+.tableCell {
+ word-break: break-word;
+}
+
+.tableActionsCell {
+ text-align: center;
+ padding: var(--spacing-2) var(--spacing-3);
+}
+
+/* Cards (Mobile) */
+.cardsContainer {
+ display: none;
+ flex-direction: column;
+ gap: var(--spacing-4);
+}
+
+.card {
+ background-color: var(--color-bg-primary);
+ border: 1px solid var(--color-border);
+ border-radius: var(--radius-lg);
+ box-shadow: var(--shadow-sm);
+ padding: var(--spacing-4);
+ cursor: pointer;
+ transition:
+ box-shadow var(--transition-normal),
+ border-color var(--transition-normal);
+}
+
+.card:hover {
+ box-shadow: var(--shadow-md);
+}
+
+.card:focus-visible {
+ outline: none;
+ box-shadow: var(--shadow-focus);
+}
+
+.cardHeader {
+ display: flex;
+ justify-content: space-between;
+ align-items: flex-start;
+ gap: var(--spacing-3);
+}
+
+.cardContent {
+ flex: 1;
+ display: flex;
+ flex-direction: column;
+ gap: var(--spacing-3);
+}
+
+.cardRow {
+ display: flex;
+ align-items: center;
+ gap: var(--spacing-2);
+ padding: var(--spacing-2) 0;
+ border-bottom: 1px solid var(--color-border);
+}
+
+.cardRow:last-child {
+ border-bottom: none;
+ padding-bottom: 0;
+}
+
+.cardLabel {
+ font-weight: var(--font-weight-medium);
+ color: var(--color-text-muted);
+ font-size: var(--font-size-xs);
+ text-transform: uppercase;
+ letter-spacing: 0.025em;
+ min-width: 100px;
+ flex-shrink: 0;
+}
+
+.cardValue {
+ flex: 1;
+ color: var(--color-text-secondary);
+ word-break: break-word;
+}
+
+.cardActions {
+ flex-shrink: 0;
+}
+
+/* Loading State */
+.loading {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ min-height: 300px;
+ color: var(--color-text-muted);
+ font-size: var(--font-size-sm);
+}
+
+/* Error Banner */
+.errorBanner {
+ background-color: var(--color-danger-bg);
+ border: 1px solid var(--color-danger-border);
+ border-radius: var(--radius-lg);
+ padding: var(--spacing-4);
+ color: var(--color-danger);
+ font-size: var(--font-size-sm);
+ margin-bottom: var(--spacing-4);
+}
+
+/* Empty State */
+.emptyState {
+ padding: var(--spacing-8);
+ text-align: center;
+ background-color: var(--color-bg-primary);
+ border: 1px solid var(--color-border);
+ border-radius: var(--radius-lg);
+ box-shadow: var(--shadow-sm);
+}
+
+.emptyStateHeading {
+ font-size: var(--font-size-lg);
+ font-weight: var(--font-weight-semibold);
+ color: var(--color-text-primary);
+ margin: 0 0 var(--spacing-2) 0;
+}
+
+.emptyStateText {
+ color: var(--color-text-muted);
+ margin: 0 0 var(--spacing-4) 0;
+ font-size: var(--font-size-sm);
+}
+
+/* Pagination */
+.pagination {
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ margin-top: var(--spacing-4);
+ padding-top: var(--spacing-3);
+ border-top: 1px solid var(--color-border);
+ gap: var(--spacing-3);
+}
+
+.paginationInfo {
+ font-size: var(--font-size-xs);
+ color: var(--color-text-muted);
+}
+
+.paginationControls {
+ display: flex;
+ align-items: center;
+ gap: var(--spacing-2);
+}
+
+.paginationPages {
+ display: flex;
+ gap: var(--spacing-1);
+}
+
+.paginationButton {
+ padding: var(--spacing-1-5) var(--spacing-2-5);
+ background-color: var(--color-bg-primary);
+ border: 1px solid var(--color-border-strong);
+ border-radius: var(--radius-md);
+ font-size: var(--font-size-xs);
+ font-weight: var(--font-weight-medium);
+ color: var(--color-text-secondary);
+ cursor: pointer;
+ transition:
+ background-color var(--transition-normal),
+ color var(--transition-normal);
+ min-width: 44px;
+ min-height: 44px;
+}
+
+.paginationButton:hover:not(:disabled) {
+ background-color: var(--color-bg-tertiary);
+ border-color: var(--color-text-placeholder);
+}
+
+.paginationButton:focus-visible {
+ outline: none;
+ box-shadow: var(--shadow-focus);
+}
+
+.paginationButton:disabled {
+ color: var(--color-text-placeholder);
+ cursor: not-allowed;
+}
+
+.paginationButtonActive {
+ background-color: var(--color-primary);
+ border-color: var(--color-primary);
+ color: var(--color-primary-text);
+}
+
+.paginationButtonActive:hover {
+ background-color: var(--color-primary-hover);
+ border-color: var(--color-primary-hover);
+}
+
+.paginationPageSize {
+ display: flex;
+ align-items: center;
+ gap: var(--spacing-2);
+}
+
+.pageSizeLabel {
+ font-size: var(--font-size-xs);
+ font-weight: var(--font-weight-medium);
+ color: var(--color-text-muted);
+ white-space: nowrap;
+}
+
+.pageSizeSelect {
+ padding: var(--spacing-1) var(--spacing-2);
+ border: 1px solid var(--color-border-strong);
+ border-radius: var(--radius-md);
+ font-size: var(--font-size-xs);
+ color: var(--color-text-primary);
+ background-color: var(--color-bg-primary);
+ cursor: pointer;
+ transition: border-color var(--transition-normal);
+}
+
+.pageSizeSelect:focus-visible {
+ outline: none;
+ border-color: var(--color-primary);
+ box-shadow: var(--shadow-focus-subtle);
+}
+
+/* Responsive Design */
+@media (max-width: 767px) {
+ .tableContainer {
+ display: none;
+ }
+
+ .cardsContainer {
+ display: flex;
+ }
+
+ .toolbar {
+ padding: var(--spacing-3);
+ gap: var(--spacing-2);
+ }
+
+ .toolbarRow {
+ flex-direction: column;
+ gap: var(--spacing-2);
+ }
+
+ .searchBox {
+ min-width: auto;
+ }
+
+ .toolbarButtons {
+ width: 100%;
+ flex-direction: row;
+ }
+
+ .columnSettingsButton {
+ display: none;
+ }
+
+ .pagination {
+ flex-direction: column;
+ align-items: stretch;
+ gap: var(--spacing-2);
+ }
+
+ .paginationInfo {
+ text-align: center;
+ }
+
+ .paginationControls {
+ flex-direction: column;
+ width: 100%;
+ }
+
+ .paginationPages {
+ justify-content: center;
+ width: 100%;
+ }
+
+ .paginationPageSize {
+ width: 100%;
+ justify-content: center;
+ }
+}
+
+@media (min-width: 768px) and (max-width: 1024px) {
+ .columnSettingsButton {
+ display: flex;
+ }
+}
+
+@media (prefers-reduced-motion: reduce) {
+ .table tbody tr,
+ .tableHeaderSortable,
+ .tableHeaderFilterButton,
+ .card,
+ .paginationButton,
+ .resetButton,
+ .columnSettingsButton,
+ .pageSizeSelect,
+ .searchInput {
+ transition: none;
+ }
+}
diff --git a/client/src/components/DataTable/DataTable.test.tsx b/client/src/components/DataTable/DataTable.test.tsx
new file mode 100644
index 000000000..bb709f3d0
--- /dev/null
+++ b/client/src/components/DataTable/DataTable.test.tsx
@@ -0,0 +1,343 @@
+import { describe, it, expect, jest, beforeEach } from '@jest/globals';
+import { render, screen, fireEvent } from '@testing-library/react';
+import userEvent from '@testing-library/user-event';
+
+interface TestItem {
+ id: string;
+ title: string;
+ amount: number;
+}
+
+// Mock useColumnPreferences to return all columns as visible by default
+const mockToggleColumn = jest.fn();
+const mockMoveColumn = jest.fn();
+const mockResetToDefaults = jest.fn();
+const mockUseColumnPreferences = jest.fn();
+
+jest.unstable_mockModule('../../hooks/useColumnPreferences.js', () => ({
+ useColumnPreferences: mockUseColumnPreferences,
+}));
+
+import type * as DataTableModule from './DataTable.js';
+
+let DataTable: (typeof DataTableModule)['DataTable'];
+type TableState = DataTableModule.TableState;
+
+const COLUMNS: DataTableModule.ColumnDef[] = [
+ { key: 'title', label: 'Title', defaultVisible: true, render: (i) => i.title },
+ { key: 'amount', label: 'Amount', defaultVisible: true, render: (i) => String(i.amount) },
+ { key: 'id', label: 'ID', defaultVisible: true, render: (i) => i.id },
+];
+
+const SAMPLE_ITEMS: TestItem[] = [
+ { id: 'item-1', title: 'Alpha Work', amount: 1000 },
+ { id: 'item-2', title: 'Beta Work', amount: 2000 },
+ { id: 'item-3', title: 'Gamma Work', amount: 3000 },
+];
+
+function makeTableState(overrides: Partial = {}): TableState {
+ return {
+ search: '',
+ filters: new Map(),
+ sortBy: null,
+ sortDir: null,
+ page: 1,
+ pageSize: 25,
+ ...overrides,
+ };
+}
+
+function renderDataTable({
+ items = SAMPLE_ITEMS,
+ totalItems = SAMPLE_ITEMS.length,
+ totalPages = 1,
+ currentPage = 1,
+ isLoading = false,
+ error = null,
+ tableState = makeTableState(),
+ onStateChange = jest.fn(),
+ onRowClick,
+ emptyState,
+}: {
+ items?: TestItem[];
+ totalItems?: number;
+ totalPages?: number;
+ currentPage?: number;
+ isLoading?: boolean;
+ error?: string | null;
+ tableState?: TableState;
+ onStateChange?: jest.Mock;
+ onRowClick?: jest.Mock;
+ emptyState?: {
+ message: string;
+ description?: string;
+ action?: { label: string; onClick: () => void };
+ };
+} = {}) {
+ return render(
+
+ pageKey="test-page"
+ columns={COLUMNS}
+ items={items}
+ totalItems={totalItems}
+ totalPages={totalPages}
+ currentPage={currentPage}
+ isLoading={isLoading}
+ error={error}
+ getRowKey={(item) => item.id}
+ onRowClick={onRowClick}
+ tableState={tableState}
+ onStateChange={onStateChange}
+ emptyState={emptyState}
+ />,
+ );
+}
+
+beforeEach(async () => {
+ ({ DataTable } = (await import('./DataTable.js')) as typeof DataTableModule);
+ mockUseColumnPreferences.mockReturnValue({
+ visibleColumns: new Set(COLUMNS.map((c) => c.key)),
+ columnOrder: COLUMNS.map((c) => c.key),
+ isLoaded: true,
+ toggleColumn: mockToggleColumn,
+ moveColumn: mockMoveColumn,
+ resetToDefaults: mockResetToDefaults,
+ });
+ mockToggleColumn.mockReset();
+ mockMoveColumn.mockReset();
+ mockResetToDefaults.mockReset();
+});
+
+describe('DataTable', () => {
+ describe('loading state', () => {
+ it('renders loading indicator when isLoading=true and items=[]', () => {
+ renderDataTable({ isLoading: true, items: [] });
+ expect(screen.getByRole('status')).toBeInTheDocument();
+ });
+
+ it('does not render table rows when in loading state with empty items', () => {
+ const { container } = renderDataTable({ isLoading: true, items: [] });
+ expect(container.querySelector('tbody')).not.toBeInTheDocument();
+ });
+
+ it('renders table content normally when isLoading=true but items exist', () => {
+ // When loading but items exist (refresh scenario), show items
+ const { container } = renderDataTable({ isLoading: true, items: SAMPLE_ITEMS });
+ expect(container.querySelector('tbody')).toBeInTheDocument();
+ });
+ });
+
+ describe('error state', () => {
+ it('renders error banner when error prop is non-null', () => {
+ renderDataTable({ error: 'Failed to load items' });
+ expect(screen.getByRole('alert')).toBeInTheDocument();
+ expect(screen.getByText('Failed to load items')).toBeInTheDocument();
+ });
+
+ it('does not render error banner when error is null', () => {
+ renderDataTable({ error: null });
+ expect(screen.queryByRole('alert')).not.toBeInTheDocument();
+ });
+
+ it('does not render error banner when error is undefined', () => {
+ renderDataTable();
+ expect(screen.queryByRole('alert')).not.toBeInTheDocument();
+ });
+
+ it('still renders items when error is present', () => {
+ const { container } = renderDataTable({ error: 'Minor error', items: SAMPLE_ITEMS });
+ expect(container.querySelector('tbody')).toBeInTheDocument();
+ const rows = container.querySelectorAll('tbody tr');
+ expect(rows).toHaveLength(3);
+ });
+ });
+
+ describe('empty state', () => {
+ it('renders empty state message when items=[] and not loading', () => {
+ renderDataTable({
+ items: [],
+ isLoading: false,
+ emptyState: { message: 'No work items found' },
+ });
+ expect(screen.getByText('No work items found')).toBeInTheDocument();
+ });
+
+ it('renders default empty message when emptyState not provided', () => {
+ renderDataTable({ items: [], isLoading: false });
+ expect(screen.getByText(/no items found/i)).toBeInTheDocument();
+ });
+
+ it('renders empty state description when provided', () => {
+ renderDataTable({
+ items: [],
+ emptyState: {
+ message: 'No results',
+ description: 'Try adjusting your filters',
+ },
+ });
+ expect(screen.getByText('Try adjusting your filters')).toBeInTheDocument();
+ });
+
+ it('renders empty state action button when provided', () => {
+ const mockAction = jest.fn();
+ renderDataTable({
+ items: [],
+ emptyState: {
+ message: 'No items',
+ action: { label: 'Add Item', onClick: mockAction },
+ },
+ });
+ expect(screen.getByRole('button', { name: 'Add Item' })).toBeInTheDocument();
+ });
+
+ it('calls emptyState action onClick when action button clicked', async () => {
+ const user = userEvent.setup();
+ const mockAction = jest.fn();
+ renderDataTable({
+ items: [],
+ emptyState: {
+ message: 'No items',
+ action: { label: 'Add Item', onClick: mockAction },
+ },
+ });
+ await user.click(screen.getByRole('button', { name: 'Add Item' }));
+ expect(mockAction).toHaveBeenCalledTimes(1);
+ });
+
+ it('renders table header even when items are empty', () => {
+ const { container } = renderDataTable({ items: [] });
+ expect(container.querySelector('thead')).toBeInTheDocument();
+ });
+
+ it('does not render table rows when items are empty', () => {
+ const { container } = renderDataTable({ items: [] });
+ expect(container.querySelector('tbody')).toBeInTheDocument();
+ expect(container.querySelectorAll('tbody tr')).toHaveLength(0);
+ });
+ });
+
+ describe('table rows', () => {
+ it('renders a row for each item in items array', () => {
+ const { container } = renderDataTable({ items: SAMPLE_ITEMS });
+ const rows = container.querySelectorAll('tbody tr');
+ expect(rows).toHaveLength(3);
+ });
+
+ it('renders cell content for each item', () => {
+ renderDataTable({ items: SAMPLE_ITEMS });
+ expect(screen.getAllByText('Alpha Work').length).toBeGreaterThan(0);
+ expect(screen.getAllByText('Beta Work').length).toBeGreaterThan(0);
+ });
+
+ it('calls onRowClick with the correct item when a row is clicked', async () => {
+ const user = userEvent.setup();
+ const mockOnRowClick = jest.fn();
+ const { container } = renderDataTable({ onRowClick: mockOnRowClick });
+ const rows = container.querySelectorAll('tbody tr');
+ await user.click(rows[0] as HTMLElement);
+ expect(mockOnRowClick).toHaveBeenCalledWith(SAMPLE_ITEMS[0]);
+ });
+
+ it('calls onRowClick with the second item when second row clicked', async () => {
+ const user = userEvent.setup();
+ const mockOnRowClick = jest.fn();
+ const { container } = renderDataTable({ onRowClick: mockOnRowClick });
+ const rows = container.querySelectorAll('tbody tr');
+ await user.click(rows[1] as HTMLElement);
+ expect(mockOnRowClick).toHaveBeenCalledWith(SAMPLE_ITEMS[1]);
+ });
+
+ it('does not throw when onRowClick not provided and row is clicked', async () => {
+ const user = userEvent.setup();
+ const { container } = renderDataTable({ onRowClick: undefined });
+ const rows = container.querySelectorAll('tbody tr');
+ await expect(user.click(rows[0] as HTMLElement)).resolves.not.toThrow();
+ });
+ });
+
+ describe('search toolbar', () => {
+ it('renders search input', () => {
+ renderDataTable();
+ expect(screen.getByRole('searchbox')).toBeInTheDocument();
+ });
+
+ it('search input has current search value', () => {
+ renderDataTable({ tableState: makeTableState({ search: 'my search' }) });
+ expect(screen.getByRole('searchbox')).toHaveValue('my search');
+ });
+
+ it('calls onStateChange with new search when input changes', () => {
+ const mockOnStateChange = jest.fn();
+ renderDataTable({ onStateChange: mockOnStateChange });
+ fireEvent.change(screen.getByRole('searchbox'), { target: { value: 'hello' } });
+ expect(mockOnStateChange).toHaveBeenCalled();
+ const calls = mockOnStateChange.mock.calls as [TableState][];
+ const lastCall = calls[calls.length - 1];
+ expect(lastCall[0].search).toBe('hello');
+ });
+
+ it('shows Clear Filters button when search is active', () => {
+ renderDataTable({ tableState: makeTableState({ search: 'active' }) });
+ expect(screen.getByRole('button', { name: /clear filters/i })).toBeInTheDocument();
+ });
+
+ it('does not show Clear Filters button when no active search or filters', () => {
+ renderDataTable({ tableState: makeTableState({ search: '' }) });
+ expect(screen.queryByRole('button', { name: /clear filters/i })).not.toBeInTheDocument();
+ });
+
+ it('calls onStateChange with cleared search when Clear Filters clicked', async () => {
+ const user = userEvent.setup();
+ const mockOnStateChange = jest.fn();
+ renderDataTable({
+ tableState: makeTableState({ search: 'existing' }),
+ onStateChange: mockOnStateChange,
+ });
+ await user.click(screen.getByRole('button', { name: /clear filters/i }));
+ const calls = mockOnStateChange.mock.calls as [TableState][];
+ const lastCall = calls[calls.length - 1];
+ expect(lastCall[0].search).toBe('');
+ expect(lastCall[0].filters.size).toBe(0);
+ });
+ });
+
+ describe('header content slot', () => {
+ it('renders custom headerContent when provided', () => {
+ render(
+
+ pageKey="test"
+ columns={COLUMNS}
+ items={SAMPLE_ITEMS}
+ totalItems={3}
+ totalPages={1}
+ currentPage={1}
+ isLoading={false}
+ getRowKey={(i) => i.id}
+ tableState={makeTableState()}
+ onStateChange={jest.fn()}
+ headerContent={My Header
}
+ />,
+ );
+ expect(screen.getByTestId('custom-header')).toBeInTheDocument();
+ });
+ });
+
+ describe('pagination', () => {
+ it('does not render pagination when totalPages=1', () => {
+ renderDataTable({ totalPages: 1 });
+ expect(screen.queryByRole('button', { name: /previous/i })).not.toBeInTheDocument();
+ });
+
+ it('renders pagination when totalPages > 1', () => {
+ renderDataTable({ totalPages: 3, currentPage: 1, totalItems: 75 });
+ expect(screen.getByRole('button', { name: /previous/i })).toBeInTheDocument();
+ });
+ });
+
+ describe('column settings integration', () => {
+ it('renders column settings gear button', () => {
+ renderDataTable();
+ expect(screen.getByRole('button', { name: /column settings/i })).toBeInTheDocument();
+ });
+ });
+});
diff --git a/client/src/components/DataTable/DataTable.tsx b/client/src/components/DataTable/DataTable.tsx
new file mode 100644
index 000000000..72bc88411
--- /dev/null
+++ b/client/src/components/DataTable/DataTable.tsx
@@ -0,0 +1,455 @@
+import { useMemo, useState } from 'react';
+import { useTranslation } from 'react-i18next';
+import type { ReactNode } from 'react';
+import type { FilterMeta } from '@cornerstone/shared';
+import type { SearchPickerProps } from '../SearchPicker/SearchPicker.js';
+import { DataTableHeader } from './DataTableHeader.js';
+import { DataTableRow } from './DataTableRow.js';
+import { DataTableCard } from './DataTableCard.js';
+import { DataTablePagination } from './DataTablePagination.js';
+import { DataTableColumnSettings } from './DataTableColumnSettings.js';
+import { useColumnPreferences } from '../../hooks/useColumnPreferences.js';
+import { Skeleton } from '../Skeleton/Skeleton.js';
+import { EmptyState } from '../EmptyState/EmptyState.js';
+import styles from './DataTable.module.css';
+
+/**
+ * Filter type enumeration for DataTable column filters
+ */
+export type FilterType = 'string' | 'number' | 'date' | 'enum' | 'boolean' | 'entity';
+
+/**
+ * Option for enum filters
+ */
+export interface EnumOption {
+ value: string;
+ label: string;
+}
+
+/**
+ * Hierarchy item for enum filter (parent-child relationships)
+ */
+export interface EnumHierarchyItem {
+ id: string;
+ parentId: string | null;
+}
+
+/**
+ * Column definition for DataTable
+ */
+export interface ColumnDef {
+ key: string;
+ label: string;
+ sortable?: boolean;
+ sortKey?: string;
+ filterable?: boolean;
+ filterType?: FilterType;
+ filterParamKey?: string;
+ enumOptions?: EnumOption[];
+ enumHierarchy?: EnumHierarchyItem[];
+ entitySearchFn?: SearchPickerProps['searchFn'];
+ entityRenderItem?: SearchPickerProps['renderItem'];
+ entityPlaceholder?: string;
+ numberMin?: number;
+ numberMax?: number;
+ numberStep?: number;
+ defaultVisible?: boolean;
+ /** Raw numeric value for client-side number filtering (when no filterParamKey) */
+ getValue?: (item: T) => number;
+ render: (item: T) => ReactNode;
+ renderCard?: (item: T) => ReactNode;
+ className?: string;
+ headerClassName?: string;
+}
+
+/**
+ * Active filter representation
+ */
+export interface ActiveFilter {
+ value: string;
+}
+
+/**
+ * Table state holding pagination, search, sorting, and filters
+ */
+export interface TableState {
+ search: string;
+ filters: Map;
+ sortBy: string | null;
+ sortDir: 'asc' | 'desc' | null;
+ page: number;
+ pageSize: number;
+}
+
+/**
+ * API parameters derived from TableState
+ */
+export interface TableApiParams {
+ q?: string;
+ sortBy?: string;
+ sortOrder?: 'asc' | 'desc';
+ page: number;
+ pageSize: number;
+ [key: string]: string | number | boolean | undefined;
+}
+
+/**
+ * Props for DataTable component
+ */
+export interface DataTableProps {
+ pageKey: string;
+ columns: ColumnDef[];
+ items: T[];
+ totalItems: number;
+ totalPages: number;
+ currentPage: number;
+ isLoading: boolean;
+ error?: string | null;
+ getRowKey: (item: T) => string;
+ onRowClick?: (item: T) => void;
+ renderActions?: (item: T) => ReactNode;
+ tableState: TableState;
+ onStateChange: (state: TableState) => void;
+ headerContent?: ReactNode;
+ customFilters?: ReactNode;
+ emptyState?: {
+ message: string;
+ description?: string;
+ action?: { label: string; onClick: () => void };
+ };
+ filterMeta?: FilterMeta;
+ className?: string;
+}
+
+/**
+ * DataTable component with integrated state management
+ *
+ * Provides:
+ * - Search with debouncing
+ * - Column sorting (3-state cycling)
+ * - Per-column filtering
+ * - Pagination with configurable page sizes
+ * - Column visibility preferences (desktop-only toggle)
+ * - Responsive layout (table on desktop, cards on mobile)
+ * - Loading, error, and empty states
+ *
+ * @param props Component props
+ * @returns Rendered DataTable
+ */
+export function DataTable({
+ pageKey,
+ columns,
+ items,
+ totalItems,
+ totalPages,
+ currentPage,
+ isLoading,
+ error,
+ getRowKey,
+ onRowClick,
+ renderActions,
+ tableState,
+ onStateChange,
+ headerContent,
+ customFilters,
+ emptyState,
+ filterMeta,
+ className,
+}: DataTableProps) {
+ const { t } = useTranslation('common');
+
+ // Client-side filter state for columns without server-side support
+ const [clientFilters, setClientFilters] = useState>(new Map());
+
+ // Load column visibility and ordering preferences
+ const { visibleColumns, columnOrder, toggleColumn, moveColumn, resetToDefaults } =
+ useColumnPreferences(pageKey, columns);
+
+ // Sort columns by stored order
+ const sortedColumns = useMemo(() => {
+ const columnMap = new Map(columns.map((col) => [col.key, col]));
+ const ordered: typeof columns = [];
+
+ for (const key of columnOrder) {
+ const col = columnMap.get(key);
+ if (col) {
+ ordered.push(col);
+ }
+ }
+
+ // Add any columns not in the stored order (new columns added to page)
+ for (const col of columns) {
+ if (!columnOrder.includes(col.key)) {
+ ordered.push(col);
+ }
+ }
+
+ return ordered;
+ }, [columns, columnOrder]);
+
+ // Identify columns that filter client-side only (no filterParamKey)
+ const clientOnlyFilterKeys = useMemo(() => {
+ const keys = new Set();
+ for (const col of sortedColumns) {
+ if (col.filterable && col.filterType === 'number' && !col.filterParamKey && col.getValue) {
+ keys.add(col.key);
+ }
+ }
+ return keys;
+ }, [sortedColumns]);
+
+ // Apply client-side filters to the items list
+ const filteredItems = useMemo(() => {
+ let result = items;
+ for (const col of sortedColumns) {
+ if (!clientOnlyFilterKeys.has(col.key) || !col.getValue) continue;
+ const filterVal = clientFilters.get(col.key)?.value;
+ if (!filterVal) continue;
+ const minMatch = filterVal.match(/min:([\d.]+)/);
+ const maxMatch = filterVal.match(/max:([\d.]+)/);
+ const filterMin = minMatch ? parseFloat(minMatch[1]) : undefined;
+ const filterMax = maxMatch ? parseFloat(maxMatch[1]) : undefined;
+ result = result.filter((item) => {
+ const val = col.getValue!(item);
+ if (filterMin !== undefined && val < filterMin) return false;
+ if (filterMax !== undefined && val > filterMax) return false;
+ return true;
+ });
+ }
+ return result;
+ }, [items, sortedColumns, clientOnlyFilterKeys, clientFilters]);
+
+ // Compute client-side filterMeta bounds for columns without server support
+ const clientFilterMeta = useMemo(() => {
+ const meta: Record = {};
+ for (const col of sortedColumns) {
+ if (clientOnlyFilterKeys.has(col.key) && col.getValue) {
+ let min = Infinity,
+ max = -Infinity;
+ for (const item of items) {
+ const v = col.getValue(item);
+ if (v < min) min = v;
+ if (v > max) max = v;
+ }
+ meta[col.key] = { min: min === Infinity ? 0 : min, max: max === -Infinity ? 0 : max };
+ }
+ }
+ return meta;
+ }, [items, sortedColumns, clientOnlyFilterKeys]);
+
+ // Merge API filterMeta with client-side computed meta
+ const mergedFilterMeta = useMemo(
+ () => ({
+ ...filterMeta,
+ ...clientFilterMeta,
+ }),
+ [filterMeta, clientFilterMeta],
+ );
+
+ // Combine server-side and client-side filters for header display
+ const allFilters = useMemo(() => {
+ const merged = new Map(tableState.filters);
+ for (const [key, val] of clientFilters) {
+ merged.set(key, val);
+ }
+ return merged;
+ }, [tableState.filters, clientFilters]);
+
+ const handleSearch = (query: string) => {
+ const newState = { ...tableState, search: query, page: 1 };
+ onStateChange(newState);
+ };
+
+ const handleSort = (columnKey: string, columnSortKey?: string) => {
+ const sortKey = columnSortKey || columnKey;
+ let newSortDir: 'asc' | 'desc' | null = 'asc';
+
+ if (tableState.sortBy === sortKey && tableState.sortDir === 'asc') {
+ newSortDir = 'desc';
+ } else if (tableState.sortBy === sortKey && tableState.sortDir === 'desc') {
+ newSortDir = null;
+ }
+
+ const newState = {
+ ...tableState,
+ sortBy: newSortDir ? sortKey : null,
+ sortDir: newSortDir,
+ page: 1,
+ };
+ onStateChange(newState);
+ };
+
+ const handleFilter = (paramKey: string, value: string | null) => {
+ // Route client-side filters to internal state
+ if (clientOnlyFilterKeys.has(paramKey)) {
+ setClientFilters((prev) => {
+ const next = new Map(prev);
+ if (value === null || value === '') next.delete(paramKey);
+ else next.set(paramKey, { value });
+ return next;
+ });
+ return; // Don't propagate to parent for client-side filters
+ }
+
+ // Server-side filters: propagate to parent
+ const newFilters = new Map(tableState.filters);
+ if (value === null || value === '') {
+ newFilters.delete(paramKey);
+ } else {
+ newFilters.set(paramKey, { value });
+ }
+ const newState = { ...tableState, filters: newFilters, page: 1 };
+ onStateChange(newState);
+ };
+
+ const handlePage = (page: number) => {
+ const newState = { ...tableState, page };
+ onStateChange(newState);
+ };
+
+ const handlePageSize = (size: number) => {
+ const newState = { ...tableState, pageSize: size, page: 1 };
+ onStateChange(newState);
+ };
+
+ const handleResetFilters = () => {
+ setClientFilters(new Map());
+ const newState = {
+ ...tableState,
+ search: '',
+ filters: new Map(),
+ page: 1,
+ };
+ onStateChange(newState);
+ };
+
+ const hasActiveFilters = useMemo(
+ () => tableState.search !== '' || tableState.filters.size > 0 || clientFilters.size > 0,
+ [tableState.search, tableState.filters, clientFilters],
+ );
+
+ if (isLoading && items.length === 0) {
+ return (
+
+
+
+ );
+ }
+
+ return (
+
+ {error && (
+
+ {error}
+
+ )}
+
+ {/* Header content slot */}
+ {headerContent}
+
+ {/* Toolbar */}
+
+
+
+ handleSearch(e.target.value)}
+ className={styles.searchInput}
+ aria-label={t('dataTable.search.ariaLabel')}
+ />
+
+
+ {hasActiveFilters && (
+
+ {t('button.clearFilters')}
+
+ )}
+
+ columns={sortedColumns}
+ visibleColumns={visibleColumns}
+ onToggleColumn={toggleColumn}
+ onMoveColumn={moveColumn}
+ onResetToDefaults={resetToDefaults}
+ />
+
+
+
+ {/* Custom filters slot */}
+ {customFilters &&
{customFilters}
}
+
+
+ {/* Desktop Table — always show header */}
+
+
+
+ columns={sortedColumns}
+ visibleColumns={visibleColumns}
+ tableState={{ ...tableState, filters: allFilters }}
+ filterMeta={mergedFilterMeta}
+ onSort={handleSort}
+ onFilter={handleFilter}
+ hasActions={!!renderActions}
+ />
+
+ {filteredItems.map((item) => (
+
+ key={getRowKey(item)}
+ item={item}
+ columns={sortedColumns}
+ visibleColumns={visibleColumns}
+ onClick={() => onRowClick?.(item)}
+ renderActions={renderActions ? () => renderActions(item) : undefined}
+ />
+ ))}
+
+
+
+
+ {/* Empty state or mobile cards */}
+ {filteredItems.length === 0 ? (
+
+ ) : (
+
+ {filteredItems.map((item) => (
+
+ key={getRowKey(item)}
+ item={item}
+ columns={sortedColumns}
+ visibleColumns={visibleColumns}
+ onClick={() => onRowClick?.(item)}
+ renderActions={renderActions ? () => renderActions(item) : undefined}
+ />
+ ))}
+
+ )}
+
+ {/* Pagination */}
+ {totalPages > 1 && (
+
+ )}
+
+ );
+}
+
+export default DataTable;
diff --git a/client/src/components/DataTable/DataTableCard.tsx b/client/src/components/DataTable/DataTableCard.tsx
new file mode 100644
index 000000000..ac100ad44
--- /dev/null
+++ b/client/src/components/DataTable/DataTableCard.tsx
@@ -0,0 +1,50 @@
+import type { ColumnDef } from './DataTable.js';
+import styles from './DataTable.module.css';
+
+export interface DataTableCardProps {
+ item: T;
+ columns: ColumnDef[];
+ visibleColumns: Set;
+ onClick?: () => void;
+ renderActions?: (item: T) => React.ReactNode;
+}
+
+/**
+ * Mobile card renderer for a single item
+ * Uses renderCard if available, falls back to render
+ */
+export function DataTableCard({
+ item,
+ columns,
+ visibleColumns,
+ onClick,
+ renderActions,
+}: DataTableCardProps) {
+ const visibleCols = columns.filter((col) => visibleColumns.has(col.key));
+
+ return (
+
+
+
+ {visibleCols.map((col) => {
+ // Use renderCard if available, otherwise use render
+ const content = col.renderCard ? col.renderCard(item) : col.render(item);
+ if (content === null) return null;
+
+ return (
+
+ {col.label}
+ {content ?? '—'}
+
+ );
+ })}
+
+ {renderActions && (
+
e.stopPropagation()}>
+ {renderActions(item)}
+
+ )}
+
+
+ );
+}
diff --git a/client/src/components/DataTable/DataTableColumnSettings.test.tsx b/client/src/components/DataTable/DataTableColumnSettings.test.tsx
new file mode 100644
index 000000000..3caedb68d
--- /dev/null
+++ b/client/src/components/DataTable/DataTableColumnSettings.test.tsx
@@ -0,0 +1,339 @@
+import { describe, it, expect, jest, beforeEach } from '@jest/globals';
+import { render, screen, act, fireEvent } from '@testing-library/react';
+import userEvent from '@testing-library/user-event';
+import type { ColumnDef } from './DataTable.js';
+import { DataTableColumnSettings } from './DataTableColumnSettings.js';
+
+interface TestItem {
+ id: string;
+ title: string;
+ amount: number;
+}
+
+const COLUMNS: ColumnDef[] = [
+ { key: 'title', label: 'Title', defaultVisible: true, render: (i) => i.title },
+ { key: 'amount', label: 'Amount', defaultVisible: true, render: (i) => i.amount },
+ { key: 'id', label: 'ID', defaultVisible: false, render: (i) => i.id },
+];
+
+function renderSettings({
+ columns = COLUMNS,
+ visibleColumns = new Set(['title', 'amount']),
+ onToggleColumn = jest.fn(),
+ onMoveColumn = jest.fn(),
+ onResetToDefaults = jest.fn(),
+}: {
+ columns?: ColumnDef[];
+ visibleColumns?: Set;
+ onToggleColumn?: jest.Mock;
+ onMoveColumn?: jest.Mock;
+ onResetToDefaults?: jest.Mock;
+} = {}) {
+ return render(
+
+ columns={columns}
+ visibleColumns={visibleColumns}
+ onToggleColumn={onToggleColumn}
+ onMoveColumn={onMoveColumn}
+ onResetToDefaults={onResetToDefaults}
+ />,
+ );
+}
+
+describe('DataTableColumnSettings', () => {
+ describe('trigger button', () => {
+ it('renders a gear/settings button', () => {
+ renderSettings();
+ expect(screen.getByRole('button', { name: /column settings/i })).toBeInTheDocument();
+ });
+
+ it('button has aria-expanded="false" initially', () => {
+ renderSettings();
+ expect(screen.getByRole('button', { name: /column settings/i })).toHaveAttribute(
+ 'aria-expanded',
+ 'false',
+ );
+ });
+
+ it('button has aria-haspopup="dialog"', () => {
+ renderSettings();
+ expect(screen.getByRole('button', { name: /column settings/i })).toHaveAttribute(
+ 'aria-haspopup',
+ 'dialog',
+ );
+ });
+ });
+
+ describe('popover open/close', () => {
+ it('does not show popover initially', () => {
+ renderSettings();
+ expect(screen.queryByRole('dialog')).not.toBeInTheDocument();
+ });
+
+ it('shows popover after clicking the trigger button', async () => {
+ const user = userEvent.setup();
+ renderSettings();
+ await user.click(screen.getByRole('button', { name: /column settings/i }));
+ expect(screen.getByRole('dialog')).toBeInTheDocument();
+ });
+
+ it('button has aria-expanded="true" when popover is open', async () => {
+ const user = userEvent.setup();
+ renderSettings();
+ await user.click(screen.getByRole('button', { name: /column settings/i }));
+ expect(screen.getByRole('button', { name: /column settings/i })).toHaveAttribute(
+ 'aria-expanded',
+ 'true',
+ );
+ });
+
+ it('hides popover after clicking trigger button again', async () => {
+ const user = userEvent.setup();
+ renderSettings();
+ const triggerBtn = screen.getByRole('button', { name: /column settings/i });
+ await user.click(triggerBtn);
+ expect(screen.getByRole('dialog')).toBeInTheDocument();
+ await user.click(triggerBtn);
+ expect(screen.queryByRole('dialog')).not.toBeInTheDocument();
+ });
+
+ it('hides popover on Escape key press', async () => {
+ const user = userEvent.setup();
+ renderSettings();
+ await user.click(screen.getByRole('button', { name: /column settings/i }));
+ expect(screen.getByRole('dialog')).toBeInTheDocument();
+ await user.keyboard('{Escape}');
+ expect(screen.queryByRole('dialog')).not.toBeInTheDocument();
+ });
+
+ it('hides popover on outside click', async () => {
+ renderSettings();
+ const triggerBtn = screen.getByRole('button', { name: /column settings/i });
+ await userEvent.click(triggerBtn);
+ expect(screen.getByRole('dialog')).toBeInTheDocument();
+
+ act(() => {
+ const event = new MouseEvent('mousedown', { bubbles: true });
+ document.body.dispatchEvent(event);
+ });
+
+ expect(screen.queryByRole('dialog')).not.toBeInTheDocument();
+ });
+ });
+
+ describe('checkbox list', () => {
+ it('renders a checkbox for each column', async () => {
+ const user = userEvent.setup();
+ renderSettings();
+ await user.click(screen.getByRole('button', { name: /column settings/i }));
+ const checkboxes = screen.getAllByRole('checkbox');
+ expect(checkboxes).toHaveLength(COLUMNS.length);
+ });
+
+ it('renders column labels', async () => {
+ const user = userEvent.setup();
+ renderSettings();
+ await user.click(screen.getByRole('button', { name: /column settings/i }));
+ expect(screen.getByText('Title')).toBeInTheDocument();
+ expect(screen.getByText('Amount')).toBeInTheDocument();
+ expect(screen.getByText('ID')).toBeInTheDocument();
+ });
+
+ it('checks visible columns', async () => {
+ const user = userEvent.setup();
+ renderSettings({ visibleColumns: new Set(['title', 'amount']) });
+ await user.click(screen.getByRole('button', { name: /column settings/i }));
+ expect(screen.getByRole('checkbox', { name: 'Title' })).toBeChecked();
+ expect(screen.getByRole('checkbox', { name: 'Amount' })).toBeChecked();
+ expect(screen.getByRole('checkbox', { name: 'ID' })).not.toBeChecked();
+ });
+
+ it('calls onToggleColumn with column key when checkbox clicked', async () => {
+ const user = userEvent.setup();
+ const mockToggle = jest.fn();
+ renderSettings({ onToggleColumn: mockToggle });
+ await user.click(screen.getByRole('button', { name: /column settings/i }));
+ await user.click(screen.getByRole('checkbox', { name: 'Title' }));
+ expect(mockToggle).toHaveBeenCalledWith('title');
+ });
+
+ it('calls onToggleColumn for a hidden column when its checkbox clicked', async () => {
+ const user = userEvent.setup();
+ const mockToggle = jest.fn();
+ renderSettings({ onToggleColumn: mockToggle });
+ await user.click(screen.getByRole('button', { name: /column settings/i }));
+ await user.click(screen.getByRole('checkbox', { name: 'ID' }));
+ expect(mockToggle).toHaveBeenCalledWith('id');
+ });
+ });
+
+ describe('reset button', () => {
+ it('renders a "Reset to defaults" button in the popover', async () => {
+ const user = userEvent.setup();
+ renderSettings();
+ await user.click(screen.getByRole('button', { name: /column settings/i }));
+ expect(screen.getByRole('button', { name: /reset to defaults/i })).toBeInTheDocument();
+ });
+
+ it('calls onResetToDefaults when reset button clicked', async () => {
+ const user = userEvent.setup();
+ const mockReset = jest.fn();
+ renderSettings({ onResetToDefaults: mockReset });
+ await user.click(screen.getByRole('button', { name: /column settings/i }));
+ await user.click(screen.getByRole('button', { name: /reset to defaults/i }));
+ expect(mockReset).toHaveBeenCalledTimes(1);
+ });
+ });
+
+ describe('SVG icon in trigger button (#1136)', () => {
+ it('renders an SVG element inside the trigger button', () => {
+ renderSettings();
+ const triggerBtn = screen.getByRole('button', { name: /column settings/i });
+ expect(triggerBtn.querySelector('svg')).not.toBeNull();
+ });
+
+ it('does NOT contain an emoji character in the trigger button text', () => {
+ renderSettings();
+ const triggerBtn = screen.getByRole('button', { name: /column settings/i });
+ // The gear emoji ⚙️ was the prior implementation; verify it is no longer present
+ expect(triggerBtn.textContent).not.toContain('⚙️');
+ });
+
+ it('SVG has aria-hidden="true" so it is invisible to assistive technology', () => {
+ renderSettings();
+ const triggerBtn = screen.getByRole('button', { name: /column settings/i });
+ const svg = triggerBtn.querySelector('svg');
+ expect(svg).toHaveAttribute('aria-hidden', 'true');
+ });
+ });
+
+ describe('drag-and-drop column reordering (#1140)', () => {
+ async function openPopover() {
+ const user = userEvent.setup();
+ renderSettings();
+ await user.click(screen.getByRole('button', { name: /column settings/i }));
+ return user;
+ }
+
+ it('sets effectAllowed to "move" on dragStart', async () => {
+ await openPopover();
+
+ // The "Amount" item (index 1) is draggable; "Title" (index 0) is not
+ const checkboxItems = document.querySelectorAll('[draggable="true"]');
+ expect(checkboxItems.length).toBeGreaterThan(0);
+
+ const draggableItem = checkboxItems[0] as HTMLElement;
+ const dataTransfer = { effectAllowed: '', dropEffect: '' };
+
+ fireEvent.dragStart(draggableItem, { dataTransfer });
+
+ expect(dataTransfer.effectAllowed).toBe('move');
+ });
+
+ it('applies a drop indicator CSS class when dragging over a valid target item', async () => {
+ await openPopover();
+
+ const checkboxItems = document.querySelectorAll('[draggable="true"]');
+ const draggableItem = checkboxItems[0] as HTMLElement; // Amount (col index 1)
+ const dataTransfer = { effectAllowed: '', dropEffect: '' };
+
+ fireEvent.dragStart(draggableItem, { dataTransfer });
+
+ // Use a different target item (checkboxItems[1] = ID column, col index 2)
+ const targetItem = checkboxItems[1] as HTMLElement;
+
+ // In jsdom getBoundingClientRect always returns zeros, so clientY > 0 always
+ // evaluates to "below" — assert that SOME drop indicator class is applied.
+ fireEvent.dragOver(targetItem, { dataTransfer, clientY: 50 });
+
+ expect(targetItem.className).toMatch(/columnCheckboxItemDrop(Above|Below)/);
+ });
+
+ it('applies drop-below CSS class when clientY is in the lower half (jsdom geometry)', async () => {
+ await openPopover();
+
+ const checkboxItems = document.querySelectorAll('[draggable="true"]');
+ const draggableItem = checkboxItems[0] as HTMLElement;
+ const dataTransfer = { effectAllowed: '', dropEffect: '' };
+
+ fireEvent.dragStart(draggableItem, { dataTransfer });
+
+ const targetItem = checkboxItems[1] as HTMLElement;
+
+ // In jsdom, getBoundingClientRect returns all zeros. clientY > 0 means
+ // clientY >= midpoint (0 + 0/2 = 0), so position is always 'below'
+ fireEvent.dragOver(targetItem, { dataTransfer, clientY: 50 });
+
+ expect(targetItem.className).toContain('columnCheckboxItemDropBelow');
+ });
+
+ it('clears drop indicator classes on dragLeave', async () => {
+ await openPopover();
+
+ const checkboxItems = document.querySelectorAll('[draggable="true"]');
+ const draggableItem = checkboxItems[0] as HTMLElement;
+ const dataTransfer = { effectAllowed: '', dropEffect: '' };
+
+ fireEvent.dragStart(draggableItem, { dataTransfer });
+
+ const targetItem = checkboxItems[1] as HTMLElement;
+
+ fireEvent.dragOver(targetItem, { dataTransfer, clientY: 50 });
+ // Confirm indicator class was applied
+ expect(targetItem.className).toMatch(/columnCheckboxItemDrop(Above|Below)/);
+
+ fireEvent.dragLeave(targetItem);
+ // After leave, neither above nor below class should be present
+ expect(targetItem.className).not.toContain('columnCheckboxItemDropAbove');
+ expect(targetItem.className).not.toContain('columnCheckboxItemDropBelow');
+ });
+
+ it('calls onMoveColumn with correct indices on drop', async () => {
+ const mockMoveColumn = jest.fn();
+ const user = userEvent.setup();
+ render(
+
+ columns={COLUMNS}
+ visibleColumns={new Set(['title', 'amount'])}
+ onToggleColumn={jest.fn()}
+ onMoveColumn={mockMoveColumn}
+ onResetToDefaults={jest.fn()}
+ />,
+ );
+ await user.click(screen.getByRole('button', { name: /column settings/i }));
+
+ const checkboxItems = document.querySelectorAll('[draggable="true"]');
+ // We have 2 draggable items (index 1: Amount, index 2: ID — index 0 Title is not draggable)
+ const draggedItem = checkboxItems[0] as HTMLElement; // Amount (col index 1)
+ const targetItem = checkboxItems[1] as HTMLElement; // ID (col index 2)
+ const dataTransfer = { effectAllowed: '', dropEffect: '' };
+
+ fireEvent.dragStart(draggedItem, { dataTransfer });
+
+ const mockBCR = (): DOMRect =>
+ ({
+ top: 100,
+ bottom: 140,
+ height: 40,
+ left: 0,
+ right: 200,
+ width: 200,
+ x: 0,
+ y: 100,
+ toJSON: () => ({}),
+ }) as DOMRect;
+ const origGetBCR = HTMLElement.prototype.getBoundingClientRect;
+ HTMLElement.prototype.getBoundingClientRect = mockBCR;
+
+ try {
+ fireEvent.dragOver(targetItem, { dataTransfer, clientY: 130 }); // lower half → below
+ } finally {
+ HTMLElement.prototype.getBoundingClientRect = origGetBCR;
+ }
+
+ fireEvent.drop(targetItem);
+
+ expect(mockMoveColumn).toHaveBeenCalledWith(1, 2);
+ });
+ });
+});
diff --git a/client/src/components/DataTable/DataTableColumnSettings.tsx b/client/src/components/DataTable/DataTableColumnSettings.tsx
new file mode 100644
index 000000000..d055163ca
--- /dev/null
+++ b/client/src/components/DataTable/DataTableColumnSettings.tsx
@@ -0,0 +1,190 @@
+import { useEffect, useRef, useState } from 'react';
+import { useTranslation } from 'react-i18next';
+import type { ColumnDef } from './DataTable.js';
+import styles from './DataTable.module.css';
+
+interface DragOverState {
+ index: number;
+ position: 'above' | 'below';
+}
+
+export interface DataTableColumnSettingsProps {
+ columns: ColumnDef[];
+ visibleColumns: Set;
+ onToggleColumn: (key: string) => void;
+ onMoveColumn: (from: number, to: number) => void;
+ onResetToDefaults: () => void;
+}
+
+/**
+ * Column visibility and ordering settings popover
+ * Gear icon + checkbox list with drag-and-drop reordering, desktop-only (hidden on mobile)
+ * Uses position:fixed with getBoundingClientRect
+ */
+export function DataTableColumnSettings({
+ columns,
+ visibleColumns,
+ onToggleColumn,
+ onMoveColumn,
+ onResetToDefaults,
+}: DataTableColumnSettingsProps) {
+ const { t } = useTranslation('common');
+ const triggerRef = useRef(null);
+ const popoverRef = useRef(null);
+ const [isOpen, setIsOpen] = useState(false);
+ const [popoverStyle, setPopoverStyle] = useState({});
+ const [draggedIndex, setDraggedIndex] = useState(null);
+ const [dragOverState, setDragOverState] = useState(null);
+
+ // Close on outside click
+ useEffect(() => {
+ if (!isOpen) return;
+
+ const handleClickOutside = (event: MouseEvent) => {
+ if (
+ popoverRef.current &&
+ !popoverRef.current.contains(event.target as Node) &&
+ triggerRef.current &&
+ !triggerRef.current.contains(event.target as Node)
+ ) {
+ setIsOpen(false);
+ }
+ };
+
+ const handleEscape = (event: KeyboardEvent) => {
+ if (event.key === 'Escape') {
+ setIsOpen(false);
+ }
+ };
+
+ document.addEventListener('mousedown', handleClickOutside);
+ document.addEventListener('keydown', handleEscape);
+
+ return () => {
+ document.removeEventListener('mousedown', handleClickOutside);
+ document.removeEventListener('keydown', handleEscape);
+ };
+ }, [isOpen]);
+
+ return (
+ <>
+ {
+ if (!isOpen && triggerRef.current) {
+ const rect = triggerRef.current.getBoundingClientRect();
+ setPopoverStyle({
+ position: 'fixed',
+ top: `${rect.bottom + 4}px`,
+ right: `${window.innerWidth - rect.right}px`,
+ maxWidth: '250px',
+ zIndex: 1000,
+ });
+ }
+ setIsOpen(!isOpen);
+ }}
+ aria-label={t('dataTable.columnSettings.ariaLabel')}
+ aria-expanded={isOpen}
+ aria-haspopup="dialog"
+ >
+
+
+
+
+
+
+
+ {isOpen && (
+
+
+
{t('dataTable.columnSettings.title')}
+
+ {columns.map((col, index) => (
+
0}
+ onDragStart={(e) => {
+ e.dataTransfer.effectAllowed = 'move';
+ setDraggedIndex(index);
+ }}
+ onDragOver={(e) => {
+ e.preventDefault();
+ e.dataTransfer.dropEffect = 'move';
+ if (index > 0) {
+ const rect = (e.currentTarget as HTMLElement).getBoundingClientRect();
+ const position = e.clientY < rect.top + rect.height / 2 ? 'above' : 'below';
+ setDragOverState({ index, position });
+ }
+ }}
+ onDragLeave={() => setDragOverState(null)}
+ onDrop={() => {
+ if (
+ draggedIndex !== null &&
+ dragOverState !== null &&
+ dragOverState.index !== draggedIndex &&
+ dragOverState.index > 0
+ ) {
+ onMoveColumn(draggedIndex, dragOverState.index);
+ }
+ setDraggedIndex(null);
+ setDragOverState(null);
+ }}
+ onDragEnd={() => {
+ setDraggedIndex(null);
+ setDragOverState(null);
+ }}
+ >
+ {index > 0 && (
+
+ ⠿
+
+ )}
+ onToggleColumn(col.key)}
+ className={styles.columnCheckbox}
+ />
+
+ {col.label}
+
+
+ ))}
+
+
+ {t('dataTable.columnSettings.resetToDefaults')}
+
+
+
+ )}
+ >
+ );
+}
diff --git a/client/src/components/DataTable/DataTableFilterPopover.test.tsx b/client/src/components/DataTable/DataTableFilterPopover.test.tsx
new file mode 100644
index 000000000..f7d33e402
--- /dev/null
+++ b/client/src/components/DataTable/DataTableFilterPopover.test.tsx
@@ -0,0 +1,223 @@
+import { describe, it, expect, jest } from '@jest/globals';
+import { render, screen, fireEvent } from '@testing-library/react';
+import type { ColumnDef } from './DataTable.js';
+import { DataTableFilterPopover } from './DataTableFilterPopover.js';
+
+interface TestItem {
+ id: string;
+ name: string;
+}
+
+// Build a fake DOMRect for triggerRect prop
+function makeTriggerRect(overrides: Partial = {}): DOMRect {
+ return {
+ bottom: 40,
+ top: 20,
+ left: 100,
+ right: 200,
+ width: 100,
+ height: 20,
+ x: 100,
+ y: 20,
+ toJSON: () => ({}),
+ ...overrides,
+ } as DOMRect;
+}
+
+function makeColumn(overrides: Partial> = {}): ColumnDef {
+ return {
+ key: 'name',
+ label: 'Name',
+ filterType: 'string',
+ filterParamKey: 'name',
+ filterable: true,
+ render: (item) => item.name,
+ ...overrides,
+ };
+}
+
+describe('DataTableFilterPopover', () => {
+ describe('rendering', () => {
+ it('renders with role="dialog"', () => {
+ render(
+ ,
+ );
+ expect(screen.getByRole('dialog')).toBeInTheDocument();
+ });
+
+ it('uses fixed positioning based on triggerRect', () => {
+ render(
+ ,
+ );
+ const dialog = screen.getByRole('dialog');
+ expect(dialog).toHaveStyle({ position: 'fixed', top: '64px', left: '150px' });
+ });
+
+ it('clamps left position to at least 16px', () => {
+ render(
+ ,
+ );
+ const dialog = screen.getByRole('dialog');
+ expect(dialog).toHaveStyle({ left: '16px' });
+ });
+
+ it('renders StringFilter for filterType="string"', () => {
+ render(
+ ,
+ );
+ expect(screen.getByRole('textbox')).toBeInTheDocument();
+ });
+
+ it('renders NumberFilter for filterType="number"', () => {
+ render(
+ ,
+ );
+ const spinbuttons = screen.getAllByRole('spinbutton');
+ expect(spinbuttons.length).toBeGreaterThan(0);
+ });
+
+ it('renders BooleanFilter for filterType="boolean"', () => {
+ render(
+ ,
+ );
+ expect(screen.getByRole('button', { name: /yes/i })).toBeInTheDocument();
+ });
+
+ it('renders EnumFilter for filterType="enum" with options', () => {
+ render(
+ ,
+ );
+ expect(screen.getByText('Option A')).toBeInTheDocument();
+ });
+
+ it('does not render an Apply button inside the popover', () => {
+ render(
+ ,
+ );
+ expect(screen.queryByRole('button', { name: /apply/i })).not.toBeInTheDocument();
+ });
+
+ it('does not render a Clear button inside the popover', () => {
+ render(
+ ,
+ );
+ expect(screen.queryByRole('button', { name: /clear/i })).not.toBeInTheDocument();
+ });
+
+ it('passes initial value to the rendered filter component', () => {
+ render(
+ ,
+ );
+ expect(screen.getByRole('textbox')).toHaveValue('hello');
+ });
+ });
+
+ describe('auto-apply', () => {
+ it('calls onApply immediately when string filter input changes', () => {
+ const mockOnApply = jest.fn();
+ render(
+ ,
+ );
+ fireEvent.change(screen.getByRole('textbox'), { target: { value: 'test' } });
+ expect(mockOnApply).toHaveBeenCalledWith('test');
+ });
+
+ it('calls onApply immediately when enum filter checkbox is toggled', () => {
+ const mockOnApply = jest.fn();
+ render(
+ ,
+ );
+ fireEvent.click(screen.getByText('Option A'));
+ expect(mockOnApply).toHaveBeenCalledWith('a');
+ });
+ });
+
+ describe('aria-label', () => {
+ it('includes the column label in aria-label on dialog', () => {
+ render(
+ ,
+ );
+ expect(screen.getByRole('dialog')).toHaveAttribute(
+ 'aria-label',
+ expect.stringContaining('Budget Amount'),
+ );
+ });
+ });
+});
diff --git a/client/src/components/DataTable/DataTableFilterPopover.tsx b/client/src/components/DataTable/DataTableFilterPopover.tsx
new file mode 100644
index 000000000..6b5b96783
--- /dev/null
+++ b/client/src/components/DataTable/DataTableFilterPopover.tsx
@@ -0,0 +1,120 @@
+import { useRef } from 'react';
+import { useTranslation } from 'react-i18next';
+import type { FilterMeta } from '@cornerstone/shared';
+import type { ColumnDef, FilterType } from './DataTable.js';
+import { StringFilter } from './filters/StringFilter.js';
+import { NumberFilter } from './filters/NumberFilter.js';
+import { DateFilter } from './filters/DateFilter.js';
+import { EnumFilter } from './filters/EnumFilter.js';
+import { BooleanFilter } from './filters/BooleanFilter.js';
+import { EntityFilter } from './filters/EntityFilter.js';
+import styles from './DataTable.module.css';
+
+export interface DataTableFilterPopoverProps {
+ column: ColumnDef;
+ value: string;
+ onApply: (value: string) => void;
+ triggerRect: DOMRect;
+ filterMeta?: FilterMeta;
+}
+
+/**
+ * Filter popover for a single column
+ * Uses position:fixed to avoid clipping inside table container
+ */
+export function DataTableFilterPopover({
+ column,
+ value,
+ onApply,
+ triggerRect,
+ filterMeta,
+}: DataTableFilterPopoverProps) {
+ const { t } = useTranslation('common');
+ const popoverRef = useRef(null);
+
+ // Auto-apply: pass onApply directly to filter components as onChange
+ // No local state needed — filters propagate immediately
+ const handleChange = onApply;
+
+ // Position the popover using getBoundingClientRect
+ const popoverStyle = {
+ position: 'fixed' as const,
+ top: `${triggerRect.bottom + 4}px`,
+ left: `${Math.max(16, triggerRect.left)}px`,
+ maxWidth: '300px',
+ zIndex: 1000,
+ };
+
+ const renderFilterComponent = () => {
+ const filterType = column.filterType as FilterType;
+
+ switch (filterType) {
+ case 'string':
+ return (
+
+ );
+ case 'number': {
+ const apiMeta = filterMeta?.[column.key];
+ const effectiveMin = apiMeta?.min ?? column.numberMin;
+ const effectiveMax = apiMeta?.max ?? column.numberMax;
+ return (
+
+ );
+ }
+ case 'date':
+ return ;
+ case 'enum':
+ return (
+ column.enumOptions && (
+
+ )
+ );
+ case 'boolean':
+ return ;
+ case 'entity':
+ return (
+ column.entitySearchFn &&
+ column.entityRenderItem && (
+
+ )
+ );
+ default:
+ return null;
+ }
+ };
+
+ return (
+ e.stopPropagation()}
+ onMouseDown={(e) => e.stopPropagation()}
+ >
+ {renderFilterComponent()}
+
+ );
+}
diff --git a/client/src/components/DataTable/DataTableHeader.test.tsx b/client/src/components/DataTable/DataTableHeader.test.tsx
new file mode 100644
index 000000000..66d13ee48
--- /dev/null
+++ b/client/src/components/DataTable/DataTableHeader.test.tsx
@@ -0,0 +1,319 @@
+import { describe, it, expect, jest, beforeEach } from '@jest/globals';
+import { render, screen } from '@testing-library/react';
+import userEvent from '@testing-library/user-event';
+import type { ColumnDef, TableState } from './DataTable.js';
+import { DataTableHeader } from './DataTableHeader.js';
+
+interface TestItem {
+ id: string;
+ title: string;
+ amount: number;
+}
+
+const DEFAULT_COLUMNS: ColumnDef[] = [
+ {
+ key: 'title',
+ label: 'Title',
+ sortable: true,
+ filterable: true,
+ filterType: 'string',
+ filterParamKey: 'title',
+ render: (item) => item.title,
+ },
+ {
+ key: 'amount',
+ label: 'Amount',
+ sortable: true,
+ render: (item) => item.amount,
+ },
+ {
+ key: 'id',
+ label: 'ID',
+ render: (item) => item.id,
+ },
+];
+
+function makeTableState(overrides: Partial = {}): TableState {
+ return {
+ search: '',
+ filters: new Map(),
+ sortBy: null,
+ sortDir: null,
+ page: 1,
+ pageSize: 25,
+ ...overrides,
+ };
+}
+
+function renderHeader({
+ columns = DEFAULT_COLUMNS,
+ visibleColumns = new Set(DEFAULT_COLUMNS.map((c) => c.key)),
+ tableState = makeTableState(),
+ onSort = jest.fn(),
+ onFilter = jest.fn(),
+}: {
+ columns?: ColumnDef[];
+ visibleColumns?: Set;
+ tableState?: TableState;
+ onSort?: jest.Mock;
+ onFilter?: jest.Mock;
+} = {}) {
+ return render(
+
+
+ columns={columns}
+ visibleColumns={visibleColumns}
+ tableState={tableState}
+ onSort={onSort}
+ onFilter={onFilter}
+ />
+
,
+ );
+}
+
+describe('DataTableHeader', () => {
+ describe('rendering', () => {
+ it('renders a thead element', () => {
+ const { container } = renderHeader();
+ expect(container.querySelector('thead')).toBeInTheDocument();
+ });
+
+ it('renders a th for each visible column', () => {
+ const { container } = renderHeader();
+ const headers = container.querySelectorAll('th');
+ expect(headers).toHaveLength(3);
+ });
+
+ it('renders only visible columns', () => {
+ const { container } = renderHeader({
+ visibleColumns: new Set(['title', 'amount']),
+ });
+ expect(container.querySelectorAll('th')).toHaveLength(2);
+ expect(screen.getByText('Title')).toBeInTheDocument();
+ expect(screen.getByText('Amount')).toBeInTheDocument();
+ expect(screen.queryByText('ID')).not.toBeInTheDocument();
+ });
+
+ it('renders column labels', () => {
+ renderHeader();
+ expect(screen.getByText('Title')).toBeInTheDocument();
+ expect(screen.getByText('Amount')).toBeInTheDocument();
+ expect(screen.getByText('ID')).toBeInTheDocument();
+ });
+
+ it('renders filter button only for filterable columns with filterParamKey and filterType', () => {
+ renderHeader();
+ // Only 'title' column has filterable=true AND filterParamKey AND filterType
+ const filterButtons = screen.queryAllByRole('button');
+ expect(filterButtons).toHaveLength(1);
+ });
+ });
+
+ describe('sort aria-sort attributes', () => {
+ it('has aria-sort="none" on all columns when no sort is active', () => {
+ const { container } = renderHeader();
+ const headers = container.querySelectorAll('th');
+ headers.forEach((th) => {
+ expect(th).toHaveAttribute('aria-sort', 'none');
+ });
+ });
+
+ it('has aria-sort="ascending" on the sorted column when sortDir is "asc"', () => {
+ const { container } = renderHeader({
+ tableState: makeTableState({ sortBy: 'title', sortDir: 'asc' }),
+ });
+ const titleTh = container.querySelectorAll('th')[0];
+ expect(titleTh).toHaveAttribute('aria-sort', 'ascending');
+ });
+
+ it('has aria-sort="descending" on the sorted column when sortDir is "desc"', () => {
+ const { container } = renderHeader({
+ tableState: makeTableState({ sortBy: 'title', sortDir: 'desc' }),
+ });
+ const titleTh = container.querySelectorAll('th')[0];
+ expect(titleTh).toHaveAttribute('aria-sort', 'descending');
+ });
+
+ it('has aria-sort="none" on columns not being sorted', () => {
+ const { container } = renderHeader({
+ tableState: makeTableState({ sortBy: 'title', sortDir: 'asc' }),
+ });
+ // 'amount' is index 1, not being sorted
+ const amountTh = container.querySelectorAll('th')[1];
+ expect(amountTh).toHaveAttribute('aria-sort', 'none');
+ });
+
+ it('displays ascending sort icon when column is sorted asc', () => {
+ renderHeader({
+ tableState: makeTableState({ sortBy: 'title', sortDir: 'asc' }),
+ });
+ expect(screen.getByText(/Title.*↑/)).toBeInTheDocument();
+ });
+
+ it('displays descending sort icon when column is sorted desc', () => {
+ renderHeader({
+ tableState: makeTableState({ sortBy: 'title', sortDir: 'desc' }),
+ });
+ expect(screen.getByText(/Title.*↓/)).toBeInTheDocument();
+ });
+ });
+
+ describe('sort click handling', () => {
+ it('calls onSort with column key when sortable column header is clicked', async () => {
+ const user = userEvent.setup();
+ const mockOnSort = jest.fn();
+ const { container } = renderHeader({ onSort: mockOnSort });
+ const titleTh = container.querySelectorAll('th')[0];
+ await user.click(titleTh);
+ expect(mockOnSort).toHaveBeenCalledWith('title', undefined);
+ });
+
+ it('does not call onSort when non-sortable column header is clicked', async () => {
+ const user = userEvent.setup();
+ const mockOnSort = jest.fn();
+ const { container } = renderHeader({ onSort: mockOnSort });
+ // 'id' column (index 2) is not sortable
+ const idTh = container.querySelectorAll('th')[2];
+ await user.click(idTh);
+ expect(mockOnSort).not.toHaveBeenCalled();
+ });
+
+ it('passes sortKey to onSort when column has a custom sortKey', async () => {
+ const user = userEvent.setup();
+ const mockOnSort = jest.fn();
+ const columnsWithSortKey: ColumnDef[] = [
+ {
+ key: 'title',
+ label: 'Title',
+ sortable: true,
+ sortKey: 'title_text',
+ render: (item) => item.title,
+ },
+ ];
+ const { container } = renderHeader({
+ columns: columnsWithSortKey,
+ visibleColumns: new Set(['title']),
+ onSort: mockOnSort,
+ });
+ await user.click(container.querySelector('th')!);
+ expect(mockOnSort).toHaveBeenCalledWith('title', 'title_text');
+ });
+ });
+
+ describe('filter button', () => {
+ it('shows filter button for filterable column with filterParamKey and filterType', () => {
+ renderHeader();
+ expect(screen.getByRole('button', { name: /filter by title/i })).toBeInTheDocument();
+ });
+
+ it('filter button click stops propagation and does not trigger sort', async () => {
+ const user = userEvent.setup();
+ const mockOnSort = jest.fn();
+ renderHeader({ onSort: mockOnSort });
+ const filterBtn = screen.getByRole('button', { name: /filter by title/i });
+ await user.click(filterBtn);
+ // onSort should not be called because click stops propagation
+ expect(mockOnSort).not.toHaveBeenCalled();
+ });
+
+ it('filter button toggles filter popover visibility on click', async () => {
+ const user = userEvent.setup();
+ const { container } = renderHeader();
+ const filterBtn = screen.getByRole('button', { name: /filter by title/i });
+
+ // Before click: no dialog
+ expect(screen.queryByRole('dialog')).not.toBeInTheDocument();
+
+ // Click to open — note: getBoundingClientRect returns zeros in jsdom
+ // so we need a mock
+ jest.spyOn(filterBtn, 'getBoundingClientRect').mockReturnValue({
+ bottom: 40,
+ top: 20,
+ left: 100,
+ right: 200,
+ width: 100,
+ height: 20,
+ x: 100,
+ y: 20,
+ toJSON: () => ({}),
+ } as DOMRect);
+
+ await user.click(filterBtn);
+ expect(screen.getByRole('dialog')).toBeInTheDocument();
+ });
+
+ it('filter button has active class when filter is active for that column', () => {
+ const activeFilters = new Map([['title', { value: 'test' }]]);
+ renderHeader({
+ tableState: makeTableState({ filters: activeFilters }),
+ });
+ // Filter button should exist and have an active class
+ const filterBtn = screen.getByRole('button', { name: /filter by title/i });
+ // The class name uses CSS modules (identity-obj-proxy returns class name as-is)
+ expect(filterBtn.className).toContain('tableHeaderFilterButtonActive');
+ });
+ });
+
+ describe('actions column translation (#1137)', () => {
+ it('renders an "Actions" column header when hasActions is true', () => {
+ const { container } = renderHeader({ columns: DEFAULT_COLUMNS });
+ // Re-render with hasActions
+ const { container: c } = render(
+
+
+ columns={DEFAULT_COLUMNS}
+ visibleColumns={new Set(DEFAULT_COLUMNS.map((col) => col.key))}
+ tableState={makeTableState()}
+ onSort={jest.fn()}
+ onFilter={jest.fn()}
+ hasActions={true}
+ />
+
,
+ );
+ const headers = c.querySelectorAll('th');
+ const headerTexts = Array.from(headers).map((th) => th.textContent?.trim());
+ expect(headerTexts).toContain('Actions');
+ });
+
+ it('does NOT render an "Actions" column header when hasActions is false', () => {
+ renderHeader();
+ // Default render has no hasActions prop (undefined = falsy)
+ expect(screen.queryByText('Actions')).not.toBeInTheDocument();
+ });
+
+ it('does NOT render an "Actions" column header when hasActions is omitted', () => {
+ const { container } = render(
+
+
+ columns={DEFAULT_COLUMNS}
+ visibleColumns={new Set(DEFAULT_COLUMNS.map((col) => col.key))}
+ tableState={makeTableState()}
+ onSort={jest.fn()}
+ onFilter={jest.fn()}
+ />
+
,
+ );
+ const headers = container.querySelectorAll('th');
+ const headerTexts = Array.from(headers).map((th) => th.textContent?.trim());
+ expect(headerTexts).not.toContain('Actions');
+ });
+
+ it('actions column is added after all data columns', () => {
+ const { container } = render(
+
+
+ columns={DEFAULT_COLUMNS}
+ visibleColumns={new Set(DEFAULT_COLUMNS.map((col) => col.key))}
+ tableState={makeTableState()}
+ onSort={jest.fn()}
+ onFilter={jest.fn()}
+ hasActions={true}
+ />
+
,
+ );
+ const headers = container.querySelectorAll('th');
+ // Actions should be the last header
+ expect(headers[headers.length - 1].textContent?.trim()).toBe('Actions');
+ });
+ });
+});
diff --git a/client/src/components/DataTable/DataTableHeader.tsx b/client/src/components/DataTable/DataTableHeader.tsx
new file mode 100644
index 000000000..e83a51270
--- /dev/null
+++ b/client/src/components/DataTable/DataTableHeader.tsx
@@ -0,0 +1,145 @@
+import { useEffect, useRef, useState } from 'react';
+import { useTranslation } from 'react-i18next';
+import type { FilterMeta } from '@cornerstone/shared';
+import type { ColumnDef, TableState } from './DataTable.js';
+import { DataTableFilterPopover } from './DataTableFilterPopover.js';
+import styles from './DataTable.module.css';
+
+export interface DataTableHeaderProps {
+ columns: ColumnDef[];
+ visibleColumns: Set;
+ tableState: TableState;
+ onSort: (columnKey: string, columnSortKey?: string) => void;
+ onFilter: (paramKey: string, value: string | null) => void;
+ hasActions?: boolean;
+ filterMeta?: FilterMeta;
+}
+
+/**
+ * Table header with sortable columns and per-column filter buttons
+ */
+export function DataTableHeader({
+ columns,
+ visibleColumns,
+ tableState,
+ onSort,
+ onFilter,
+ hasActions,
+ filterMeta,
+}: DataTableHeaderProps) {
+ const { t } = useTranslation('common');
+ const [activeFilterColumn, setActiveFilterColumn] = useState(null);
+ const filterTriggerRefs = useRef>({});
+
+ // Close filter popover on outside click or Escape
+ useEffect(() => {
+ if (!activeFilterColumn) return;
+ const handleClickOutside = (e: MouseEvent) => {
+ const target = e.target as HTMLElement;
+ if (
+ !target.closest(`.${styles.filterPopover}`) &&
+ !target.closest(`.${styles.tableHeaderFilterButton}`)
+ ) {
+ setActiveFilterColumn(null);
+ }
+ };
+ const handleEscape = (e: KeyboardEvent) => {
+ if (e.key === 'Escape') setActiveFilterColumn(null);
+ };
+ document.addEventListener('mousedown', handleClickOutside);
+ document.addEventListener('keydown', handleEscape);
+ return () => {
+ document.removeEventListener('mousedown', handleClickOutside);
+ document.removeEventListener('keydown', handleEscape);
+ };
+ }, [activeFilterColumn]);
+
+ const visibleCols = columns.filter((col) => visibleColumns.has(col.key));
+
+ const renderSortIcon = (columnKey: string, columnSortKey?: string) => {
+ const sortKey = columnSortKey || columnKey;
+ if (tableState.sortBy !== sortKey) return null;
+ if (tableState.sortDir === 'asc') return ' ↑';
+ if (tableState.sortDir === 'desc') return ' ↓';
+ return null;
+ };
+
+ const getSortAttribute = (columnKey: string, columnSortKey?: string) => {
+ const sortKey = columnSortKey || columnKey;
+ if (tableState.sortBy !== sortKey) return 'none';
+ if (tableState.sortDir === 'asc') return 'ascending';
+ if (tableState.sortDir === 'desc') return 'descending';
+ return 'none';
+ };
+
+ return (
+
+
+ {visibleCols.map((col) => (
+ col.sortable && onSort(col.key, col.sortKey)}
+ aria-sort={getSortAttribute(col.key, col.sortKey)}
+ >
+
+
+ {col.label}
+ {renderSortIcon(col.key, col.sortKey)}
+
+ {col.filterable && col.filterType && (col.filterParamKey || col.getValue) && (
+
{
+ if (el) filterTriggerRefs.current[col.key] = el;
+ }}
+ type="button"
+ className={`${styles.tableHeaderFilterButton} ${
+ tableState.filters.has(col.filterParamKey || col.key)
+ ? styles.tableHeaderFilterButtonActive
+ : ''
+ }`}
+ onClick={(e) => {
+ e.stopPropagation();
+ setActiveFilterColumn(activeFilterColumn === col.key ? null : col.key);
+ }}
+ aria-label={t('dataTable.filter.filterByColumn', { column: col.label })}
+ title={t('dataTable.filter.filterByColumn', { column: col.label })}
+ >
+
+
+
+
+ )}
+
+
+ {activeFilterColumn === col.key &&
+ col.filterable &&
+ col.filterType &&
+ (col.filterParamKey || col.getValue) &&
+ filterTriggerRefs.current[col.key] && (
+ {
+ onFilter(col.filterParamKey || col.key, value || null);
+ }}
+ triggerRect={filterTriggerRefs.current[col.key].getBoundingClientRect()}
+ />
+ )}
+
+ ))}
+ {hasActions && {t('actions')} }
+
+
+ );
+}
diff --git a/client/src/components/DataTable/DataTablePagination.test.tsx b/client/src/components/DataTable/DataTablePagination.test.tsx
new file mode 100644
index 000000000..d89a01d15
--- /dev/null
+++ b/client/src/components/DataTable/DataTablePagination.test.tsx
@@ -0,0 +1,187 @@
+import { describe, it, expect, jest } from '@jest/globals';
+import { render, screen } from '@testing-library/react';
+import userEvent from '@testing-library/user-event';
+import { DataTablePagination } from './DataTablePagination.js';
+
+function renderPagination({
+ currentPage = 1,
+ totalPages = 5,
+ totalItems = 100,
+ pageSize = 25,
+ onPageChange = jest.fn(),
+ onPageSizeChange,
+}: {
+ currentPage?: number;
+ totalPages?: number;
+ totalItems?: number;
+ pageSize?: number;
+ onPageChange?: jest.Mock;
+ onPageSizeChange?: jest.Mock;
+} = {}) {
+ return render(
+ ,
+ );
+}
+
+describe('DataTablePagination', () => {
+ describe('rendering', () => {
+ it('renders nothing when totalPages <= 1', () => {
+ const { container } = renderPagination({ totalPages: 1 });
+ expect(container).toBeEmptyDOMElement();
+ });
+
+ it('renders pagination when totalPages > 1', () => {
+ renderPagination({ totalPages: 3 });
+ expect(screen.getByRole('button', { name: /previous/i })).toBeInTheDocument();
+ expect(screen.getByRole('button', { name: /next/i })).toBeInTheDocument();
+ });
+
+ it('renders page number buttons', () => {
+ renderPagination({ totalPages: 3, currentPage: 1 });
+ expect(screen.getByRole('button', { name: '1' })).toBeInTheDocument();
+ expect(screen.getByRole('button', { name: '2' })).toBeInTheDocument();
+ expect(screen.getByRole('button', { name: '3' })).toBeInTheDocument();
+ });
+
+ it('marks current page button with aria-current="page"', () => {
+ renderPagination({ totalPages: 3, currentPage: 2 });
+ expect(screen.getByRole('button', { name: '2' })).toHaveAttribute('aria-current', 'page');
+ });
+
+ it('does not mark other pages with aria-current', () => {
+ renderPagination({ totalPages: 3, currentPage: 2 });
+ expect(screen.getByRole('button', { name: '1' })).not.toHaveAttribute('aria-current');
+ });
+
+ it('shows "Showing X–Y of N items" info text', () => {
+ renderPagination({ currentPage: 2, totalPages: 4, totalItems: 100, pageSize: 25 });
+ // Showing 26–50 of 100 items
+ expect(screen.getByText(/26/)).toBeInTheDocument();
+ expect(screen.getByText(/50/)).toBeInTheDocument();
+ expect(screen.getByText(/100/)).toBeInTheDocument();
+ });
+
+ it('caps "to" value at totalItems on last page', () => {
+ renderPagination({ currentPage: 4, totalPages: 4, totalItems: 90, pageSize: 25 });
+ // Page 4: showing 76–90 of 90
+ expect(screen.getByText(/76/)).toBeInTheDocument();
+ expect(screen.getByText(/90/)).toBeInTheDocument();
+ });
+ });
+
+ describe('boundary disabling', () => {
+ it('Prev button is disabled when on page 1', () => {
+ renderPagination({ currentPage: 1, totalPages: 5 });
+ expect(screen.getByRole('button', { name: /previous/i })).toBeDisabled();
+ });
+
+ it('Prev button is enabled when not on page 1', () => {
+ renderPagination({ currentPage: 2, totalPages: 5 });
+ expect(screen.getByRole('button', { name: /previous/i })).not.toBeDisabled();
+ });
+
+ it('Next button is disabled when on last page', () => {
+ renderPagination({ currentPage: 5, totalPages: 5 });
+ expect(screen.getByRole('button', { name: /next/i })).toBeDisabled();
+ });
+
+ it('Next button is enabled when not on last page', () => {
+ renderPagination({ currentPage: 3, totalPages: 5 });
+ expect(screen.getByRole('button', { name: /next/i })).not.toBeDisabled();
+ });
+ });
+
+ describe('page navigation', () => {
+ it('clicking page 2 calls onPageChange(2)', async () => {
+ const user = userEvent.setup();
+ const mockOnPageChange = jest.fn();
+ renderPagination({ currentPage: 1, totalPages: 5, onPageChange: mockOnPageChange });
+ await user.click(screen.getByRole('button', { name: '2' }));
+ expect(mockOnPageChange).toHaveBeenCalledWith(2);
+ });
+
+ it('clicking Next calls onPageChange with currentPage + 1', async () => {
+ const user = userEvent.setup();
+ const mockOnPageChange = jest.fn();
+ renderPagination({ currentPage: 3, totalPages: 5, onPageChange: mockOnPageChange });
+ await user.click(screen.getByRole('button', { name: /next/i }));
+ expect(mockOnPageChange).toHaveBeenCalledWith(4);
+ });
+
+ it('clicking Prev calls onPageChange with currentPage - 1', async () => {
+ const user = userEvent.setup();
+ const mockOnPageChange = jest.fn();
+ renderPagination({ currentPage: 3, totalPages: 5, onPageChange: mockOnPageChange });
+ await user.click(screen.getByRole('button', { name: /previous/i }));
+ expect(mockOnPageChange).toHaveBeenCalledWith(2);
+ });
+ });
+
+ describe('page number windowing', () => {
+ it('shows all pages when totalPages <= 5', () => {
+ renderPagination({ currentPage: 1, totalPages: 4 });
+ [1, 2, 3, 4].forEach((n) => {
+ expect(screen.getByRole('button', { name: String(n) })).toBeInTheDocument();
+ });
+ });
+
+ it('shows 5 page buttons when totalPages > 5', () => {
+ renderPagination({ currentPage: 5, totalPages: 20 });
+ // Count page number buttons (exclude prev/next)
+ const allButtons = screen.getAllByRole('button');
+ const pageButtons = allButtons.filter((b) => /^\d+$/.test(b.textContent || ''));
+ expect(pageButtons).toHaveLength(5);
+ });
+
+ it('shows pages starting from 1 when on page 1 of many', () => {
+ renderPagination({ currentPage: 1, totalPages: 20 });
+ expect(screen.getByRole('button', { name: '1' })).toBeInTheDocument();
+ expect(screen.getByRole('button', { name: '5' })).toBeInTheDocument();
+ });
+
+ it('shows last 5 pages when near the end', () => {
+ renderPagination({ currentPage: 19, totalPages: 20 });
+ expect(screen.getByRole('button', { name: '16' })).toBeInTheDocument();
+ expect(screen.getByRole('button', { name: '20' })).toBeInTheDocument();
+ });
+ });
+
+ describe('page size selector', () => {
+ it('does not render page size selector when onPageSizeChange not provided', () => {
+ renderPagination();
+ expect(screen.queryByRole('combobox')).not.toBeInTheDocument();
+ });
+
+ it('renders page size selector when onPageSizeChange provided', () => {
+ renderPagination({ onPageSizeChange: jest.fn() });
+ expect(screen.getByRole('combobox')).toBeInTheDocument();
+ });
+
+ it('shows current pageSize as selected value in selector', () => {
+ renderPagination({ pageSize: 50, onPageSizeChange: jest.fn() });
+ expect(screen.getByRole('combobox')).toHaveValue('50');
+ });
+
+ it('calls onPageSizeChange with parsed integer when size selected', async () => {
+ const user = userEvent.setup();
+ const mockOnPageSizeChange = jest.fn();
+ renderPagination({ pageSize: 25, onPageSizeChange: mockOnPageSizeChange });
+ await user.selectOptions(screen.getByRole('combobox'), '100');
+ expect(mockOnPageSizeChange).toHaveBeenCalledWith(100);
+ });
+
+ it('renders all page size options: 10, 25, 50, 100', () => {
+ renderPagination({ onPageSizeChange: jest.fn() });
+ const select = screen.getByRole('combobox') as HTMLSelectElement;
+ const optionValues = Array.from(select.options).map((o) => o.value);
+ expect(optionValues).toEqual(['10', '25', '50', '100']);
+ });
+ });
+});
diff --git a/client/src/components/DataTable/DataTablePagination.tsx b/client/src/components/DataTable/DataTablePagination.tsx
new file mode 100644
index 000000000..d508874d7
--- /dev/null
+++ b/client/src/components/DataTable/DataTablePagination.tsx
@@ -0,0 +1,123 @@
+import { useMemo } from 'react';
+import { useTranslation } from 'react-i18next';
+import styles from './DataTable.module.css';
+
+export interface DataTablePaginationProps {
+ currentPage: number;
+ totalPages: number;
+ totalItems: number;
+ pageSize: number;
+ onPageChange: (page: number) => void;
+ onPageSizeChange?: (size: number) => void;
+}
+
+const PAGE_SIZE_OPTIONS = [10, 25, 50, 100];
+
+/**
+ * Pagination controls
+ * Desktop: full page list + page size selector
+ * Mobile: simplified Prev/Next + "Page N of M"
+ */
+export function DataTablePagination({
+ currentPage,
+ totalPages,
+ totalItems,
+ pageSize,
+ onPageChange,
+ onPageSizeChange,
+}: DataTablePaginationProps) {
+ const { t } = useTranslation('common');
+
+ const pageNumbers = useMemo(() => {
+ if (totalPages <= 5) {
+ return Array.from({ length: totalPages }, (_, i) => i + 1);
+ }
+
+ if (currentPage <= 3) {
+ return Array.from({ length: 5 }, (_, i) => i + 1);
+ }
+
+ if (currentPage >= totalPages - 2) {
+ return Array.from({ length: 5 }, (_, i) => totalPages - 4 + i);
+ }
+
+ return Array.from({ length: 5 }, (_, i) => currentPage - 2 + i);
+ }, [currentPage, totalPages]);
+
+ const showingFrom = (currentPage - 1) * pageSize + 1;
+ const showingTo = Math.min(currentPage * pageSize, totalItems);
+
+ if (totalPages <= 1) {
+ return null;
+ }
+
+ return (
+
+
+ {t('dataTable.pagination.showing', {
+ from: showingFrom,
+ to: showingTo,
+ total: totalItems,
+ })}
+
+
+
+
onPageChange(currentPage - 1)}
+ disabled={currentPage === 1}
+ aria-label={t('dataTable.pagination.previous')}
+ >
+ {t('dataTable.pagination.previous')}
+
+
+
+ {pageNumbers.map((pageNum) => (
+ onPageChange(pageNum)}
+ aria-current={currentPage === pageNum ? 'page' : undefined}
+ >
+ {pageNum}
+
+ ))}
+
+
+
onPageChange(currentPage + 1)}
+ disabled={currentPage === totalPages}
+ aria-label={t('dataTable.pagination.next')}
+ >
+ {t('dataTable.pagination.next')}
+
+
+
+ {onPageSizeChange && (
+
+
+ {t('dataTable.pagination.pageSize')}
+
+ onPageSizeChange(parseInt(e.target.value, 10))}
+ className={styles.pageSizeSelect}
+ >
+ {PAGE_SIZE_OPTIONS.map((size) => (
+
+ {size}
+
+ ))}
+
+
+ )}
+
+ );
+}
diff --git a/client/src/components/DataTable/DataTableRow.tsx b/client/src/components/DataTable/DataTableRow.tsx
new file mode 100644
index 000000000..7f3abf3b8
--- /dev/null
+++ b/client/src/components/DataTable/DataTableRow.tsx
@@ -0,0 +1,45 @@
+import type { ColumnDef } from './DataTable.js';
+import styles from './DataTable.module.css';
+
+export interface DataTableRowProps {
+ item: T;
+ columns: ColumnDef[];
+ visibleColumns: Set;
+ isSelected?: boolean;
+ onClick?: () => void;
+ renderActions?: (item: T) => React.ReactNode;
+}
+
+/**
+ * Single table row renderer
+ * Renders null values as em-dash
+ */
+export function DataTableRow({
+ item,
+ columns,
+ visibleColumns,
+ isSelected = false,
+ onClick,
+ renderActions,
+}: DataTableRowProps) {
+ const visibleCols = columns.filter((col) => visibleColumns.has(col.key));
+
+ return (
+
+ {visibleCols.map((col) => (
+
+ {col.render(item) ?? '—'}
+
+ ))}
+ {renderActions && (
+ e.stopPropagation()}>
+ {renderActions(item)}
+
+ )}
+
+ );
+}
diff --git a/client/src/components/DataTable/filters/BooleanFilter.test.tsx b/client/src/components/DataTable/filters/BooleanFilter.test.tsx
new file mode 100644
index 000000000..751959c43
--- /dev/null
+++ b/client/src/components/DataTable/filters/BooleanFilter.test.tsx
@@ -0,0 +1,86 @@
+import { describe, it, expect, jest } from '@jest/globals';
+import { render, screen } from '@testing-library/react';
+import userEvent from '@testing-library/user-event';
+import { BooleanFilter } from './BooleanFilter.js';
+
+describe('BooleanFilter', () => {
+ describe('rendering', () => {
+ it('renders All, Yes, and No buttons', () => {
+ render( );
+ expect(screen.getByRole('button', { name: /all/i })).toBeInTheDocument();
+ expect(screen.getByRole('button', { name: /yes/i })).toBeInTheDocument();
+ expect(screen.getByRole('button', { name: /no/i })).toBeInTheDocument();
+ });
+
+ it('renders buttons in a group with aria-label', () => {
+ render( );
+ expect(screen.getByRole('group')).toBeInTheDocument();
+ });
+ });
+
+ describe('aria-pressed state', () => {
+ it('All button has aria-pressed="true" when value is empty string', () => {
+ render( );
+ expect(screen.getByRole('button', { name: /all/i })).toHaveAttribute('aria-pressed', 'true');
+ });
+
+ it('Yes button has aria-pressed="true" when value is "true"', () => {
+ render( );
+ expect(screen.getByRole('button', { name: /yes/i })).toHaveAttribute('aria-pressed', 'true');
+ });
+
+ it('No button has aria-pressed="true" when value is "false"', () => {
+ render( );
+ expect(screen.getByRole('button', { name: /no/i })).toHaveAttribute('aria-pressed', 'true');
+ });
+
+ it('All button has aria-pressed="false" when value is "true"', () => {
+ render( );
+ expect(screen.getByRole('button', { name: /all/i })).toHaveAttribute('aria-pressed', 'false');
+ });
+
+ it('Yes button has aria-pressed="false" when value is "false"', () => {
+ render( );
+ expect(screen.getByRole('button', { name: /yes/i })).toHaveAttribute('aria-pressed', 'false');
+ });
+
+ it('No button has aria-pressed="false" when value is "true"', () => {
+ render( );
+ expect(screen.getByRole('button', { name: /no/i })).toHaveAttribute('aria-pressed', 'false');
+ });
+ });
+
+ describe('click handlers', () => {
+ it('clicking "Yes" fires onChange with "true"', async () => {
+ const user = userEvent.setup();
+ const mockOnChange = jest.fn();
+ render( );
+ await user.click(screen.getByRole('button', { name: /yes/i }));
+ expect(mockOnChange).toHaveBeenCalledWith('true');
+ });
+
+ it('clicking "No" fires onChange with "false"', async () => {
+ const user = userEvent.setup();
+ const mockOnChange = jest.fn();
+ render( );
+ await user.click(screen.getByRole('button', { name: /no/i }));
+ expect(mockOnChange).toHaveBeenCalledWith('false');
+ });
+
+ it('clicking "All" fires onChange with empty string', async () => {
+ const user = userEvent.setup();
+ const mockOnChange = jest.fn();
+ render( );
+ await user.click(screen.getByRole('button', { name: /all/i }));
+ expect(mockOnChange).toHaveBeenCalledWith('');
+ });
+
+ it('clicking same active button calls onChange again with same value', async () => {
+ const user = userEvent.setup();
+ const mockOnChange = jest.fn();
+ render( );
+ await user.click(screen.getByRole('button', { name: /yes/i }));
+ expect(mockOnChange).toHaveBeenCalledWith('true');
+ });
+ });
+});
diff --git a/client/src/components/DataTable/filters/BooleanFilter.tsx b/client/src/components/DataTable/filters/BooleanFilter.tsx
new file mode 100644
index 000000000..b131cff1c
--- /dev/null
+++ b/client/src/components/DataTable/filters/BooleanFilter.tsx
@@ -0,0 +1,64 @@
+import { useCallback } from 'react';
+import { useTranslation } from 'react-i18next';
+import styles from './Filter.module.css';
+
+export interface BooleanFilterProps {
+ value: string;
+ onChange: (value: string) => void;
+}
+
+/**
+ * 3-button segmented control for boolean filtering
+ * Values: '' (all), 'true' (yes), 'false' (no)
+ */
+export function BooleanFilter({ value, onChange }: BooleanFilterProps) {
+ const { t } = useTranslation('common');
+
+ const handleSelect = useCallback(
+ (val: string) => {
+ onChange(val);
+ },
+ [onChange],
+ );
+
+ return (
+
+
+ handleSelect('')}
+ aria-pressed={value === ''}
+ >
+ {t('dataTable.filter.all')}
+
+ handleSelect('true')}
+ aria-pressed={value === 'true'}
+ >
+ {t('dataTable.filter.yes')}
+
+ handleSelect('false')}
+ aria-pressed={value === 'false'}
+ >
+ {t('dataTable.filter.no')}
+
+
+
+ );
+}
diff --git a/client/src/components/DataTable/filters/DateFilter.test.tsx b/client/src/components/DataTable/filters/DateFilter.test.tsx
new file mode 100644
index 000000000..d6e94155b
--- /dev/null
+++ b/client/src/components/DataTable/filters/DateFilter.test.tsx
@@ -0,0 +1,257 @@
+import { describe, it, expect, jest } from '@jest/globals';
+import type { ReactNode } from 'react';
+import { render as rtlRender, screen, fireEvent } from '@testing-library/react';
+import { DateFilter } from './DateFilter.js';
+import { LocaleProvider } from '../../../contexts/LocaleContext.js';
+
+/**
+ * Custom render function that wraps the component with LocaleProvider
+ */
+function render(component: ReactNode, options?: Parameters[1]) {
+ return rtlRender({component} , options);
+}
+
+/**
+ * Helper to find a day button by its text content
+ */
+function findDayButton(container: HTMLElement, dayNumber: number): HTMLButtonElement | null {
+ const dayButtons = Array.from(container.querySelectorAll('button')).filter(
+ (btn) =>
+ btn.textContent === String(dayNumber) &&
+ !btn.getAttribute('aria-label')?.includes('Previous') &&
+ !btn.getAttribute('aria-label')?.includes('Next'),
+ );
+ return dayButtons[0] || null;
+}
+
+describe('DateFilter', () => {
+ describe('rendering', () => {
+ it('renders the DateRangePicker component with calendar grid', () => {
+ const { container } = render( );
+ const grid = container.querySelector('[role="grid"]');
+ expect(grid).toBeInTheDocument();
+ });
+
+ it('does NOT render any elements', () => {
+ const { container } = render( );
+ const dateInputs = container.querySelectorAll('input[type="date"]');
+ expect(dateInputs).toHaveLength(0);
+ });
+
+ it('renders the calendar with day buttons', () => {
+ const { container } = render( );
+ const dayButtons = Array.from(container.querySelectorAll('button')).filter((btn) =>
+ /^\d{1,2}$/.test(btn.textContent?.trim() || ''),
+ );
+ expect(dayButtons.length).toBeGreaterThan(0);
+ });
+
+ it('does not render Apply button', () => {
+ render( );
+ expect(screen.queryByRole('button', { name: /apply/i })).not.toBeInTheDocument();
+ });
+
+ it('does not render Clear button', () => {
+ render( );
+ expect(screen.queryByRole('button', { name: /clear/i })).not.toBeInTheDocument();
+ });
+ });
+
+ describe('value parsing', () => {
+ it('parses from date from "from:2026-03-15,to:2026-03-25" format and shows it selected', () => {
+ const { container } = render(
+ ,
+ );
+ const dayBtn15 = findDayButton(container, 15);
+ expect(dayBtn15).toHaveClass('daySelected');
+ });
+
+ it('parses to date from "from:2026-03-15,to:2026-03-25" format and shows it selected', () => {
+ const { container } = render(
+ ,
+ );
+ const dayBtn25 = findDayButton(container, 25);
+ expect(dayBtn25).toHaveClass('daySelected');
+ });
+
+ it('renders with no selected days when value is empty', () => {
+ const { container } = render( );
+ const selectedDays = Array.from(container.querySelectorAll('button')).filter(
+ (btn) => btn.getAttribute('aria-pressed') === 'true',
+ );
+ expect(selectedDays).toHaveLength(0);
+ });
+
+ it('handles "from:2026-03-15" format with no to date', () => {
+ const { container } = render( );
+ const dayBtn15 = findDayButton(container, 15);
+ expect(dayBtn15).toHaveAttribute('aria-pressed', 'true');
+ });
+ });
+
+ describe('auto-apply on selection', () => {
+ it('clicking a day when no start date does NOT call onChange (partial selection)', () => {
+ const mockOnChange = jest.fn();
+ const { container } = render( );
+ const dayBtn15 = findDayButton(container, 15);
+ fireEvent.click(dayBtn15!);
+ expect(mockOnChange).not.toHaveBeenCalled();
+ });
+
+ it('after selecting start date, clicking end date calls onChange with "from:...,to:..."', () => {
+ const mockOnChange = jest.fn();
+ const { container: container1 } = render( );
+ const dayBtn15 = findDayButton(container1, 15);
+ fireEvent.click(dayBtn15!);
+
+ mockOnChange.mockClear();
+ // Now render with startDate set
+ const { container: container2 } = render(
+ ,
+ );
+
+ const dayBtn25 = findDayButton(container2, 25);
+ fireEvent.click(dayBtn25!);
+
+ expect(mockOnChange).toHaveBeenCalledWith(
+ expect.stringMatching(/^from:2026-03-15,to:2026-03-25$/),
+ );
+ });
+
+ it('onChange is called immediately when both dates are selected', () => {
+ const mockOnChange = jest.fn();
+ const { container: container1 } = render( );
+ const dayBtn15 = findDayButton(container1, 15);
+ fireEvent.click(dayBtn15!);
+
+ mockOnChange.mockClear();
+ // Now render with startDate set
+ const { container: container2 } = render(
+ ,
+ );
+
+ const dayBtn25 = findDayButton(container2, 25);
+ fireEvent.click(dayBtn25!);
+
+ expect(mockOnChange).toHaveBeenCalledTimes(1);
+ });
+ });
+
+ describe('value format', () => {
+ it('partial selection does not emit onChange', () => {
+ const mockOnChange = jest.fn();
+ const { container } = render( );
+ const dayBtn15 = findDayButton(container, 15);
+ fireEvent.click(dayBtn15!);
+ expect(mockOnChange).not.toHaveBeenCalled();
+ });
+
+ it('when both dates are empty, onChange receives empty string', () => {
+ const mockOnChange = jest.fn();
+ const { rerender, container } = render(
+ ,
+ );
+ const dayBtn15 = findDayButton(container, 15);
+
+ // Click the start date again to clear both
+ fireEvent.click(dayBtn15!);
+
+ expect(mockOnChange).toHaveBeenCalledWith('');
+ });
+
+ it('from part uses "from:" prefix when both dates set', () => {
+ const mockOnChange = jest.fn();
+ const { container: container1 } = render( );
+ const dayBtn15 = findDayButton(container1, 15);
+ fireEvent.click(dayBtn15!);
+
+ mockOnChange.mockClear();
+ const { container: container2 } = render(
+ ,
+ );
+
+ const dayBtn25 = findDayButton(container2, 25);
+ fireEvent.click(dayBtn25!);
+
+ const calledValue = mockOnChange.mock.calls[0][0];
+ expect(calledValue).toMatch(/^from:/);
+ });
+
+ it('to part uses "to:" prefix', () => {
+ const mockOnChange = jest.fn();
+ const { container: container1 } = render( );
+ const dayBtn15 = findDayButton(container1, 15);
+ fireEvent.click(dayBtn15!);
+
+ mockOnChange.mockClear();
+ const { container: container2 } = render(
+ ,
+ );
+
+ const dayBtn25 = findDayButton(container2, 25);
+ fireEvent.click(dayBtn25!);
+
+ const calledValue = mockOnChange.mock.calls[0][0];
+ expect(calledValue).toMatch(/to:/);
+ });
+
+ it('parts are comma-separated', () => {
+ const mockOnChange = jest.fn();
+ const { container: container1 } = render( );
+ const dayBtn15 = findDayButton(container1, 15);
+ fireEvent.click(dayBtn15!);
+
+ mockOnChange.mockClear();
+ const { container: container2 } = render(
+ ,
+ );
+
+ const dayBtn25 = findDayButton(container2, 25);
+ fireEvent.click(dayBtn25!);
+
+ const calledValue = mockOnChange.mock.calls[0][0];
+ expect(calledValue).toMatch(/from:.+,to:.+/);
+ });
+ });
+
+ describe('calendar interaction', () => {
+ it('hovering over a date after start shows range highlight', () => {
+ const { rerender, container } = render(
+ ,
+ );
+ const dayBtn25 = findDayButton(container, 25);
+ fireEvent.mouseEnter(dayBtn25!);
+ // The range should be visually highlighted via dayInRange class
+ const dayCellContainer = dayBtn25?.closest('[role="gridcell"]');
+ expect(dayCellContainer).toBeInTheDocument();
+ });
+
+ it('dates before start date have dayDisabled CSS class but remain clickable to reset selection', () => {
+ const { container } = render( );
+ const dayBtn10 = findDayButton(container, 10);
+ expect(dayBtn10).toBeTruthy();
+ expect(dayBtn10).not.toBeDisabled();
+ expect(dayBtn10).toHaveClass('dayDisabled');
+ });
+ });
+
+ describe('edge cases', () => {
+ it('handles parsing "from:" without a value', () => {
+ const { container } = render( );
+ const grid = container.querySelector('[role="grid"]');
+ expect(grid).toBeInTheDocument(); // Should render without crashing
+ });
+
+ it('handles parsing "to:" without a value', () => {
+ const { container } = render( );
+ const grid = container.querySelector('[role="grid"]');
+ expect(grid).toBeInTheDocument(); // Should render without crashing
+ });
+
+ it('ignores malformed date strings in filter value', () => {
+ const { container } = render( );
+ const grid = container.querySelector('[role="grid"]');
+ expect(grid).toBeInTheDocument(); // Should render without crashing
+ });
+ });
+});
diff --git a/client/src/components/DataTable/filters/DateFilter.tsx b/client/src/components/DataTable/filters/DateFilter.tsx
new file mode 100644
index 000000000..4611b96d4
--- /dev/null
+++ b/client/src/components/DataTable/filters/DateFilter.tsx
@@ -0,0 +1,60 @@
+import { useCallback, useState, useEffect } from 'react';
+import { useTranslation } from 'react-i18next';
+import { DateRangePicker } from '../../DateRangePicker/index.js';
+import styles from './Filter.module.css';
+
+export interface DateFilterProps {
+ value: string;
+ onChange: (value: string) => void;
+}
+
+/**
+ * From/to date range filter for DataTable
+ * Stores as "from:YYYY-MM-DD,to:YYYY-MM-DD" format
+ * Auto-applies on date selection change
+ */
+export function DateFilter({ value, onChange }: DateFilterProps) {
+ const { t } = useTranslation('common');
+
+ const from = value.match(/from:(\d{4}-\d{2}-\d{2})/)?.[1] ?? '';
+ const to = value.match(/to:(\d{4}-\d{2}-\d{2})/)?.[1] ?? '';
+
+ // Track intermediate dates locally so DateRangePicker sees updates immediately
+ const [localFrom, setLocalFrom] = useState('');
+ const [localTo, setLocalTo] = useState('');
+
+ // Sync local state when the parent value prop changes (e.g., clear filters)
+ useEffect(() => {
+ setLocalFrom(from);
+ setLocalTo(to);
+ }, [from, to]);
+
+ const handleChange = useCallback(
+ (newFrom: string, newTo: string) => {
+ // Always update local state so DateRangePicker sees the updated dates
+ setLocalFrom(newFrom);
+ setLocalTo(newTo);
+
+ // Only emit onChange when BOTH dates are set or BOTH are cleared
+ if (newFrom && newTo) {
+ onChange(`from:${newFrom},to:${newTo}`);
+ } else if (!newFrom && !newTo) {
+ onChange('');
+ }
+ // Partial selection: don't emit to parent — let user finish picking
+ // But DateRangePicker will see the updated localFrom/localTo via re-render
+ },
+ [onChange],
+ );
+
+ return (
+
+
+
+ );
+}
diff --git a/client/src/components/DataTable/filters/EntityFilter.test.tsx b/client/src/components/DataTable/filters/EntityFilter.test.tsx
new file mode 100644
index 000000000..5f697e832
--- /dev/null
+++ b/client/src/components/DataTable/filters/EntityFilter.test.tsx
@@ -0,0 +1,121 @@
+import { describe, it, expect, jest, beforeEach } from '@jest/globals';
+import { render, screen } from '@testing-library/react';
+import userEvent from '@testing-library/user-event';
+
+interface TestEntity {
+ id: string;
+ name: string;
+}
+
+// Mock SearchPicker to avoid async complexity — just render a simple input
+const mockSearchPickerOnChange = jest.fn<(id: string) => void>();
+
+jest.unstable_mockModule('../../SearchPicker/SearchPicker.js', () => ({
+ SearchPicker: ({
+ value,
+ onChange,
+ placeholder,
+ }: {
+ value: string;
+ onChange: (id: string) => void;
+ placeholder?: string;
+ }) => {
+ // Keep a ref to onChange so tests can trigger it
+ mockSearchPickerOnChange.mockImplementation(onChange);
+ return (
+ onChange(e.target.value)}
+ />
+ );
+ },
+}));
+
+import type * as EntityFilterModule from './EntityFilter.js';
+
+let EntityFilter: (typeof EntityFilterModule)['EntityFilter'];
+
+const mockSearchFn = jest.fn<(query: string, excludeIds: string[]) => Promise>();
+const mockRenderItem = (item: TestEntity) => ({ id: item.id, label: item.name });
+
+beforeEach(async () => {
+ ({ EntityFilter } = (await import('./EntityFilter.js')) as typeof EntityFilterModule);
+ mockSearchFn.mockReset();
+ mockSearchPickerOnChange.mockClear();
+});
+
+describe('EntityFilter', () => {
+ it('renders the SearchPicker component', () => {
+ render(
+ ,
+ );
+ expect(screen.getByTestId('mock-search-picker')).toBeInTheDocument();
+ });
+
+ it('passes the current value to SearchPicker', () => {
+ render(
+ ,
+ );
+ expect(screen.getByTestId('mock-search-picker')).toHaveValue('entity-123');
+ });
+
+ it('passes custom placeholder to SearchPicker', () => {
+ render(
+ ,
+ );
+ expect(screen.getByPlaceholderText('Pick a vendor...')).toBeInTheDocument();
+ });
+
+ it('calls onChange when SearchPicker selection changes', async () => {
+ const user = userEvent.setup();
+ const mockOnChange = jest.fn();
+ render(
+ ,
+ );
+ const input = screen.getByTestId('mock-search-picker');
+ await user.clear(input);
+ await user.type(input, 'vendor-42');
+ // The mock input calls onChange on each character — find the last call
+ const calls = mockOnChange.mock.calls as [string][];
+ const lastCall = calls[calls.length - 1][0];
+ expect(lastCall).toContain('vendor-4');
+ });
+
+ it('wires onChange directly to SearchPicker onChange prop', () => {
+ const mockOnChange = jest.fn();
+ render(
+ ,
+ );
+ // Simulate direct invocation of the onChange prop
+ mockSearchPickerOnChange('entity-999');
+ expect(mockOnChange).toHaveBeenCalledWith('entity-999');
+ });
+});
diff --git a/client/src/components/DataTable/filters/EntityFilter.tsx b/client/src/components/DataTable/filters/EntityFilter.tsx
new file mode 100644
index 000000000..cfa519d75
--- /dev/null
+++ b/client/src/components/DataTable/filters/EntityFilter.tsx
@@ -0,0 +1,36 @@
+import { useTranslation } from 'react-i18next';
+import { SearchPicker, type SearchPickerProps } from '../../SearchPicker/SearchPicker.js';
+
+export interface EntityFilterProps {
+ value: string;
+ onChange: (value: string) => void;
+ searchFn: SearchPickerProps['searchFn'];
+ renderItem: SearchPickerProps['renderItem'];
+ placeholder?: string;
+}
+
+/**
+ * Entity selector filter using SearchPicker
+ * Allows searching and selecting from related entities
+ */
+export function EntityFilter({
+ value,
+ onChange,
+ searchFn,
+ renderItem,
+ placeholder,
+}: EntityFilterProps) {
+ const { t } = useTranslation('common');
+
+ return (
+
+ value={value}
+ onChange={onChange}
+ excludeIds={[]}
+ searchFn={searchFn}
+ renderItem={renderItem}
+ placeholder={placeholder || t('dataTable.filter.searchPlaceholder')}
+ showItemsOnFocus
+ />
+ );
+}
diff --git a/client/src/components/DataTable/filters/EnumFilter.test.tsx b/client/src/components/DataTable/filters/EnumFilter.test.tsx
new file mode 100644
index 000000000..7182b5772
--- /dev/null
+++ b/client/src/components/DataTable/filters/EnumFilter.test.tsx
@@ -0,0 +1,152 @@
+import { describe, it, expect, jest } from '@jest/globals';
+import { render, screen } from '@testing-library/react';
+import userEvent from '@testing-library/user-event';
+import type { EnumOption } from '../DataTable.js';
+import { EnumFilter } from './EnumFilter.js';
+
+const OPTIONS: EnumOption[] = [
+ { value: 'active', label: 'Active' },
+ { value: 'inactive', label: 'Inactive' },
+ { value: 'pending', label: 'Pending' },
+];
+
+describe('EnumFilter', () => {
+ describe('rendering', () => {
+ it('renders a checkbox for each option', () => {
+ render( );
+ const checkboxes = screen.getAllByRole('checkbox');
+ expect(checkboxes).toHaveLength(3);
+ });
+
+ it('renders option labels', () => {
+ render( );
+ expect(screen.getByText('Active')).toBeInTheDocument();
+ expect(screen.getByText('Inactive')).toBeInTheDocument();
+ expect(screen.getByText('Pending')).toBeInTheDocument();
+ });
+
+ it('pre-checks options that are in the initial value', () => {
+ render( );
+ expect(screen.getByRole('checkbox', { name: 'Active' })).toBeChecked();
+ expect(screen.getByRole('checkbox', { name: 'Inactive' })).not.toBeChecked();
+ expect(screen.getByRole('checkbox', { name: 'Pending' })).toBeChecked();
+ });
+
+ it('does not render an Apply button', () => {
+ render( );
+ expect(screen.queryByRole('button', { name: /apply/i })).not.toBeInTheDocument();
+ });
+
+ it('does not render a Clear button', () => {
+ render( );
+ expect(screen.queryByRole('button', { name: /clear/i })).not.toBeInTheDocument();
+ });
+
+ it('renders Select All quick action button', () => {
+ render( );
+ expect(screen.getByRole('button', { name: /select all/i })).toBeInTheDocument();
+ });
+
+ it('renders Select None quick action button', () => {
+ render( );
+ expect(screen.getByRole('button', { name: /select none/i })).toBeInTheDocument();
+ });
+ });
+
+ describe('auto-apply on checkbox change', () => {
+ it('calls onChange immediately when an unchecked option is checked', async () => {
+ const user = userEvent.setup();
+ const mockOnChange = jest.fn();
+ render( );
+ await user.click(screen.getByRole('checkbox', { name: 'Active' }));
+ expect(mockOnChange).toHaveBeenCalledWith('active');
+ });
+
+ it('calls onChange immediately when a checked option is unchecked', async () => {
+ const user = userEvent.setup();
+ const mockOnChange = jest.fn();
+ render( );
+ await user.click(screen.getByRole('checkbox', { name: 'Active' }));
+ expect(mockOnChange).toHaveBeenCalledWith('');
+ });
+
+ it('calls onChange with comma-separated values when multiple options are selected', async () => {
+ const user = userEvent.setup();
+ const mockOnChange = jest.fn();
+ render( );
+ await user.click(screen.getByRole('checkbox', { name: 'Active' }));
+ await user.click(screen.getByRole('checkbox', { name: 'Pending' }));
+ // Second call should contain both active and pending
+ const lastCallArg = (
+ mockOnChange.mock.calls[mockOnChange.mock.calls.length - 1] as [string]
+ )[0];
+ expect(lastCallArg.split(',')).toEqual(expect.arrayContaining(['active', 'pending']));
+ expect(lastCallArg.split(',')).toHaveLength(2);
+ });
+
+ it('calls onChange after each individual checkbox click, not batched', async () => {
+ const user = userEvent.setup();
+ const mockOnChange = jest.fn();
+ render( );
+ await user.click(screen.getByRole('checkbox', { name: 'Active' }));
+ await user.click(screen.getByRole('checkbox', { name: 'Inactive' }));
+ expect(mockOnChange).toHaveBeenCalledTimes(2);
+ });
+
+ it('checkbox is visually checked after clicking an unchecked option', async () => {
+ const user = userEvent.setup();
+ render( );
+ const activeCheckbox = screen.getByRole('checkbox', { name: 'Active' });
+ expect(activeCheckbox).not.toBeChecked();
+ await user.click(activeCheckbox);
+ expect(activeCheckbox).toBeChecked();
+ });
+
+ it('checkbox is visually unchecked after clicking a checked option', async () => {
+ const user = userEvent.setup();
+ render( );
+ const activeCheckbox = screen.getByRole('checkbox', { name: 'Active' });
+ expect(activeCheckbox).toBeChecked();
+ await user.click(activeCheckbox);
+ expect(activeCheckbox).not.toBeChecked();
+ });
+ });
+
+ describe('Select All quick action', () => {
+ it('calls onChange with all option values when Select All clicked', async () => {
+ const user = userEvent.setup();
+ const mockOnChange = jest.fn();
+ render( );
+ await user.click(screen.getByRole('button', { name: /select all/i }));
+ const callArg = (mockOnChange.mock.calls[0] as [string])[0];
+ expect(callArg.split(',')).toEqual(expect.arrayContaining(['active', 'inactive', 'pending']));
+ expect(callArg.split(',')).toHaveLength(3);
+ });
+
+ it('checks all checkboxes when Select All clicked', async () => {
+ const user = userEvent.setup();
+ render( );
+ await user.click(screen.getByRole('button', { name: /select all/i }));
+ const checkboxes = screen.getAllByRole('checkbox');
+ checkboxes.forEach((cb) => expect(cb).toBeChecked());
+ });
+ });
+
+ describe('Select None quick action', () => {
+ it('calls onChange with empty string when Select None clicked', async () => {
+ const user = userEvent.setup();
+ const mockOnChange = jest.fn();
+ render( );
+ await user.click(screen.getByRole('button', { name: /select none/i }));
+ expect(mockOnChange).toHaveBeenCalledWith('');
+ });
+
+ it('unchecks all checkboxes when Select None clicked', async () => {
+ const user = userEvent.setup();
+ render( );
+ await user.click(screen.getByRole('button', { name: /select none/i }));
+ const checkboxes = screen.getAllByRole('checkbox');
+ checkboxes.forEach((cb) => expect(cb).not.toBeChecked());
+ });
+ });
+});
diff --git a/client/src/components/DataTable/filters/EnumFilter.tsx b/client/src/components/DataTable/filters/EnumFilter.tsx
new file mode 100644
index 000000000..9d40abe5d
--- /dev/null
+++ b/client/src/components/DataTable/filters/EnumFilter.tsx
@@ -0,0 +1,179 @@
+import { useState, useCallback, useRef } from 'react';
+import { useTranslation } from 'react-i18next';
+import type { EnumOption, EnumHierarchyItem } from '../DataTable.js';
+import styles from './Filter.module.css';
+
+export interface EnumFilterProps {
+ value: string;
+ onChange: (value: string) => void;
+ options: EnumOption[];
+ hierarchy?: EnumHierarchyItem[];
+}
+
+/**
+ * Checkbox list filter for enum/select values
+ * Stores as comma-separated values
+ * Auto-applies on checkbox change
+ * Supports hierarchical options (parent-child relationships)
+ */
+export function EnumFilter({ value, onChange, options, hierarchy }: EnumFilterProps) {
+ const { t } = useTranslation('common');
+
+ const parseValue = (v: string) => new Set(v ? v.split(',') : []);
+ const [selected, setSelected] = useState(parseValue(value));
+
+ // Build parent -> children map from hierarchy
+ const { parentToChildren, allParents } = (() => {
+ if (!hierarchy) return { parentToChildren: new Map(), allParents: new Set() };
+
+ const map = new Map();
+ const parents = new Set();
+
+ for (const item of hierarchy) {
+ if (item.parentId) {
+ if (!map.has(item.parentId)) {
+ map.set(item.parentId, []);
+ }
+ map.get(item.parentId)!.push(item.id);
+ } else {
+ parents.add(item.id);
+ }
+ }
+
+ return { parentToChildren: map, allParents: parents };
+ })();
+
+ const handleToggle = useCallback(
+ (optionValue: string) => {
+ setSelected((prev) => {
+ const updated = new Set(prev);
+
+ if (updated.has(optionValue)) {
+ updated.delete(optionValue);
+ // If this is a parent, remove all children
+ const children = parentToChildren.get(optionValue);
+ if (children) {
+ for (const child of children) {
+ updated.delete(child);
+ }
+ }
+ } else {
+ updated.add(optionValue);
+ // If this is a parent, add all children
+ const children = parentToChildren.get(optionValue);
+ if (children) {
+ for (const child of children) {
+ updated.add(child);
+ }
+ }
+ }
+
+ // Auto-apply by calling onChange immediately
+ const joined = Array.from(updated).join(',');
+ onChange(joined);
+ return updated;
+ });
+ },
+ [parentToChildren, onChange],
+ );
+
+ const handleSelectAll = useCallback(() => {
+ const allValues = options.map((opt) => opt.value);
+ setSelected(new Set(allValues));
+ onChange(allValues.join(','));
+ }, [options, onChange]);
+
+ const handleSelectNone = useCallback(() => {
+ setSelected(new Set());
+ onChange('');
+ }, [onChange]);
+
+ // Sort options: parents first (in order), then their children indented
+ const sortedOptions: Array<{ option: EnumOption; isChild: boolean }> = (() => {
+ if (!hierarchy) return options.map((o) => ({ option: o, isChild: false }));
+
+ const result: Array<{ option: EnumOption; isChild: boolean }> = [];
+ const optionMap = new Map(options.map((o) => [o.value, o]));
+
+ // First, add all parents in original order
+ for (const option of options) {
+ if (allParents.has(option.value)) {
+ result.push({ option, isChild: false });
+
+ // Then add their children
+ const children = parentToChildren.get(option.value) || [];
+ for (const childId of children) {
+ const childOption = optionMap.get(childId);
+ if (childOption) {
+ result.push({ option: childOption, isChild: true });
+ }
+ }
+ }
+ }
+
+ // Finally, add any options with no parent or child relationship
+ for (const option of options) {
+ if (!allParents.has(option.value) && !hierarchy.some((h) => h.id === option.value)) {
+ result.push({ option, isChild: false });
+ }
+ }
+
+ return result;
+ })();
+
+ // Check if a parent has indeterminate state (some but not all children selected)
+ const isIndeterminate = (parentId: string): boolean => {
+ const children = parentToChildren.get(parentId);
+ if (!children || children.length === 0) return false;
+
+ const selectedChildren = children.filter((c: string) => selected.has(c));
+ return selectedChildren.length > 0 && selectedChildren.length < children.length;
+ };
+
+ // Set indeterminate state on parent checkboxes
+ const parentCheckboxRefs = useRef>(new Map());
+
+ return (
+
+
+
+ {t('dataTable.filter.selectAll')}
+
+
+ {t('dataTable.filter.selectNone')}
+
+
+
+ {sortedOptions.map(({ option, isChild }) => {
+ const isParent = allParents.has(option.value);
+
+ return (
+
+ {
+ if (el && isParent) {
+ parentCheckboxRefs.current.set(option.value, el);
+ el.indeterminate = isIndeterminate(option.value);
+ } else if (isParent) {
+ parentCheckboxRefs.current.delete(option.value);
+ }
+ }}
+ type="checkbox"
+ checked={selected.has(option.value)}
+ onChange={() => handleToggle(option.value)}
+ className={styles.filterCheckbox}
+ id={`enum-${option.value}`}
+ aria-label={isParent ? `${option.label} (group)` : option.label}
+ />
+ {option.label}
+
+ );
+ })}
+
+
+ );
+}
diff --git a/client/src/components/DataTable/filters/Filter.module.css b/client/src/components/DataTable/filters/Filter.module.css
new file mode 100644
index 000000000..8a5e444f3
--- /dev/null
+++ b/client/src/components/DataTable/filters/Filter.module.css
@@ -0,0 +1,291 @@
+.filterContent {
+ display: flex;
+ flex-direction: column;
+ gap: var(--spacing-3);
+ padding: var(--spacing-3);
+}
+
+.filterInput {
+ padding: var(--spacing-2) var(--spacing-3);
+ border: 1px solid var(--color-border-strong);
+ border-radius: var(--radius-md);
+ font-size: var(--font-size-sm);
+ color: var(--color-text-primary);
+ background-color: var(--color-bg-primary);
+ transition: var(--transition-input);
+}
+
+.filterInput:focus-visible {
+ outline: none;
+ border-color: var(--color-primary);
+ box-shadow: var(--shadow-focus-subtle);
+}
+
+.filterDateInput {
+ padding: var(--spacing-2) var(--spacing-3);
+ border: 1px solid var(--color-border-strong);
+ border-radius: var(--radius-md);
+ font-size: var(--font-size-sm);
+ color: var(--color-text-primary);
+ background-color: var(--color-bg-primary);
+ transition: var(--transition-input);
+}
+
+.filterDateInput:focus-visible {
+ outline: none;
+ border-color: var(--color-primary);
+ box-shadow: var(--shadow-focus-subtle);
+}
+
+.filterDateInputConfirmed {
+ border-color: var(--color-primary);
+ background-color: var(--color-primary-bg);
+}
+
+.filterDateInputConfirmed:focus-visible {
+ box-shadow: var(--shadow-focus-subtle);
+}
+
+.filterActions {
+ display: flex;
+ gap: var(--spacing-2);
+}
+
+.filterButton {
+ flex: 1;
+ padding: var(--spacing-1-5) var(--spacing-3);
+ background-color: var(--color-primary);
+ color: var(--color-primary-text);
+ border: none;
+ border-radius: var(--radius-md);
+ font-size: var(--font-size-xs);
+ font-weight: var(--font-weight-medium);
+ cursor: pointer;
+ transition: background-color var(--transition-normal);
+}
+
+.filterButton:hover:not(:disabled) {
+ background-color: var(--color-primary-hover);
+}
+
+.filterButton:focus-visible {
+ outline: none;
+ box-shadow: var(--shadow-focus);
+}
+
+.filterButton:disabled {
+ background-color: var(--color-text-placeholder);
+ cursor: not-allowed;
+}
+
+.filterButtonSecondary {
+ flex: 1;
+ padding: var(--spacing-1-5) var(--spacing-3);
+ background-color: var(--color-bg-tertiary);
+ color: var(--color-text-secondary);
+ border: 1px solid var(--color-border-strong);
+ border-radius: var(--radius-md);
+ font-size: var(--font-size-xs);
+ font-weight: var(--font-weight-medium);
+ cursor: pointer;
+ transition: background-color var(--transition-normal);
+}
+
+.filterButtonSecondary:hover:not(:disabled) {
+ background-color: var(--color-border);
+}
+
+.filterButtonSecondary:focus-visible {
+ outline: none;
+ box-shadow: var(--shadow-focus);
+}
+
+.filterButtonSecondary:disabled {
+ opacity: 0.5;
+ cursor: not-allowed;
+}
+
+.filterRangeRow {
+ display: flex;
+ gap: var(--spacing-2);
+ align-items: center;
+}
+
+.filterRangeInput {
+ flex: 1;
+ padding: var(--spacing-2) var(--spacing-3);
+ border: 1px solid var(--color-border-strong);
+ border-radius: var(--radius-md);
+ font-size: var(--font-size-sm);
+ color: var(--color-text-primary);
+ background-color: var(--color-bg-primary);
+ transition: var(--transition-input);
+}
+
+.filterRangeInput:focus-visible {
+ outline: none;
+ border-color: var(--color-primary);
+ box-shadow: var(--shadow-focus-subtle);
+}
+
+.filterRangeLabel {
+ font-size: var(--font-size-xs);
+ color: var(--color-text-muted);
+ font-weight: var(--font-weight-medium);
+ min-width: 30px;
+ text-align: center;
+}
+
+.filterCheckboxGroup {
+ display: flex;
+ flex-direction: column;
+ gap: var(--spacing-2);
+ max-height: 300px;
+ overflow-y: auto;
+}
+
+.filterCheckboxItem {
+ display: flex;
+ align-items: center;
+ gap: var(--spacing-2);
+ padding: var(--spacing-1-5) var(--spacing-2);
+ border-radius: var(--radius-sm);
+ cursor: pointer;
+ transition: background-color var(--transition-normal);
+ /* Override default label styles */
+ margin: 0;
+ padding: var(--spacing-1-5) var(--spacing-2);
+}
+
+.filterCheckboxItem:hover {
+ background-color: var(--color-bg-secondary);
+}
+
+.filterCheckboxItem:has(input:focus-visible) {
+ outline: none;
+ box-shadow: var(--shadow-focus-subtle);
+}
+
+.filterCheckbox {
+ width: 18px;
+ height: 18px;
+ border: 1px solid var(--color-border-strong);
+ border-radius: var(--radius-sm);
+ cursor: pointer;
+ accent-color: var(--color-primary);
+}
+
+.filterCheckbox:focus-visible {
+ outline: none;
+ box-shadow: var(--shadow-focus);
+}
+
+.filterCheckboxLabel {
+ font-size: var(--font-size-sm);
+ color: var(--color-text-secondary);
+ flex: 1;
+ cursor: pointer;
+}
+
+.filterCheckboxIndented {
+ padding-left: calc(var(--spacing-2) + 24px);
+ font-weight: var(--font-weight-normal);
+ opacity: 0.9;
+}
+
+.filterQuickActions {
+ display: flex;
+ gap: var(--spacing-2);
+ margin-bottom: var(--spacing-1);
+}
+
+.filterQuickActionButton {
+ flex: 1;
+ padding: var(--spacing-1) var(--spacing-2);
+ background-color: var(--color-bg-secondary);
+ color: var(--color-text-secondary);
+ border: 1px solid var(--color-border);
+ border-radius: var(--radius-sm);
+ font-size: var(--font-size-xs);
+ cursor: pointer;
+ transition:
+ background-color var(--transition-normal),
+ color var(--transition-normal);
+}
+
+.filterQuickActionButton:hover {
+ background-color: var(--color-bg-hover);
+ color: var(--color-text-primary);
+}
+
+.filterQuickActionButton:focus-visible {
+ outline: none;
+ box-shadow: var(--shadow-focus);
+}
+
+.filterSegmentedControl {
+ display: flex;
+ gap: 0;
+ border: 1px solid var(--color-border-strong);
+ border-radius: var(--radius-md);
+ overflow: hidden;
+}
+
+.filterSegmentedButton {
+ flex: 1;
+ padding: var(--spacing-2) var(--spacing-3);
+ background-color: var(--color-bg-primary);
+ color: var(--color-text-secondary);
+ border: none;
+ border-right: 1px solid var(--color-border-strong);
+ font-size: var(--font-size-sm);
+ font-weight: var(--font-weight-medium);
+ cursor: pointer;
+ transition:
+ background-color var(--transition-normal),
+ color var(--transition-normal);
+}
+
+.filterSegmentedButton:last-child {
+ border-right: none;
+}
+
+.filterSegmentedButton:hover:not(.filterSegmentedButtonActive) {
+ background-color: var(--color-bg-secondary);
+}
+
+.filterSegmentedButtonActive {
+ background-color: var(--color-primary);
+ color: var(--color-primary-text);
+}
+
+.filterSegmentedButton:focus-visible {
+ outline: none;
+ box-shadow: inset var(--shadow-focus-subtle);
+}
+
+.filterRangeSlider {
+ width: 100%;
+ accent-color: var(--color-primary);
+ cursor: pointer;
+ height: 4px;
+ margin: var(--spacing-1) 0;
+}
+
+.filterRangeSlider:focus-visible {
+ outline: none;
+ box-shadow: var(--shadow-focus);
+}
+
+@media (prefers-reduced-motion: reduce) {
+ .filterInput,
+ .filterDateInput,
+ .filterRangeInput,
+ .filterButton,
+ .filterButtonSecondary,
+ .filterCheckboxItem,
+ .filterSegmentedButton,
+ .filterQuickActionButton {
+ transition: none;
+ }
+}
diff --git a/client/src/components/DataTable/filters/NumberFilter.test.tsx b/client/src/components/DataTable/filters/NumberFilter.test.tsx
new file mode 100644
index 000000000..6904d24f8
--- /dev/null
+++ b/client/src/components/DataTable/filters/NumberFilter.test.tsx
@@ -0,0 +1,198 @@
+import { describe, it, expect, jest } from '@jest/globals';
+import { render, screen, fireEvent } from '@testing-library/react';
+import userEvent from '@testing-library/user-event';
+import { NumberFilter } from './NumberFilter.js';
+
+describe('NumberFilter', () => {
+ describe('rendering', () => {
+ it('renders min and max number inputs', () => {
+ render( );
+ const inputs = screen.getAllByRole('spinbutton');
+ expect(inputs).toHaveLength(2);
+ });
+
+ it('renders min label', () => {
+ render( );
+ expect(screen.getByText(/min/i)).toBeInTheDocument();
+ });
+
+ it('renders max label', () => {
+ render( );
+ expect(screen.getByText(/max/i)).toBeInTheDocument();
+ });
+
+ it('parses existing min value from "min:100,max:500" format', () => {
+ render( );
+ const inputs = screen.getAllByRole('spinbutton');
+ expect(inputs[0]).toHaveValue(100);
+ });
+
+ it('parses existing max value from "min:100,max:500" format', () => {
+ render( );
+ const inputs = screen.getAllByRole('spinbutton');
+ expect(inputs[1]).toHaveValue(500);
+ });
+
+ it('initializes with empty inputs when value is empty string', () => {
+ render( );
+ const inputs = screen.getAllByRole('spinbutton');
+ expect(inputs[0]).toHaveValue(null);
+ expect(inputs[1]).toHaveValue(null);
+ });
+
+ it('does not render an Apply button', () => {
+ render( );
+ expect(screen.queryByRole('button', { name: /apply/i })).not.toBeInTheDocument();
+ });
+
+ it('does not render a Clear button', () => {
+ render( );
+ expect(screen.queryByRole('button', { name: /clear/i })).not.toBeInTheDocument();
+ });
+ });
+
+ describe('auto-apply on input change', () => {
+ it('calls onChange immediately when min input changes', () => {
+ const mockOnChange = jest.fn();
+ render( );
+ const [minInput] = screen.getAllByRole('spinbutton');
+ fireEvent.change(minInput, { target: { value: '100' } });
+ expect(mockOnChange).toHaveBeenCalledWith('min:100');
+ });
+
+ it('calls onChange immediately when max input changes', () => {
+ const mockOnChange = jest.fn();
+ render( );
+ const [, maxInput] = screen.getAllByRole('spinbutton');
+ fireEvent.change(maxInput, { target: { value: '500' } });
+ expect(mockOnChange).toHaveBeenCalledWith('max:500');
+ });
+
+ it('calls onChange with "min:X,max:Y" when both values are set', () => {
+ const mockOnChange = jest.fn();
+ render( );
+ const [minInput, maxInput] = screen.getAllByRole('spinbutton');
+ fireEvent.change(minInput, { target: { value: '100' } });
+ mockOnChange.mockClear();
+ fireEvent.change(maxInput, { target: { value: '500' } });
+ expect(mockOnChange).toHaveBeenCalledWith('min:100,max:500');
+ });
+
+ it('calls onChange with empty string when min input is cleared', () => {
+ const mockOnChange = jest.fn();
+ render( );
+ const [minInput] = screen.getAllByRole('spinbutton');
+ fireEvent.change(minInput, { target: { value: '' } });
+ expect(mockOnChange).toHaveBeenCalledWith('');
+ });
+
+ it('calls onChange without requiring a button click', () => {
+ const mockOnChange = jest.fn();
+ render( );
+ const [minInput] = screen.getAllByRole('spinbutton');
+ fireEvent.change(minInput, { target: { value: '42' } });
+ expect(mockOnChange).toHaveBeenCalledTimes(1);
+ expect(mockOnChange).toHaveBeenCalledWith('min:42');
+ });
+ });
+
+ describe('input interaction', () => {
+ it('updates min input as user types', async () => {
+ const user = userEvent.setup();
+ render( );
+ const [minInput] = screen.getAllByRole('spinbutton');
+ await user.clear(minInput);
+ await user.type(minInput, '50');
+ expect(minInput).toHaveValue(50);
+ });
+
+ it('updates max input as user types', async () => {
+ const user = userEvent.setup();
+ render( );
+ const [, maxInput] = screen.getAllByRole('spinbutton');
+ await user.clear(maxInput);
+ await user.type(maxInput, '999');
+ expect(maxInput).toHaveValue(999);
+ });
+ });
+
+ describe('configurable min/max bounds (#1139)', () => {
+ it('applies custom min to both number inputs and range sliders', () => {
+ const { container } = render(
+ ,
+ );
+ const sliders = container.querySelectorAll('input[type="range"]');
+ const spinbuttons = screen.getAllByRole('spinbutton');
+
+ expect(sliders[0]).toHaveAttribute('min', '100');
+ expect(sliders[1]).toHaveAttribute('min', '100');
+ expect(spinbuttons[0]).toHaveAttribute('min', '100');
+ expect(spinbuttons[1]).toHaveAttribute('min', '100');
+ });
+
+ it('applies custom max to both number inputs and range sliders', () => {
+ const { container } = render(
+ ,
+ );
+ const sliders = container.querySelectorAll('input[type="range"]');
+ const spinbuttons = screen.getAllByRole('spinbutton');
+
+ expect(sliders[0]).toHaveAttribute('max', '10000');
+ expect(sliders[1]).toHaveAttribute('max', '10000');
+ expect(spinbuttons[0]).toHaveAttribute('max', '10000');
+ expect(spinbuttons[1]).toHaveAttribute('max', '10000');
+ });
+
+ it('uses custom min as placeholder for the min number input', () => {
+ render( );
+ const [minInput] = screen.getAllByRole('spinbutton');
+ expect(minInput).toHaveAttribute('placeholder', '100');
+ });
+
+ it('uses custom max as placeholder for the max number input', () => {
+ render( );
+ const [, maxInput] = screen.getAllByRole('spinbutton');
+ expect(maxInput).toHaveAttribute('placeholder', '10000');
+ });
+
+ it('defaults min to 0 when no min prop is provided', () => {
+ const { container } = render( );
+ const sliders = container.querySelectorAll('input[type="range"]');
+ const [minInput] = screen.getAllByRole('spinbutton');
+
+ expect(sliders[0]).toHaveAttribute('min', '0');
+ expect(minInput).toHaveAttribute('min', '0');
+ expect(minInput).toHaveAttribute('placeholder', '0');
+ });
+
+ it('defaults max to 999999 when no max prop is provided', () => {
+ const { container } = render( );
+ const sliders = container.querySelectorAll('input[type="range"]');
+ const [, maxInput] = screen.getAllByRole('spinbutton');
+
+ expect(sliders[1]).toHaveAttribute('max', '999999');
+ expect(maxInput).toHaveAttribute('max', '999999');
+ expect(maxInput).toHaveAttribute('placeholder', '999999');
+ });
+
+ it('applies custom step to both number inputs and range sliders', () => {
+ const { container } = render( );
+ const sliders = container.querySelectorAll('input[type="range"]');
+ const spinbuttons = screen.getAllByRole('spinbutton');
+
+ expect(sliders[0]).toHaveAttribute('step', '0.01');
+ expect(sliders[1]).toHaveAttribute('step', '0.01');
+ expect(spinbuttons[0]).toHaveAttribute('step', '0.01');
+ expect(spinbuttons[1]).toHaveAttribute('step', '0.01');
+ });
+
+ it('defaults step to 1 when no step prop is provided', () => {
+ const { container } = render( );
+ const sliders = container.querySelectorAll('input[type="range"]');
+ const [minInput] = screen.getAllByRole('spinbutton');
+
+ expect(sliders[0]).toHaveAttribute('step', '1');
+ expect(minInput).toHaveAttribute('step', '1');
+ });
+ });
+});
diff --git a/client/src/components/DataTable/filters/NumberFilter.tsx b/client/src/components/DataTable/filters/NumberFilter.tsx
new file mode 100644
index 000000000..5b21d7c5b
--- /dev/null
+++ b/client/src/components/DataTable/filters/NumberFilter.tsx
@@ -0,0 +1,118 @@
+import { useState, useCallback } from 'react';
+import { useTranslation } from 'react-i18next';
+import styles from './Filter.module.css';
+
+export interface NumberFilterProps {
+ value: string;
+ onChange: (value: string) => void;
+ min?: number;
+ max?: number;
+ step?: number;
+}
+
+/**
+ * Min/max number range filter for DataTable
+ * Stores as "min:X,max:Y" format
+ * Auto-applies on input/slider change
+ */
+export function NumberFilter({
+ value,
+ onChange,
+ min: minBound,
+ max: maxBound,
+ step,
+}: NumberFilterProps) {
+ const { t } = useTranslation('common');
+
+ const parseValue = (v: string) => {
+ const min = v.match(/min:([\d.]+)/)?.[1] || '';
+ const max = v.match(/max:([\d.]+)/)?.[1] || '';
+ return { min, max };
+ };
+
+ const { min, max } = parseValue(value);
+ const [localMin, setLocalMin] = useState(min);
+ const [localMax, setLocalMax] = useState(max);
+
+ const emitChange = useCallback(
+ (newMin: string, newMax: string) => {
+ const parts = [];
+ if (newMin) parts.push(`min:${newMin}`);
+ if (newMax) parts.push(`max:${newMax}`);
+ onChange(parts.join(','));
+ },
+ [onChange],
+ );
+
+ const handleMinChange = useCallback(
+ (newMin: string) => {
+ setLocalMin(newMin);
+ emitChange(newMin, localMax);
+ },
+ [localMax, emitChange],
+ );
+
+ const handleMaxChange = useCallback(
+ (newMax: string) => {
+ setLocalMax(newMax);
+ emitChange(localMin, newMax);
+ },
+ [localMin, emitChange],
+ );
+
+ const defaultMin = minBound ?? 0;
+ const defaultMax = maxBound ?? 999999;
+ const defaultStep = step ?? 1;
+
+ return (
+
+ );
+}
diff --git a/client/src/components/DataTable/filters/StringFilter.test.tsx b/client/src/components/DataTable/filters/StringFilter.test.tsx
new file mode 100644
index 000000000..85d27f9d4
--- /dev/null
+++ b/client/src/components/DataTable/filters/StringFilter.test.tsx
@@ -0,0 +1,70 @@
+import { describe, it, expect, jest } from '@jest/globals';
+import { render, screen, fireEvent } from '@testing-library/react';
+import { StringFilter } from './StringFilter.js';
+
+describe('StringFilter', () => {
+ describe('rendering', () => {
+ it('renders a text input with the given initial value', () => {
+ render( );
+ const input = screen.getByRole('textbox');
+ expect(input).toBeInTheDocument();
+ expect(input).toHaveValue('hello');
+ });
+
+ it('renders with empty value when no initial value provided', () => {
+ render( );
+ expect(screen.getByRole('textbox')).toHaveValue('');
+ });
+
+ it('renders with custom placeholder when provided', () => {
+ render( );
+ expect(screen.getByPlaceholderText('Search names...')).toBeInTheDocument();
+ });
+
+ it('does not render an Apply button', () => {
+ render( );
+ expect(screen.queryByRole('button', { name: /apply/i })).not.toBeInTheDocument();
+ });
+
+ it('does not render a Clear button', () => {
+ render( );
+ expect(screen.queryByRole('button', { name: /clear/i })).not.toBeInTheDocument();
+ });
+ });
+
+ describe('auto-apply on change', () => {
+ it('calls onChange immediately when input value changes', () => {
+ const mockOnChange = jest.fn();
+ render( );
+ const input = screen.getByRole('textbox');
+ fireEvent.change(input, { target: { value: 'foo' } });
+ expect(mockOnChange).toHaveBeenCalledWith('foo');
+ });
+
+ it('calls onChange on each keystroke without requiring a button click', () => {
+ const mockOnChange = jest.fn();
+ render( );
+ const input = screen.getByRole('textbox');
+ fireEvent.change(input, { target: { value: 'a' } });
+ fireEvent.change(input, { target: { value: 'ab' } });
+ fireEvent.change(input, { target: { value: 'abc' } });
+ expect(mockOnChange).toHaveBeenCalledTimes(3);
+ expect(mockOnChange).toHaveBeenLastCalledWith('abc');
+ });
+
+ it('calls onChange with empty string when input is cleared', () => {
+ const mockOnChange = jest.fn();
+ render( );
+ const input = screen.getByRole('textbox');
+ fireEvent.change(input, { target: { value: '' } });
+ expect(mockOnChange).toHaveBeenCalledWith('');
+ });
+
+ it('calls onChange with the full current input value on change', () => {
+ const mockOnChange = jest.fn();
+ render( );
+ fireEvent.change(screen.getByRole('textbox'), { target: { value: 'foo bar' } });
+ expect(mockOnChange).toHaveBeenCalledWith('foo bar');
+ });
+ });
+});
diff --git a/client/src/components/DataTable/filters/StringFilter.tsx b/client/src/components/DataTable/filters/StringFilter.tsx
new file mode 100644
index 000000000..9c2860c75
--- /dev/null
+++ b/client/src/components/DataTable/filters/StringFilter.tsx
@@ -0,0 +1,38 @@
+import { useState, useCallback } from 'react';
+import { useTranslation } from 'react-i18next';
+import styles from './Filter.module.css';
+
+export interface StringFilterProps {
+ value: string;
+ onChange: (value: string) => void;
+ placeholder?: string;
+}
+
+/**
+ * Text input filter for DataTable
+ * Auto-applies on input change
+ */
+export function StringFilter({ value, onChange, placeholder = 'Filter...' }: StringFilterProps) {
+ const { t } = useTranslation('common');
+
+ const handleChange = useCallback(
+ (e: React.ChangeEvent) => {
+ // Auto-apply on every keystroke
+ onChange(e.target.value);
+ },
+ [onChange],
+ );
+
+ return (
+
+
+
+ );
+}
diff --git a/client/src/components/DataTable/index.ts b/client/src/components/DataTable/index.ts
new file mode 100644
index 000000000..6b8c479b6
--- /dev/null
+++ b/client/src/components/DataTable/index.ts
@@ -0,0 +1,38 @@
+export { DataTable } from './DataTable.js';
+export type {
+ DataTableProps,
+ ColumnDef,
+ TableState,
+ TableApiParams,
+ FilterType,
+ EnumOption,
+ ActiveFilter,
+} from './DataTable.js';
+
+export { DataTableHeader } from './DataTableHeader.js';
+export { DataTableRow } from './DataTableRow.js';
+export { DataTableCard } from './DataTableCard.js';
+export { DataTablePagination } from './DataTablePagination.js';
+export { DataTableColumnSettings } from './DataTableColumnSettings.js';
+export { DataTableFilterPopover } from './DataTableFilterPopover.js';
+
+// Filters
+export { StringFilter } from './filters/StringFilter.js';
+export { NumberFilter } from './filters/NumberFilter.js';
+export { DateFilter } from './filters/DateFilter.js';
+export { EnumFilter } from './filters/EnumFilter.js';
+export { BooleanFilter } from './filters/BooleanFilter.js';
+export { EntityFilter } from './filters/EntityFilter.js';
+
+export type { StringFilterProps } from './filters/StringFilter.js';
+export type { NumberFilterProps } from './filters/NumberFilter.js';
+export type { DateFilterProps } from './filters/DateFilter.js';
+export type { EnumFilterProps } from './filters/EnumFilter.js';
+export type { BooleanFilterProps } from './filters/BooleanFilter.js';
+export type { EntityFilterProps } from './filters/EntityFilter.js';
+
+// Hooks
+export { useTableState } from '../../hooks/useTableState.js';
+export { useColumnPreferences } from '../../hooks/useColumnPreferences.js';
+export type { UseTableStateResult, UseTableStateOptions } from '../../hooks/useTableState.js';
+export type { UseColumnPreferencesResult } from '../../hooks/useColumnPreferences.js';
diff --git a/client/src/components/DateRangePicker/DateRangePicker.module.css b/client/src/components/DateRangePicker/DateRangePicker.module.css
new file mode 100644
index 000000000..fb22995e6
--- /dev/null
+++ b/client/src/components/DateRangePicker/DateRangePicker.module.css
@@ -0,0 +1,241 @@
+/* ============================================================
+ * DateRangePicker — calendar-based date range selection
+ * ============================================================ */
+
+.picker {
+ display: flex;
+ flex-direction: column;
+ gap: var(--spacing-2);
+}
+
+/* ---- Navigation toolbar ---- */
+
+.nav {
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ gap: var(--spacing-2);
+}
+
+.navButton {
+ width: var(--spacing-8);
+ height: var(--spacing-8);
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ border: 1px solid var(--color-border-strong);
+ border-radius: var(--radius-md);
+ background: var(--color-bg-primary);
+ color: var(--color-text-secondary);
+ cursor: pointer;
+ transition:
+ background-color var(--transition-fast),
+ color var(--transition-fast);
+ padding: 0;
+}
+
+.navButton:hover {
+ background: var(--color-bg-hover);
+ color: var(--color-text-primary);
+}
+
+.navButton:focus-visible {
+ outline: none;
+ box-shadow: var(--shadow-focus);
+}
+
+.navButton:active {
+ background: var(--color-bg-tertiary);
+}
+
+.monthLabel {
+ font-size: var(--font-size-sm);
+ font-weight: var(--font-weight-semibold);
+ color: var(--color-text-primary);
+ text-align: center;
+ flex: 1;
+ white-space: nowrap;
+}
+
+/* ---- Day headers (Sun–Sat) ---- */
+
+.dayHeader {
+ text-align: center;
+ font-size: var(--font-size-xs);
+ font-weight: var(--font-weight-semibold);
+ color: var(--color-text-muted);
+ padding: var(--spacing-1) 0;
+ text-transform: uppercase;
+ letter-spacing: 0.03em;
+}
+
+/* ---- Calendar grid ---- */
+
+.grid {
+ display: flex;
+ flex-direction: column;
+ gap: 0;
+}
+
+.weekRow {
+ display: grid;
+ grid-template-columns: repeat(7, 1fr);
+ gap: 0;
+}
+
+.dayCell {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ padding: var(--spacing-0-5);
+ aspect-ratio: 1;
+ /* Range bar background behind button */
+ background: transparent;
+}
+
+.dayInRange {
+ background: var(--color-primary-bg);
+}
+
+/* Range endpoint half-backgrounds using linear-gradient */
+.dayRangeStart {
+ background: linear-gradient(to right, transparent 50%, var(--color-primary-bg) 50%);
+}
+
+.dayRangeEnd {
+ background: linear-gradient(to left, transparent 50%, var(--color-primary-bg) 50%);
+}
+
+.dayButton {
+ width: var(--spacing-7);
+ height: var(--spacing-7);
+ min-height: var(--spacing-7);
+ min-width: var(--spacing-7);
+ border-radius: var(--radius-circle);
+ border: none;
+ background: transparent;
+ color: var(--color-text-primary);
+ font-size: var(--font-size-xs);
+ font-weight: var(--font-weight-normal);
+ cursor: pointer;
+ transition: background-color var(--transition-fast);
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ padding: 0;
+ flex-shrink: 0;
+}
+
+.dayButton:hover:not(.daySelected):not(.dayDisabled) {
+ background: var(--color-bg-hover);
+}
+
+.dayButton:focus-visible {
+ outline: none;
+ box-shadow: var(--shadow-focus);
+}
+
+.dayButton:active:not(.dayDisabled) {
+ background: var(--color-bg-tertiary);
+}
+
+/* Today highlight — bold text */
+.dayToday {
+ font-weight: var(--font-weight-bold);
+}
+
+/* Days outside current month — dimmed */
+.dayOtherMonth {
+ color: var(--color-text-placeholder);
+ opacity: 0.6;
+}
+
+/* Disabled days (before start date when selecting end) */
+.dayDisabled {
+ opacity: 0.4;
+ cursor: not-allowed;
+}
+
+.dayDisabled:hover {
+ background: transparent;
+}
+
+/* Selected day (start or end) — blue background */
+.daySelected {
+ background: var(--color-primary);
+ color: var(--color-primary-text);
+ font-weight: var(--font-weight-bold);
+}
+
+.daySelected:hover {
+ background: var(--color-primary-hover);
+}
+
+.daySelected:focus-visible {
+ box-shadow: var(--shadow-focus);
+}
+
+.daySelected:active {
+ background: var(--color-primary-active);
+}
+
+/* ---- Phase indicator label ---- */
+
+.phaseLabel {
+ font-size: var(--font-size-xs);
+ color: var(--color-text-muted);
+ text-align: center;
+ padding: var(--spacing-1) 0;
+ font-weight: var(--font-weight-normal);
+}
+
+/* ---- Responsive ---- */
+
+@media (max-width: 1023px) {
+ .navButton {
+ width: var(--spacing-10);
+ height: var(--spacing-10);
+ }
+
+ .dayButton {
+ width: 36px;
+ height: 36px;
+ min-height: 36px;
+ min-width: 36px;
+ }
+}
+
+@media (max-width: 767px) {
+ .navButton {
+ width: var(--spacing-10);
+ height: var(--spacing-10);
+ }
+
+ .dayButton {
+ width: 36px;
+ height: 36px;
+ min-height: 36px;
+ min-width: 36px;
+ }
+
+ .dayCell {
+ padding: var(--spacing-1);
+ }
+
+ .monthLabel {
+ font-size: var(--font-size-base);
+ }
+
+ .dayHeader {
+ font-size: var(--font-size-2xs);
+ }
+}
+
+/* ---- Reduced motion ---- */
+
+@media (prefers-reduced-motion: reduce) {
+ .navButton,
+ .dayButton {
+ transition: none;
+ }
+}
diff --git a/client/src/components/DateRangePicker/DateRangePicker.test.tsx b/client/src/components/DateRangePicker/DateRangePicker.test.tsx
new file mode 100644
index 000000000..52f9fdb3a
--- /dev/null
+++ b/client/src/components/DateRangePicker/DateRangePicker.test.tsx
@@ -0,0 +1,629 @@
+import { describe, it, expect, jest, beforeEach } from '@jest/globals';
+import type { ReactNode } from 'react';
+import { render as rtlRender, screen, fireEvent, within, act } from '@testing-library/react';
+import userEvent from '@testing-library/user-event';
+import { DateRangePicker } from './DateRangePicker.js';
+import { LocaleProvider } from '../../contexts/LocaleContext.js';
+import styles from './DateRangePicker.module.css';
+
+/**
+ * Custom render function that wraps the component with LocaleProvider
+ */
+function render(component: ReactNode, options?: Parameters[1]) {
+ return rtlRender({component} , options);
+}
+
+/**
+ * Helper to get month label text from the picker
+ */
+function getMonthLabel(container: HTMLElement): string {
+ const monthLabel = container.querySelector('.monthLabel');
+ return monthLabel?.textContent || '';
+}
+
+/**
+ * Helper to get all day buttons
+ */
+function getDayButtons(container: HTMLElement): HTMLButtonElement[] {
+ return Array.from(container.querySelectorAll(`.${styles.dayButton}`));
+}
+
+/**
+ * Helper to find a day button by its text content
+ */
+function findDayButton(container: HTMLElement, dayNumber: number): HTMLButtonElement | null {
+ return getDayButtons(container).find((btn) => btn.textContent === String(dayNumber)) || null;
+}
+
+/**
+ * Helper to check if element has a specific class
+ */
+function hasClass(element: Element, className: string): boolean {
+ return element.className.includes(className);
+}
+
+/**
+ * Helper to get the phase label text
+ */
+function getPhaseLabel(container: HTMLElement): string {
+ const phaseLabel = container.querySelector(`.${styles.phaseLabel}`);
+ return phaseLabel?.textContent || '';
+}
+
+describe('DateRangePicker', () => {
+ describe('rendering', () => {
+ it('renders a calendar grid with 7 day header columns', () => {
+ const { container } = render(
+ ,
+ );
+ const dayHeaders = container.querySelectorAll(`.${styles.dayHeader}`);
+ expect(dayHeaders).toHaveLength(7);
+ });
+
+ it('renders navigation buttons for previous and next month', () => {
+ const { container } = render(
+ ,
+ );
+ const navButtons = container.querySelectorAll(`.${styles.navButton}`);
+ expect(navButtons).toHaveLength(2);
+ });
+
+ it('renders the month/year heading', () => {
+ const { container } = render(
+ ,
+ );
+ const monthLabel = container.querySelector(`.${styles.monthLabel}`);
+ expect(monthLabel).toBeInTheDocument();
+ expect(monthLabel?.textContent).toMatch(/^\w+\s\d{4}$/); // "Month YYYY"
+ });
+
+ it('renders day buttons for the month grid (approximately 40-42 cells for 6-row layout)', () => {
+ const { container } = render(
+ ,
+ );
+ const dayButtons = getDayButtons(container);
+ expect(dayButtons.length).toBeGreaterThanOrEqual(35); // Minimum 5 full weeks
+ expect(dayButtons.length).toBeLessThanOrEqual(42); // Maximum 6 full weeks
+ });
+
+ it('renders with no selected day when startDate="" and endDate=""', () => {
+ const { container } = render(
+ ,
+ );
+ const selectedButtons = getDayButtons(container).filter((btn) =>
+ hasClass(btn, styles.daySelected),
+ );
+ expect(selectedButtons).toHaveLength(0);
+ });
+
+ it('applies selected styling to the start date day button', () => {
+ const { container } = render(
+ ,
+ );
+ const dayBtn = findDayButton(container, 15);
+ expect(dayBtn).toHaveClass(styles.daySelected);
+ });
+
+ it('applies selected styling to both start and end date buttons when both are set', () => {
+ const { container } = render(
+ ,
+ );
+ const dayBtn15 = findDayButton(container, 15);
+ const dayBtn25 = findDayButton(container, 25);
+ expect(dayBtn15).toHaveClass(styles.daySelected);
+ expect(dayBtn25).toHaveClass(styles.daySelected);
+ });
+
+ it('applies in-range styling to days strictly between start and end dates', () => {
+ const { container } = render(
+ ,
+ );
+ const dayBtn20 = findDayButton(container, 20);
+ const dayCellContainer = dayBtn20?.closest(`.${styles.dayCell}`);
+ expect(dayCellContainer).toHaveClass(styles.dayInRange);
+ });
+
+ it('applies other-month styling to days outside current month', () => {
+ const { container } = render(
+ ,
+ );
+ const otherMonthBtns = getDayButtons(container).filter((btn) =>
+ hasClass(btn, styles.dayOtherMonth),
+ );
+ expect(otherMonthBtns.length).toBeGreaterThan(0);
+ });
+
+ it("applies today styling to today's date", () => {
+ const today = new Date();
+ const year = today.getFullYear();
+ const month = String(today.getMonth() + 1).padStart(2, '0');
+ const day = String(today.getDate()).padStart(2, '0');
+ const todayStr = `${year}-${month}-${day}`;
+
+ const { container } = render(
+ ,
+ );
+ const todayBtn = findDayButton(container, today.getDate());
+ expect(todayBtn).toHaveClass(styles.dayToday);
+ });
+ });
+
+ describe('selection phase', () => {
+ it('shows phase label "Select start date" when startDate=""', () => {
+ const { container } = render(
+ ,
+ );
+ const phaseLabel = getPhaseLabel(container);
+ expect(phaseLabel.toLowerCase()).toContain('start');
+ });
+
+ it('shows phase label "Select end date" when startDate is set', () => {
+ const { container } = render(
+ ,
+ );
+ const phaseLabel = getPhaseLabel(container);
+ expect(phaseLabel.toLowerCase()).toContain('end');
+ });
+
+ it('clicking a day when startDate="" calls onChange with the clicked date and empty end', () => {
+ const mockOnChange = jest.fn();
+ const { container } = render(
+ ,
+ );
+ const dayBtn = findDayButton(container, 15);
+ expect(dayBtn).toBeTruthy();
+ fireEvent.click(dayBtn!);
+ expect(mockOnChange).toHaveBeenCalledWith('2026-03-15', '');
+ });
+
+ it('advances to selecting-end phase after clicking start date', () => {
+ const mockOnChange = jest.fn();
+ const { container } = render(
+ ,
+ );
+ const dayBtn = findDayButton(container, 15);
+ fireEvent.click(dayBtn!);
+ expect(mockOnChange).toHaveBeenCalledWith('2026-03-15', '');
+
+ // Now render with startDate set to verify phase changes
+ const { container: container2 } = render(
+ ,
+ );
+ const phaseLabel = getPhaseLabel(container2);
+ expect(phaseLabel.toLowerCase()).toContain('end');
+ });
+ });
+
+ describe('phase: selecting-end', () => {
+ it('with startDate set, hovering a later day shows range highlight', () => {
+ const { container } = render(
+ ,
+ );
+ // A day strictly between start and hover should show in-range
+ const dayBtn20 = findDayButton(container, 20);
+ fireEvent.mouseEnter(dayBtn20!);
+ // When hovering 2026-03-20 with start=2026-03-15, day 18 is between them
+ const dayBtn18 = findDayButton(container, 18);
+ const dayCellContainer = dayBtn18?.closest(`.${styles.dayCell}`);
+ expect(dayCellContainer).toHaveClass(styles.dayInRange);
+ });
+
+ it('clicking a day after start calls onChange with start and clicked day', () => {
+ const mockOnChange = jest.fn();
+ const { container } = render(
+ ,
+ );
+ const dayBtn25 = findDayButton(container, 25);
+ fireEvent.click(dayBtn25!);
+ expect(mockOnChange).toHaveBeenCalledWith('2026-03-15', '2026-03-25');
+ });
+
+ it('days before start date have dayDisabled class during selecting-end phase', () => {
+ const { container } = render(
+ ,
+ );
+ // Day 10 is before day 20, so it should have the dayDisabled CSS class but remain clickable
+ const dayBtn10 = findDayButton(container, 10);
+ expect(dayBtn10).toBeTruthy();
+ expect(dayBtn10).not.toBeDisabled();
+ expect(dayBtn10).toHaveClass(styles.dayDisabled);
+ });
+
+ it('clicking the startDate day again calls onChange with empty start and end (clear both)', () => {
+ const mockOnChange = jest.fn();
+ const { container } = render(
+ ,
+ );
+ const dayBtn15 = findDayButton(container, 15);
+ fireEvent.click(dayBtn15!);
+ expect(mockOnChange).toHaveBeenCalledWith('', '');
+ });
+
+ it('mouseleave clears hover preview', () => {
+ const { container } = render(
+ ,
+ );
+ const dayBtn20 = findDayButton(container, 20);
+ fireEvent.mouseEnter(dayBtn20!);
+ // Day 18 is between 15 and 20, should have in-range class during hover
+ const dayBtn18 = findDayButton(container, 18);
+ let dayCellContainer = dayBtn18?.closest(`.${styles.dayCell}`);
+ expect(dayCellContainer).toHaveClass(styles.dayInRange);
+
+ // After mouse leave, should not have in-range
+ fireEvent.mouseLeave(dayBtn20!);
+ dayCellContainer = dayBtn18?.closest(`.${styles.dayCell}`);
+ expect(dayCellContainer).not.toHaveClass(styles.dayInRange);
+ });
+
+ it('days before start date have dayDisabled CSS class (visually styled, not disabled)', () => {
+ const { container } = render(
+ ,
+ );
+ const dayBtn10 = findDayButton(container, 10);
+ expect(dayBtn10).toHaveClass(styles.dayDisabled);
+ expect(dayBtn10).not.toBeDisabled();
+ });
+ });
+
+ describe('navigation', () => {
+ it('clicking previous month button shows previous month', () => {
+ const { container } = render(
+ ,
+ );
+ const initialLabel = getMonthLabel(container);
+ expect(initialLabel).toContain('March');
+
+ const prevBtn = container.querySelector(`.${styles.navButton}`);
+ fireEvent.click(prevBtn!);
+
+ const newLabel = getMonthLabel(container);
+ expect(newLabel).toContain('February');
+ });
+
+ it('clicking next month button shows next month', () => {
+ const { container } = render(
+ ,
+ );
+ const initialLabel = getMonthLabel(container);
+ expect(initialLabel).toContain('March');
+
+ const navButtons = container.querySelectorAll(`.${styles.navButton}`);
+ const nextBtn = navButtons[navButtons.length - 1];
+ fireEvent.click(nextBtn);
+
+ const newLabel = getMonthLabel(container);
+ expect(newLabel).toContain('April');
+ });
+
+ it('month/year heading updates when navigating months', () => {
+ const { container } = render(
+ ,
+ );
+ const nextBtn = container.querySelectorAll(`.${styles.navButton}`)[1];
+
+ fireEvent.click(nextBtn);
+ const monthLabel = getMonthLabel(container);
+ expect(monthLabel).toMatch(/^\w+\s\d{4}$/);
+ });
+ });
+
+ describe('keyboard navigation', () => {
+ it('ArrowRight moves focus to next day', () => {
+ const { container } = render(
+ ,
+ );
+ const grid = container.querySelector(`.${styles.grid}`);
+
+ // Initial focused button should be on startDate (2026-03-15)
+ const initialFocused = container.querySelector('button[tabindex="0"]');
+ expect(initialFocused).toHaveAttribute('aria-label', expect.stringContaining('15'));
+
+ // Fire ArrowRight keyboard event on grid
+ fireEvent.keyDown(grid!, { key: 'ArrowRight' });
+
+ // Now focused button should be on 2026-03-16
+ const newFocused = container.querySelector('button[tabindex="0"]');
+ expect(newFocused).not.toBe(initialFocused);
+ expect(newFocused).toHaveAttribute('aria-label', expect.stringContaining('16'));
+ });
+
+ it('ArrowLeft moves focus to previous day', () => {
+ const { container } = render(
+ ,
+ );
+ const grid = container.querySelector(`.${styles.grid}`);
+
+ // Initial focused button should be on startDate (2026-03-15)
+ const initialFocused = container.querySelector('button[tabindex="0"]');
+ expect(initialFocused).toHaveAttribute('aria-label', expect.stringContaining('15'));
+
+ // Fire ArrowLeft keyboard event on grid
+ fireEvent.keyDown(grid!, { key: 'ArrowLeft' });
+
+ // Now focused button should be on 2026-03-14
+ const newFocused = container.querySelector('button[tabindex="0"]');
+ expect(newFocused).not.toBe(initialFocused);
+ expect(newFocused).toHaveAttribute('aria-label', expect.stringContaining('14'));
+ });
+
+ it('ArrowDown moves focus 7 days forward', () => {
+ const { container } = render(
+ ,
+ );
+ const grid = container.querySelector(`.${styles.grid}`);
+
+ // Initial focused button should be on startDate (2026-03-15)
+ const initialFocused = container.querySelector('button[tabindex="0"]');
+ expect(initialFocused).toHaveAttribute('aria-label', expect.stringContaining('15'));
+
+ // Fire ArrowDown keyboard event on grid
+ fireEvent.keyDown(grid!, { key: 'ArrowDown' });
+
+ // Now focused button should be on 2026-03-22 (7 days later)
+ const newFocused = container.querySelector('button[tabindex="0"]');
+ expect(newFocused).not.toBe(initialFocused);
+ expect(newFocused).toHaveAttribute('aria-label', expect.stringContaining('22'));
+ });
+
+ it('ArrowUp moves focus 7 days backward', () => {
+ const { container } = render(
+ ,
+ );
+ const grid = container.querySelector(`.${styles.grid}`);
+
+ // Initial focused button should be on startDate (2026-03-15)
+ const initialFocused = container.querySelector('button[tabindex="0"]');
+ expect(initialFocused).toHaveAttribute('aria-label', expect.stringContaining('15'));
+
+ // Fire ArrowUp keyboard event on grid
+ fireEvent.keyDown(grid!, { key: 'ArrowUp' });
+
+ // Now focused button should be on 2026-03-08 (7 days earlier)
+ const newFocused = container.querySelector('button[tabindex="0"]');
+ expect(newFocused).not.toBe(initialFocused);
+ expect(newFocused).toHaveAttribute('aria-label', expect.stringContaining('8'));
+ });
+
+ it('Enter on a focused day triggers selection', () => {
+ const mockOnChange = jest.fn();
+ const { container } = render(
+ ,
+ );
+ const grid = container.querySelector(`.${styles.grid}`);
+
+ // Fire Enter on the grid (keyboard navigation handler)
+ fireEvent.keyDown(grid!, { key: 'Enter' });
+
+ expect(mockOnChange).toHaveBeenCalled();
+ });
+
+ it('Space on a focused day triggers selection', () => {
+ const mockOnChange = jest.fn();
+ const { container } = render(
+ ,
+ );
+ const grid = container.querySelector(`.${styles.grid}`);
+
+ // Fire Space on the grid (keyboard navigation handler)
+ fireEvent.keyDown(grid!, { key: ' ' });
+
+ expect(mockOnChange).toHaveBeenCalled();
+ });
+
+ it('Escape during selecting-end phase cancels selection and resets both dates', () => {
+ const mockOnChange = jest.fn();
+ const { container } = render(
+ ,
+ );
+ const grid = container.querySelector('[role="grid"]');
+ expect(grid).toBeTruthy();
+
+ // Fire Escape key on grid
+ fireEvent.keyDown(grid!, { key: 'Escape' });
+
+ // Expect onChange to be called with both dates cleared
+ expect(mockOnChange).toHaveBeenCalledWith('', '');
+ });
+
+ it('Escape during selecting-start phase does not call onChange', () => {
+ const mockOnChange = jest.fn();
+ const { container } = render(
+ ,
+ );
+ const grid = container.querySelector('[role="grid"]');
+ expect(grid).toBeTruthy();
+
+ // Fire Escape key on grid
+ fireEvent.keyDown(grid!, { key: 'Escape' });
+
+ // Expect onChange to NOT have been called
+ expect(mockOnChange).not.toHaveBeenCalled();
+ });
+ });
+
+ describe('props sync', () => {
+ it('when startDate prop changes from a date to "" externally, phase resets to selecting-start', () => {
+ const { container: container1 } = render(
+ ,
+ );
+ const phaseLabel1 = getPhaseLabel(container1);
+ expect(phaseLabel1.toLowerCase()).toContain('end');
+
+ const { container: container2 } = render(
+ ,
+ );
+ const phaseLabel2 = getPhaseLabel(container2);
+ expect(phaseLabel2.toLowerCase()).toContain('start');
+ });
+
+ it('when endDate prop is set externally, phase remains at selecting-end', () => {
+ const { container: container1 } = render(
+ ,
+ );
+ const phaseLabel1 = getPhaseLabel(container1);
+ expect(phaseLabel1.toLowerCase()).toContain('end');
+
+ const { container: container2 } = render(
+ ,
+ );
+ const phaseLabel2 = getPhaseLabel(container2);
+ expect(phaseLabel2.toLowerCase()).toContain('end');
+ });
+ });
+
+ describe('accessibility', () => {
+ it('calendar grid has role="grid" with aria-label', () => {
+ const { container } = render(
+ ,
+ );
+ const grid = container.querySelector(`.${styles.grid}`);
+ expect(grid).toHaveAttribute('role', 'grid');
+ });
+
+ it('week rows have role="row"', () => {
+ const { container } = render(
+ ,
+ );
+ const weekRows = container.querySelectorAll(`.${styles.weekRow}`);
+ weekRows.forEach((row) => {
+ expect(row).toHaveAttribute('role', 'row');
+ });
+ });
+
+ it('day cells have role="gridcell"', () => {
+ const { container } = render(
+ ,
+ );
+ const dayCells = container.querySelectorAll(`.${styles.dayCell}`);
+ expect(dayCells.length).toBeGreaterThan(0);
+ dayCells.forEach((cell) => {
+ expect(cell).toHaveAttribute('role', 'gridcell');
+ });
+ });
+
+ it('day buttons have aria-label with formatted date', () => {
+ const { container } = render(
+ ,
+ );
+ const dayBtn15 = findDayButton(container, 15);
+ expect(dayBtn15).toHaveAttribute('aria-label');
+ const label = dayBtn15?.getAttribute('aria-label');
+ expect(label).toMatch(/\d+/); // Should contain a number
+ });
+
+ it('selected day has aria-pressed="true"', () => {
+ const { container } = render(
+ ,
+ );
+ const dayBtn15 = findDayButton(container, 15);
+ expect(dayBtn15).toHaveAttribute('aria-pressed', 'true');
+ });
+
+ it('unselected day has aria-pressed="false"', () => {
+ const { container } = render(
+ ,
+ );
+ const dayBtn10 = findDayButton(container, 10);
+ expect(dayBtn10).toHaveAttribute('aria-pressed', 'false');
+ });
+
+ it('nav buttons have aria-label', () => {
+ const { container } = render(
+ ,
+ );
+ const navButtons = container.querySelectorAll(`.${styles.navButton}`);
+ navButtons.forEach((btn) => {
+ expect(btn).toHaveAttribute('aria-label');
+ });
+ });
+ });
+
+ describe('edge cases', () => {
+ it('handles year transitions when navigating backwards from January', () => {
+ const { container } = render(
+ ,
+ );
+ const prevBtn = container.querySelector(`.${styles.navButton}`);
+ fireEvent.click(prevBtn!);
+ const monthLabel = getMonthLabel(container);
+ expect(monthLabel).toContain('2025');
+ expect(monthLabel).toContain('December');
+ });
+
+ it('handles year transitions when navigating forward from December', () => {
+ const { container } = render(
+ ,
+ );
+ const navButtons = container.querySelectorAll(`.${styles.navButton}`);
+ const nextBtn = navButtons[navButtons.length - 1];
+ fireEvent.click(nextBtn);
+ const monthLabel = getMonthLabel(container);
+ expect(monthLabel).toContain('2027');
+ expect(monthLabel).toContain('January');
+ });
+
+ it('renders correctly for leap year dates', () => {
+ const { container } = render(
+ ,
+ );
+ const monthLabel = getMonthLabel(container);
+ expect(monthLabel).toContain('February');
+ expect(monthLabel).toContain('2024');
+ });
+
+ it('handles range selection from end-of-month to next month', () => {
+ const mockOnChange = jest.fn();
+ const { container } = render(
+ ,
+ );
+ const navButtons = container.querySelectorAll(`.${styles.navButton}`);
+ const nextBtn = navButtons[navButtons.length - 1];
+ fireEvent.click(nextBtn);
+ const dayBtn1 = findDayButton(container, 1);
+ fireEvent.click(dayBtn1!);
+ expect(mockOnChange).toHaveBeenCalledWith('2026-03-31', '2026-04-01');
+ });
+ });
+
+ describe('range styling', () => {
+ it('applies dayRangeStart styling to the start date with proper background gradient', () => {
+ const { container } = render(
+ ,
+ );
+ const dayBtn15 = findDayButton(container, 15);
+ const dayCellContainer = dayBtn15?.closest(`.${styles.dayCell}`);
+ expect(dayCellContainer).toHaveClass(styles.dayRangeStart);
+ });
+
+ it('applies dayRangeEnd styling to the end date with proper background gradient', () => {
+ const { container } = render(
+ ,
+ );
+ const dayBtn25 = findDayButton(container, 25);
+ const dayCellContainer = dayBtn25?.closest(`.${styles.dayCell}`);
+ expect(dayCellContainer).toHaveClass(styles.dayRangeEnd);
+ });
+
+ it('does not apply range styling when only start date is selected', () => {
+ const { container } = render(
+ ,
+ );
+ const dayBtn20 = findDayButton(container, 20);
+ const dayCellContainer = dayBtn20?.closest(`.${styles.dayCell}`);
+ expect(dayCellContainer).not.toHaveClass(styles.dayInRange);
+ });
+
+ it('shows hover preview when hovering over day after start date during selecting-end phase', () => {
+ const { container } = render(
+ ,
+ );
+ const dayBtn25 = findDayButton(container, 25);
+ fireEvent.mouseEnter(dayBtn25!);
+ // Day 20 is between 15 (start) and 25 (hover), should show in-range
+ const dayBtn20 = findDayButton(container, 20);
+ const dayCellContainer = dayBtn20?.closest(`.${styles.dayCell}`);
+ expect(dayCellContainer).toHaveClass(styles.dayInRange);
+ });
+ });
+});
diff --git a/client/src/components/DateRangePicker/DateRangePicker.tsx b/client/src/components/DateRangePicker/DateRangePicker.tsx
new file mode 100644
index 000000000..39a2bd1ba
--- /dev/null
+++ b/client/src/components/DateRangePicker/DateRangePicker.tsx
@@ -0,0 +1,411 @@
+/**
+ * DateRangePicker — calendar-based date range selection component.
+ *
+ * Replaces native date inputs with a calendar view supporting:
+ * - Two-phase selection (start date, then end date)
+ * - Range highlighting with hover preview
+ * - Auto-advance from start to end date selection
+ * - Keyboard navigation (arrow keys, Enter, Space)
+ * - Proper ARIA labels and keyboard accessibility
+ */
+
+import { useState, useCallback, useRef, useEffect } from 'react';
+import { useTranslation } from 'react-i18next';
+import {
+ parseIsoDate,
+ formatIsoDate,
+ getTodayStr,
+ getMonthGrid,
+ getMonthName,
+ getDayNameNarrow,
+ formatDateForAria,
+ prevMonth,
+ nextMonth,
+} from '../calendar/calendarUtils.js';
+import { useLocale } from '../../contexts/LocaleContext.js';
+import styles from './DateRangePicker.module.css';
+
+export interface DateRangePickerProps {
+ startDate: string; // YYYY-MM-DD or ''
+ endDate: string; // YYYY-MM-DD or ''
+ onChange: (startDate: string, endDate: string) => void;
+ ariaLabel?: string;
+}
+
+type SelectionPhase = 'selecting-start' | 'selecting-end';
+
+/**
+ * ChevronLeftIcon — navigation arrow for previous month
+ */
+function ChevronLeftIcon() {
+ return (
+
+
+
+ );
+}
+
+/**
+ * ChevronRightIcon — navigation arrow for next month
+ */
+function ChevronRightIcon() {
+ return (
+
+
+
+ );
+}
+
+export function DateRangePicker({ startDate, endDate, onChange, ariaLabel }: DateRangePickerProps) {
+ const { t } = useTranslation('common');
+ const { locale } = useLocale();
+
+ // Initialize phase based on startDate
+ const [phase, setPhase] = useState(
+ startDate ? 'selecting-end' : 'selecting-start',
+ );
+
+ // Hover preview for range (only during end date selection)
+ const [hoverDate, setHoverDate] = useState('');
+
+ // View state (month/year to display)
+ const today = getTodayStr();
+ const todayDate = parseIsoDate(today);
+ const startDateDate = startDate ? parseIsoDate(startDate) : null;
+ const initYear = startDateDate ? startDateDate.getUTCFullYear() : todayDate.getUTCFullYear();
+ const initMonth = startDateDate ? startDateDate.getUTCMonth() + 1 : todayDate.getUTCMonth() + 1;
+
+ const [viewYear, setViewYear] = useState(initYear);
+ const [viewMonth, setViewMonth] = useState(initMonth);
+
+ // Keyboard focus state
+ const [focusedDate, setFocusedDate] = useState(startDate || today);
+ const gridContainerRef = useRef(null);
+ const focusedButtonRef = useRef(null);
+ const hasMountedRef = useRef(false);
+
+ // Sync focusedDate ref for focus management (only after initial mount)
+ useEffect(() => {
+ if (!hasMountedRef.current) {
+ hasMountedRef.current = true;
+ return;
+ }
+ if (focusedButtonRef.current) {
+ focusedButtonRef.current.focus();
+ }
+ }, [focusedDate]);
+
+ // Sync phase when incoming props change
+ useEffect(() => {
+ if (startDate === '') {
+ setPhase('selecting-start');
+ } else if (endDate === '') {
+ setPhase('selecting-end');
+ }
+ }, [startDate, endDate]);
+
+ // Get the effective end date for range display (hover preview or confirmed end)
+ const effectiveEnd = phase === 'selecting-end' && hoverDate ? hoverDate : endDate;
+
+ // Check if a day is in the highlighted range (strictly between start and end)
+ const isInRange = useCallback(
+ (dateStr: string): boolean => {
+ return !!(startDate && effectiveEnd && dateStr > startDate && dateStr < effectiveEnd);
+ },
+ [startDate, effectiveEnd],
+ );
+
+ // Check if a day is the range start
+ const isRangeStart = useCallback(
+ (dateStr: string): boolean => {
+ return (
+ dateStr === startDate && (endDate !== '' || (phase === 'selecting-end' && hoverDate !== ''))
+ );
+ },
+ [startDate, endDate, phase, hoverDate],
+ );
+
+ // Check if a day is the range end
+ const isRangeEnd = useCallback(
+ (dateStr: string): boolean => {
+ return dateStr === effectiveEnd && startDate !== '';
+ },
+ [startDate, effectiveEnd],
+ );
+
+ // Handle day cell click
+ const handleDayClick = useCallback(
+ (dateStr: string) => {
+ if (phase === 'selecting-start') {
+ // First selection: emit start date, switch to end selection
+ setPhase('selecting-end');
+ setHoverDate('');
+ onChange(dateStr, '');
+ setFocusedDate(dateStr);
+ } else {
+ // Second selection (selecting-end)
+ if (dateStr < startDate!) {
+ // Clicking before start date: reset to new start
+ setPhase('selecting-end');
+ setHoverDate('');
+ onChange(dateStr, '');
+ setFocusedDate(dateStr);
+ } else if (dateStr === startDate!) {
+ // Clicking on start date again: clear everything
+ setPhase('selecting-start');
+ setHoverDate('');
+ onChange('', '');
+ setFocusedDate(dateStr);
+ } else {
+ // Normal end date selection
+ setHoverDate('');
+ onChange(startDate!, dateStr);
+ setFocusedDate(dateStr);
+ }
+ }
+ },
+ [phase, startDate, onChange],
+ );
+
+ // Handle hover during end date selection
+ const handleDayMouseEnter = useCallback(
+ (dateStr: string) => {
+ if (phase === 'selecting-end' && startDate) {
+ setHoverDate(dateStr);
+ }
+ },
+ [phase, startDate],
+ );
+
+ const handleDayMouseLeave = useCallback(() => {
+ if (phase === 'selecting-end') {
+ setHoverDate('');
+ }
+ }, [phase]);
+
+ // Keyboard navigation
+ const handleKeyDown = useCallback(
+ (e: React.KeyboardEvent) => {
+ if (!gridContainerRef.current) return;
+
+ const focusedDateObj = parseIsoDate(focusedDate);
+ let newDate: Date | null = null;
+
+ switch (e.key) {
+ case 'ArrowRight':
+ newDate = new Date(
+ Date.UTC(
+ focusedDateObj.getUTCFullYear(),
+ focusedDateObj.getUTCMonth(),
+ focusedDateObj.getUTCDate() + 1,
+ ),
+ );
+ e.preventDefault();
+ break;
+ case 'ArrowLeft':
+ newDate = new Date(
+ Date.UTC(
+ focusedDateObj.getUTCFullYear(),
+ focusedDateObj.getUTCMonth(),
+ focusedDateObj.getUTCDate() - 1,
+ ),
+ );
+ e.preventDefault();
+ break;
+ case 'ArrowDown':
+ newDate = new Date(
+ Date.UTC(
+ focusedDateObj.getUTCFullYear(),
+ focusedDateObj.getUTCMonth(),
+ focusedDateObj.getUTCDate() + 7,
+ ),
+ );
+ e.preventDefault();
+ break;
+ case 'ArrowUp':
+ newDate = new Date(
+ Date.UTC(
+ focusedDateObj.getUTCFullYear(),
+ focusedDateObj.getUTCMonth(),
+ focusedDateObj.getUTCDate() - 7,
+ ),
+ );
+ e.preventDefault();
+ break;
+ case 'Enter':
+ case ' ':
+ // Select focused date
+ handleDayClick(focusedDate);
+ e.preventDefault();
+ break;
+ case 'Escape':
+ if (phase === 'selecting-end' && endDate === '') {
+ setPhase('selecting-start');
+ setHoverDate('');
+ onChange('', '');
+ }
+ e.preventDefault();
+ break;
+ default:
+ return;
+ }
+
+ if (newDate) {
+ const newDateStr = formatIsoDate(newDate);
+ setFocusedDate(newDateStr);
+
+ // Auto-navigate month if needed
+ const newYear = newDate.getUTCFullYear();
+ const newMonth = newDate.getUTCMonth() + 1;
+ if (newYear !== viewYear || newMonth !== viewMonth) {
+ setViewYear(newYear);
+ setViewMonth(newMonth);
+ }
+ }
+ },
+ [focusedDate, viewYear, viewMonth, handleDayClick, phase, endDate, onChange],
+ );
+
+ // Month navigation
+ const handlePrevMonth = useCallback(() => {
+ const prev = prevMonth(viewYear, viewMonth);
+ setViewYear(prev.year);
+ setViewMonth(prev.month);
+ }, [viewYear, viewMonth]);
+
+ const handleNextMonth = useCallback(() => {
+ const next = nextMonth(viewYear, viewMonth);
+ setViewYear(next.year);
+ setViewMonth(next.month);
+ }, [viewYear, viewMonth]);
+
+ // Render the calendar grid
+ const weeks = getMonthGrid(viewYear, viewMonth);
+ const monthName = getMonthName(viewMonth, locale);
+
+ return (
+
+ {/* Month navigation */}
+
+
+
+
+
+ {monthName} {viewYear}
+
+
+
+
+
+
+ {/* Week rows with day buttons */}
+
+ {/* Header row with day names */}
+
+ {[0, 1, 2, 3, 4, 5, 6].map((dayIndex) => (
+
+ {getDayNameNarrow(dayIndex, locale)}
+
+ ))}
+
+
+ {weeks.map((week, weekIndex) => (
+
+ {week.map((day) => {
+ const isSelected = day.dateStr === startDate || day.dateStr === endDate;
+ const inRange = isInRange(day.dateStr);
+ const isStart = isRangeStart(day.dateStr);
+ const isEnd = isRangeEnd(day.dateStr);
+ const isBeforeStart = phase === 'selecting-end' && day.dateStr < startDate!;
+ const isFocused = day.dateStr === focusedDate;
+
+ return (
+
+ handleDayClick(day.dateStr)}
+ onMouseEnter={() => handleDayMouseEnter(day.dateStr)}
+ onMouseLeave={handleDayMouseLeave}
+ aria-label={formatDateForAria(day.dateStr)}
+ aria-pressed={isSelected}
+ tabIndex={isFocused ? 0 : -1}
+ >
+ {day.dayOfMonth}
+
+
+ );
+ })}
+
+ ))}
+
+
+ {/* Phase indicator */}
+
+ {phase === 'selecting-start'
+ ? t('dateRangePicker.selectStart')
+ : t('dateRangePicker.selectEnd')}
+
+
+ );
+}
diff --git a/client/src/components/DateRangePicker/index.ts b/client/src/components/DateRangePicker/index.ts
new file mode 100644
index 000000000..a5eaeeb65
--- /dev/null
+++ b/client/src/components/DateRangePicker/index.ts
@@ -0,0 +1,2 @@
+export { DateRangePicker } from './DateRangePicker.js';
+export type { DateRangePickerProps } from './DateRangePicker.js';
diff --git a/client/src/components/Header/Header.module.css b/client/src/components/Header/Header.module.css
deleted file mode 100644
index abf7110c3..000000000
--- a/client/src/components/Header/Header.module.css
+++ /dev/null
@@ -1,47 +0,0 @@
-.header {
- height: 60px;
- border-bottom: 1px solid var(--color-border);
- display: flex;
- align-items: center;
- padding: 0 1rem;
- background-color: var(--color-bg-primary);
-}
-
-.menuButton {
- min-width: 44px;
- min-height: 44px;
- border: none;
- background: transparent;
- font-size: 1.5rem;
- cursor: pointer;
- display: flex;
- align-items: center;
- justify-content: center;
- border-radius: 0.25rem;
- transition: background-color 0.2s ease;
-}
-
-.menuButton:hover {
- background-color: var(--color-bg-tertiary);
-}
-
-.menuButton:focus-visible {
- outline: 2px solid var(--color-primary);
- outline-offset: 2px;
-}
-
-.titleArea {
- margin-left: 1rem;
- flex: 1;
-}
-
-/* Desktop: hide menu button */
-@media (min-width: 1025px) {
- .menuButton {
- display: none;
- }
-
- .titleArea {
- margin-left: 0;
- }
-}
diff --git a/client/src/components/Header/Header.test.tsx b/client/src/components/Header/Header.test.tsx
deleted file mode 100644
index 42dd42e0c..000000000
--- a/client/src/components/Header/Header.test.tsx
+++ /dev/null
@@ -1,88 +0,0 @@
-import { jest } from '@jest/globals';
-import { render, screen } from '@testing-library/react';
-import userEvent from '@testing-library/user-event';
-import { Header } from './Header';
-
-describe('Header', () => {
- it('renders menu toggle button with "Open menu" aria-label when sidebar is closed', () => {
- const mockToggle = jest.fn();
- render();
-
- const button = screen.getByRole('button', { name: /open menu/i });
- expect(button).toBeInTheDocument();
- });
-
- it('calls onToggleSidebar when menu button is clicked', async () => {
- const mockToggle = jest.fn();
- const user = userEvent.setup();
-
- render();
-
- const button = screen.getByRole('button', { name: /open menu/i });
- await user.click(button);
-
- expect(mockToggle).toHaveBeenCalledTimes(1);
- });
-
- it('calls onToggleSidebar multiple times on repeated clicks', async () => {
- const mockToggle = jest.fn();
- const user = userEvent.setup();
-
- render();
-
- const button = screen.getByRole('button', { name: /open menu/i });
- await user.click(button);
- await user.click(button);
- await user.click(button);
-
- expect(mockToggle).toHaveBeenCalledTimes(3);
- });
-
- it('renders title area with correct data-testid', () => {
- const mockToggle = jest.fn();
- render();
-
- const titleArea = screen.getByTestId('page-title');
- expect(titleArea).toBeInTheDocument();
- });
-
- it('menu button has type="button" to prevent form submission', () => {
- const mockToggle = jest.fn();
- render();
-
- const button = screen.getByRole('button', { name: /open menu/i });
- expect(button).toHaveAttribute('type', 'button');
- });
-
- it('shows ☰ icon when sidebar is closed', () => {
- const mockToggle = jest.fn();
- render();
-
- const button = screen.getByRole('button', { name: /open menu/i });
- expect(button).toHaveTextContent('☰');
- });
-
- it('shows ✕ icon when sidebar is open', () => {
- const mockToggle = jest.fn();
- render();
-
- const button = screen.getByRole('button', { name: /close menu/i });
- expect(button).toHaveTextContent('✕');
- });
-
- it('has "Open menu" aria-label when sidebar is closed', () => {
- const mockToggle = jest.fn();
- render();
-
- const button = screen.getByRole('button', { name: /open menu/i });
- expect(button).toHaveAttribute('aria-label', 'Open menu');
- });
-
- it('has "Close menu" aria-label when sidebar is open', () => {
- const mockToggle = jest.fn();
- render();
-
- const button = screen.getByRole('button', { name: /close menu/i });
- expect(button).toHaveAttribute('aria-label', 'Close menu');
- });
-});
diff --git a/client/src/components/Header/Header.tsx b/client/src/components/Header/Header.tsx
deleted file mode 100644
index c437210f0..000000000
--- a/client/src/components/Header/Header.tsx
+++ /dev/null
@@ -1,25 +0,0 @@
-import { useTranslation } from 'react-i18next';
-import styles from './Header.module.css';
-
-interface HeaderProps {
- onToggleSidebar: () => void;
- isSidebarOpen: boolean;
-}
-
-export function Header({ onToggleSidebar, isSidebarOpen }: HeaderProps) {
- const { t } = useTranslation('common');
-
- return (
-
-
- {isSidebarOpen ? '✕' : '☰'}
-
-
-
- );
-}
diff --git a/client/src/components/PageLayout/PageLayout.module.css b/client/src/components/PageLayout/PageLayout.module.css
new file mode 100644
index 000000000..98096d2d3
--- /dev/null
+++ b/client/src/components/PageLayout/PageLayout.module.css
@@ -0,0 +1,78 @@
+.container {
+ max-width: 1400px;
+ margin: 0 auto;
+ padding: var(--spacing-8);
+ display: flex;
+ flex-direction: column;
+}
+
+.containerNarrow {
+ max-width: 1200px;
+}
+
+.header {
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ margin-bottom: var(--spacing-6);
+}
+
+.title {
+ font-size: var(--font-size-4xl);
+ font-weight: var(--font-weight-bold);
+ color: var(--color-text-primary);
+ margin: 0;
+}
+
+.action {
+ flex-shrink: 0;
+}
+
+.subNav {
+ margin-bottom: var(--spacing-4);
+}
+
+.content {
+ display: flex;
+ flex-direction: column;
+}
+
+@media (max-width: 767px) {
+ .container {
+ padding: var(--spacing-4);
+ }
+
+ .header {
+ flex-direction: column;
+ align-items: flex-start;
+ gap: var(--spacing-3);
+ margin-bottom: var(--spacing-4);
+ }
+
+ .title {
+ font-size: var(--font-size-2xl);
+ }
+
+ .action {
+ width: 100%;
+ }
+
+ .action > * {
+ width: 100%;
+ min-height: 44px;
+ }
+
+ .subNav {
+ margin-bottom: var(--spacing-3);
+ }
+}
+
+@media (min-width: 768px) and (max-width: 1023px) {
+ .action > * {
+ min-height: 44px;
+ }
+
+ .header {
+ margin-bottom: var(--spacing-4);
+ }
+}
diff --git a/client/src/components/PageLayout/PageLayout.test.tsx b/client/src/components/PageLayout/PageLayout.test.tsx
new file mode 100644
index 000000000..9ee039452
--- /dev/null
+++ b/client/src/components/PageLayout/PageLayout.test.tsx
@@ -0,0 +1,166 @@
+/**
+ * @jest-environment jsdom
+ */
+import { describe, it, expect } from '@jest/globals';
+import { render, screen } from '@testing-library/react';
+import { PageLayout } from './PageLayout.js';
+
+// CSS modules are mocked via identity-obj-proxy (classNames returned as-is)
+
+describe('PageLayout', () => {
+ // ── title prop ────────────────────────────────────────────────────────────
+
+ it('renders h1 with the given title', () => {
+ render(
+
+ content
+ ,
+ );
+
+ expect(screen.getByRole('heading', { level: 1, name: 'Work Items' })).toBeInTheDocument();
+ });
+
+ // ── children ──────────────────────────────────────────────────────────────
+
+ it('renders children in the DOM', () => {
+ render(
+
+ Hello
+ ,
+ );
+
+ expect(screen.getByTestId('child-content')).toBeInTheDocument();
+ expect(screen.getByTestId('child-content')).toHaveTextContent('Hello');
+ });
+
+ // ── action prop ───────────────────────────────────────────────────────────
+
+ it('renders action content when action prop is provided', () => {
+ render(
+ New}>
+ content
+ ,
+ );
+
+ expect(screen.getByRole('button', { name: 'New' })).toBeInTheDocument();
+ });
+
+ it('does not render the action wrapper div when action is undefined', () => {
+ const { container } = render(
+
+ content
+ ,
+ );
+
+ // identity-obj-proxy returns CSS module class names as-is
+ expect(container.querySelector('.action')).toBeNull();
+ });
+
+ // ── subNav prop ───────────────────────────────────────────────────────────
+
+ it('renders subNav content when subNav prop is provided', () => {
+ render(
+ nav}>
+ content
+ ,
+ );
+
+ expect(screen.getByTestId('sub-nav')).toBeInTheDocument();
+ });
+
+ it('does not render the subNav wrapper div when subNav is undefined', () => {
+ const { container } = render(
+
+ content
+ ,
+ );
+
+ expect(container.querySelector('.subNav')).toBeNull();
+ });
+
+ // ── maxWidth prop ─────────────────────────────────────────────────────────
+
+ it('does not apply containerNarrow class by default (wide)', () => {
+ const { container } = render(
+
+ content
+ ,
+ );
+
+ const outer = container.firstElementChild as HTMLElement;
+ expect(outer.className).not.toContain('containerNarrow');
+ });
+
+ it('applies containerNarrow class when maxWidth is "narrow"', () => {
+ const { container } = render(
+
+ content
+ ,
+ );
+
+ const outer = container.firstElementChild as HTMLElement;
+ expect(outer.className).toContain('containerNarrow');
+ });
+
+ it('does not apply containerNarrow class when maxWidth is "wide"', () => {
+ const { container } = render(
+
+ content
+ ,
+ );
+
+ const outer = container.firstElementChild as HTMLElement;
+ expect(outer.className).not.toContain('containerNarrow');
+ });
+
+ // ── testId prop ───────────────────────────────────────────────────────────
+
+ it('applies data-testid to the container when testId prop is provided', () => {
+ const { container } = render(
+
+ content
+ ,
+ );
+
+ const outer = container.firstElementChild as HTMLElement;
+ expect(outer).toHaveAttribute('data-testid', 'my-page');
+ });
+
+ it('does not add data-testid attribute when testId is omitted', () => {
+ const { container } = render(
+
+ content
+ ,
+ );
+
+ const outer = container.firstElementChild as HTMLElement;
+ expect(outer).not.toHaveAttribute('data-testid');
+ });
+
+ // ── combined props ────────────────────────────────────────────────────────
+
+ it('renders title, action, subNav, and children together correctly', () => {
+ render(
+ Add}
+ subNav={tabs }
+ >
+
+ ,
+ );
+
+ expect(screen.getByRole('heading', { level: 1, name: 'Budget' })).toBeInTheDocument();
+ expect(screen.getByRole('button', { name: 'Add' })).toBeInTheDocument();
+ expect(screen.getByRole('navigation', { name: 'Budget nav' })).toBeInTheDocument();
+ expect(screen.getByRole('cell', { name: 'Row 1' })).toBeInTheDocument();
+ });
+});
diff --git a/client/src/components/PageLayout/PageLayout.tsx b/client/src/components/PageLayout/PageLayout.tsx
new file mode 100644
index 000000000..98341e549
--- /dev/null
+++ b/client/src/components/PageLayout/PageLayout.tsx
@@ -0,0 +1,42 @@
+import type { ReactNode } from 'react';
+import styles from './PageLayout.module.css';
+
+export interface PageLayoutProps {
+ title: string;
+ maxWidth?: 'narrow' | 'wide';
+ action?: ReactNode;
+ subNav?: ReactNode;
+ children: ReactNode;
+ testId?: string;
+}
+
+/**
+ * PageLayout — shared page structure for consistent headers, navigation, and content layout.
+ *
+ * Provides a standard container with optional sub-navigation tabs, title heading, and action button.
+ * Handles responsive layout with proper spacing and alignment.
+ */
+export function PageLayout({
+ title,
+ maxWidth = 'wide',
+ action,
+ subNav,
+ children,
+ testId,
+}: PageLayoutProps) {
+ return (
+
+
+
{title}
+ {action &&
{action}
}
+
+ {subNav &&
{subNav}
}
+
{children}
+
+ );
+}
+
+export default PageLayout;
diff --git a/client/src/components/PageLayout/index.ts b/client/src/components/PageLayout/index.ts
new file mode 100644
index 000000000..1fbbc9695
--- /dev/null
+++ b/client/src/components/PageLayout/index.ts
@@ -0,0 +1 @@
+export { PageLayout, type PageLayoutProps } from './PageLayout.js';
diff --git a/client/src/components/ProjectSubNav/ProjectSubNav.module.css b/client/src/components/ProjectSubNav/ProjectSubNav.module.css
deleted file mode 100644
index 566d64816..000000000
--- a/client/src/components/ProjectSubNav/ProjectSubNav.module.css
+++ /dev/null
@@ -1,78 +0,0 @@
-/* ============================================================
- * ProjectSubNav — horizontal tab navigation for the Project section
- * ============================================================ */
-
-.subNav {
- width: 100%;
-}
-
-/* Scrollable tab row — scrolls horizontally when tabs overflow on mobile */
-.tabList {
- display: flex;
- flex-direction: row;
- gap: 0;
- border-bottom: 2px solid var(--color-border);
- overflow-x: auto;
- -webkit-overflow-scrolling: touch;
- /* Hide scrollbar on browsers that support it — tabs still scroll on touch */
- scrollbar-width: none; /* Firefox */
-}
-
-.tabList::-webkit-scrollbar {
- display: none; /* Chrome / Safari */
-}
-
-/* Individual tab link */
-.tab {
- display: inline-flex;
- align-items: center;
- padding: var(--spacing-2-5) var(--spacing-4);
- font-size: var(--font-size-sm);
- font-weight: var(--font-weight-medium);
- color: var(--color-text-secondary);
- text-decoration: none;
- white-space: nowrap;
- border-bottom: 2px solid transparent;
- /* Pull the tab border down so it overlaps the container border */
- margin-bottom: -2px;
- transition:
- color var(--transition-normal),
- border-color var(--transition-normal),
- background-color var(--transition-normal);
- border-radius: var(--radius-sm) var(--radius-sm) 0 0;
-}
-
-.tab:hover {
- color: var(--color-text-primary);
- background-color: var(--color-bg-secondary);
-}
-
-.tab:focus-visible {
- outline: none;
- box-shadow: var(--shadow-focus-subtle);
- border-radius: var(--radius-sm);
-}
-
-/* Active tab */
-.tabActive {
- color: var(--color-primary);
- border-bottom-color: var(--color-primary);
- font-weight: var(--font-weight-semibold);
-}
-
-.tabActive:hover {
- color: var(--color-primary-hover);
- background-color: var(--color-bg-secondary);
- border-bottom-color: var(--color-primary-hover);
-}
-
-/* ============================================================
- * RESPONSIVE — Mobile (max 767px): reduce padding for smaller screens
- * ============================================================ */
-
-@media (max-width: 767px) {
- .tab {
- padding: var(--spacing-2) var(--spacing-3);
- font-size: var(--font-size-xs);
- }
-}
diff --git a/client/src/components/ProjectSubNav/ProjectSubNav.tsx b/client/src/components/ProjectSubNav/ProjectSubNav.tsx
deleted file mode 100644
index 85ec237aa..000000000
--- a/client/src/components/ProjectSubNav/ProjectSubNav.tsx
+++ /dev/null
@@ -1,41 +0,0 @@
-import { NavLink } from 'react-router-dom';
-import { useTranslation } from 'react-i18next';
-import styles from './ProjectSubNav.module.css';
-
-const PROJECT_TABS = [
- { labelKey: 'subnav.project.overview', to: '/project/overview' },
- { labelKey: 'subnav.project.workItems', to: '/project/work-items' },
- { labelKey: 'subnav.project.householdItems', to: '/project/household-items' },
- { labelKey: 'subnav.project.milestones', to: '/project/milestones' },
-] as const;
-
-/**
- * ProjectSubNav — horizontal tab-style navigation for the Project section.
- *
- * Renders a scrollable row of tab links for all project sub-pages.
- * The currently active tab is highlighted using the primary design token.
- * On mobile the row scrolls horizontally so all tabs remain reachable.
- */
-export function ProjectSubNav() {
- const { t } = useTranslation('common');
-
- return (
-
-
- {PROJECT_TABS.map((tab) => (
- `${styles.tab} ${isActive ? styles.tabActive : ''}`}
- role="listitem"
- >
- {t(tab.labelKey)}
-
- ))}
-
-
- );
-}
-
-export default ProjectSubNav;
diff --git a/client/src/components/ScheduleSubNav/ScheduleSubNav.module.css b/client/src/components/ScheduleSubNav/ScheduleSubNav.module.css
deleted file mode 100644
index 47bb9734d..000000000
--- a/client/src/components/ScheduleSubNav/ScheduleSubNav.module.css
+++ /dev/null
@@ -1,78 +0,0 @@
-/* ============================================================
- * ScheduleSubNav — horizontal tab navigation for the Schedule section
- * ============================================================ */
-
-.subNav {
- width: 100%;
-}
-
-/* Scrollable tab row — scrolls horizontally when tabs overflow on mobile */
-.tabList {
- display: flex;
- flex-direction: row;
- gap: 0;
- border-bottom: 2px solid var(--color-border);
- overflow-x: auto;
- -webkit-overflow-scrolling: touch;
- /* Hide scrollbar on browsers that support it — tabs still scroll on touch */
- scrollbar-width: none; /* Firefox */
-}
-
-.tabList::-webkit-scrollbar {
- display: none; /* Chrome / Safari */
-}
-
-/* Individual tab link */
-.tab {
- display: inline-flex;
- align-items: center;
- padding: var(--spacing-2-5) var(--spacing-4);
- font-size: var(--font-size-sm);
- font-weight: var(--font-weight-medium);
- color: var(--color-text-secondary);
- text-decoration: none;
- white-space: nowrap;
- border-bottom: 2px solid transparent;
- /* Pull the tab border down so it overlaps the container border */
- margin-bottom: -2px;
- transition:
- color var(--transition-normal),
- border-color var(--transition-normal),
- background-color var(--transition-normal);
- border-radius: var(--radius-sm) var(--radius-sm) 0 0;
-}
-
-.tab:hover {
- color: var(--color-text-primary);
- background-color: var(--color-bg-secondary);
-}
-
-.tab:focus-visible {
- outline: none;
- box-shadow: var(--shadow-focus-subtle);
- border-radius: var(--radius-sm);
-}
-
-/* Active tab */
-.tabActive {
- color: var(--color-primary);
- border-bottom-color: var(--color-primary);
- font-weight: var(--font-weight-semibold);
-}
-
-.tabActive:hover {
- color: var(--color-primary-hover);
- background-color: var(--color-bg-secondary);
- border-bottom-color: var(--color-primary-hover);
-}
-
-/* ============================================================
- * RESPONSIVE — Mobile (max 767px): reduce padding for smaller screens
- * ============================================================ */
-
-@media (max-width: 767px) {
- .tab {
- padding: var(--spacing-2) var(--spacing-3);
- font-size: var(--font-size-xs);
- }
-}
diff --git a/client/src/components/ScheduleSubNav/ScheduleSubNav.test.tsx b/client/src/components/ScheduleSubNav/ScheduleSubNav.test.tsx
index f55b34df0..f7d666efa 100644
--- a/client/src/components/ScheduleSubNav/ScheduleSubNav.test.tsx
+++ b/client/src/components/ScheduleSubNav/ScheduleSubNav.test.tsx
@@ -24,10 +24,12 @@ describe('ScheduleSubNav', () => {
expect(screen.getByText('Calendar')).toBeInTheDocument();
});
- it('renders nav with aria-label="Schedule view navigation"', () => {
+ it('renders nav with aria-label="Schedule section navigation"', () => {
renderNav();
- expect(screen.getByRole('navigation', { name: 'Gantt' })).toBeInTheDocument();
+ expect(
+ screen.getByRole('navigation', { name: 'Schedule section navigation' }),
+ ).toBeInTheDocument();
});
it('renders data-testid="schedule-view-gantt" on Gantt tab', () => {
diff --git a/client/src/components/ScheduleSubNav/ScheduleSubNav.tsx b/client/src/components/ScheduleSubNav/ScheduleSubNav.tsx
index e6c1ce748..a1f17e544 100644
--- a/client/src/components/ScheduleSubNav/ScheduleSubNav.tsx
+++ b/client/src/components/ScheduleSubNav/ScheduleSubNav.tsx
@@ -1,36 +1,25 @@
-import { NavLink } from 'react-router-dom';
import { useTranslation } from 'react-i18next';
-import styles from './ScheduleSubNav.module.css';
+import { SubNav, type SubNavTab } from '../SubNav/SubNav.js';
export function ScheduleSubNav() {
const { t } = useTranslation('schedule');
- const scheduleTabs = [
- { labelKey: 'schedule.navigation.gantt', to: '/schedule/gantt' },
- { labelKey: 'schedule.navigation.calendar', to: '/schedule/calendar' },
- ] as const;
+ const scheduleTabs: SubNavTab[] = [
+ {
+ labelKey: 'schedule.navigation.gantt',
+ to: '/schedule/gantt',
+ ns: 'schedule',
+ testId: 'schedule-view-gantt',
+ },
+ {
+ labelKey: 'schedule.navigation.calendar',
+ to: '/schedule/calendar',
+ ns: 'schedule',
+ testId: 'schedule-view-calendar',
+ },
+ ];
- return (
-
-
- {scheduleTabs.map((tab) => {
- const label = t(tab.labelKey);
- return (
- `${styles.tab} ${isActive ? styles.tabActive : ''}`}
- role="listitem"
- data-testid={`schedule-view-${tab.labelKey.split('.').pop()!.toLowerCase()}`}
- >
- {label}
-
- );
- })}
-
-
- );
+ return ;
}
export default ScheduleSubNav;
diff --git a/client/src/components/SettingsSubNav/SettingsSubNav.module.css b/client/src/components/SettingsSubNav/SettingsSubNav.module.css
deleted file mode 100644
index be8b38e13..000000000
--- a/client/src/components/SettingsSubNav/SettingsSubNav.module.css
+++ /dev/null
@@ -1,78 +0,0 @@
-/* ============================================================
- * SettingsSubNav — horizontal tab navigation for the Settings section
- * ============================================================ */
-
-.subNav {
- width: 100%;
-}
-
-/* Scrollable tab row — scrolls horizontally when tabs overflow on mobile */
-.tabList {
- display: flex;
- flex-direction: row;
- gap: 0;
- border-bottom: 2px solid var(--color-border);
- overflow-x: auto;
- -webkit-overflow-scrolling: touch;
- /* Hide scrollbar on browsers that support it — tabs still scroll on touch */
- scrollbar-width: none; /* Firefox */
-}
-
-.tabList::-webkit-scrollbar {
- display: none; /* Chrome / Safari */
-}
-
-/* Individual tab link */
-.tab {
- display: inline-flex;
- align-items: center;
- padding: var(--spacing-2-5) var(--spacing-4);
- font-size: var(--font-size-sm);
- font-weight: var(--font-weight-medium);
- color: var(--color-text-secondary);
- text-decoration: none;
- white-space: nowrap;
- border-bottom: 2px solid transparent;
- /* Pull the tab border down so it overlaps the container border */
- margin-bottom: -2px;
- transition:
- color var(--transition-normal),
- border-color var(--transition-normal),
- background-color var(--transition-normal);
- border-radius: var(--radius-sm) var(--radius-sm) 0 0;
-}
-
-.tab:hover {
- color: var(--color-text-primary);
- background-color: var(--color-bg-secondary);
-}
-
-.tab:focus-visible {
- outline: none;
- box-shadow: var(--shadow-focus-subtle);
- border-radius: var(--radius-sm);
-}
-
-/* Active tab */
-.tabActive {
- color: var(--color-primary);
- border-bottom-color: var(--color-primary);
- font-weight: var(--font-weight-semibold);
-}
-
-.tabActive:hover {
- color: var(--color-primary-hover);
- background-color: var(--color-bg-secondary);
- border-bottom-color: var(--color-primary-hover);
-}
-
-/* ============================================================
- * RESPONSIVE — Mobile (max 767px): reduce padding for smaller screens
- * ============================================================ */
-
-@media (max-width: 767px) {
- .tab {
- padding: var(--spacing-2) var(--spacing-3);
- font-size: var(--font-size-xs);
- }
-}
diff --git a/client/src/components/SettingsSubNav/SettingsSubNav.tsx b/client/src/components/SettingsSubNav/SettingsSubNav.tsx
deleted file mode 100644
index b9e783a13..000000000
--- a/client/src/components/SettingsSubNav/SettingsSubNav.tsx
+++ /dev/null
@@ -1,51 +0,0 @@
-import { NavLink } from 'react-router-dom';
-import { useTranslation } from 'react-i18next';
-import { useAuth } from '../../contexts/AuthContext.js';
-import styles from './SettingsSubNav.module.css';
-
-const SETTINGS_TABS = [
- { labelKey: 'subnav.settings.profile', to: '/settings/profile' },
- { labelKey: 'subnav.settings.manage', to: '/settings/manage' },
-] as const;
-
-/**
- * SettingsSubNav — horizontal tab-style navigation for the Settings section.
- *
- * Renders a scrollable row of tab links for all settings sub-pages.
- * The User Management tab is only visible to admins.
- * On mobile the row scrolls horizontally so all tabs remain reachable.
- */
-export function SettingsSubNav() {
- const { t } = useTranslation('common');
- const { user } = useAuth();
-
- return (
-
-
- {SETTINGS_TABS.map((tab) => (
- `${styles.tab} ${isActive ? styles.tabActive : ''}`}
- role="listitem"
- >
- {t(tab.labelKey)}
-
- ))}
- {user?.role === 'admin' && (
- `${styles.tab} ${isActive ? styles.tabActive : ''}`}
- role="listitem"
- >
- {t('subnav.settings.userManagement')}
-
- )}
-
-
- );
-}
-
-export default SettingsSubNav;
diff --git a/client/src/components/Sidebar/Sidebar.module.css b/client/src/components/Sidebar/Sidebar.module.css
index 7155b8ed5..eb886e7de 100644
--- a/client/src/components/Sidebar/Sidebar.module.css
+++ b/client/src/components/Sidebar/Sidebar.module.css
@@ -5,6 +5,7 @@
display: flex;
flex-direction: column;
height: 100vh;
+ height: 100dvh;
}
.logoArea {
@@ -127,10 +128,6 @@
background-color: var(--color-sidebar-active);
}
-.sidebarHeader {
- display: none;
-}
-
/* Mobile and tablet: fixed position, hidden by default */
@media (max-width: 1024px) {
.sidebar {
@@ -138,6 +135,7 @@
top: 0;
left: 0;
bottom: 0;
+ height: auto;
width: 240px;
z-index: var(--z-sidebar);
transform: translateX(-100%);
@@ -149,41 +147,6 @@
transform: translateX(0);
}
- .sidebarHeader {
- display: flex;
- justify-content: flex-start;
- padding: var(--spacing-2);
- }
-
- .closeButton {
- min-width: 44px;
- min-height: 44px;
- border: none;
- background: transparent;
- color: var(--color-sidebar-text);
- font-size: var(--font-size-2xl);
- cursor: pointer;
- display: flex;
- align-items: center;
- justify-content: center;
- border-radius: var(--radius-sm);
- opacity: 0;
- transition: opacity var(--transition-fast) 0.2s;
- }
-
- .closeButton:hover {
- background-color: var(--color-sidebar-hover);
- }
-
- .closeButton:focus-visible {
- outline: 2px solid var(--color-sidebar-focus-ring);
- outline-offset: 2px;
- }
-
- .sidebar.open .closeButton {
- opacity: 1;
- }
-
.navLink {
min-height: 44px;
display: flex;
diff --git a/client/src/components/Sidebar/Sidebar.test.tsx b/client/src/components/Sidebar/Sidebar.test.tsx
index 3c4ddce21..1ae03e95d 100644
--- a/client/src/components/Sidebar/Sidebar.test.tsx
+++ b/client/src/components/Sidebar/Sidebar.test.tsx
@@ -166,23 +166,6 @@ describe('Sidebar', () => {
expect(activeLinks[0]).toHaveTextContent(/^budget$/i);
});
- it('renders a close button with correct aria-label', () => {
- renderWithRouter( );
-
- const closeButton = screen.getByRole('button', { name: /close menu/i });
- expect(closeButton).toBeInTheDocument();
- });
-
- it('clicking close button calls onClose', async () => {
- const user = userEvent.setup();
- renderWithRouter( );
-
- const closeButton = screen.getByRole('button', { name: /close menu/i });
- await user.click(closeButton);
-
- expect(mockOnClose).toHaveBeenCalledTimes(1);
- });
-
it('sidebar has .open class when isOpen is true', () => {
renderWithRouter( );
@@ -312,10 +295,10 @@ describe('Sidebar', () => {
// 4 main nav links (Project, Budget, Schedule, Diary) + 1 logo link + 1 GitHub link
// (Settings is now a button, not a link)
expect(links).toHaveLength(6);
- // 4 buttons: close button + theme toggle + settings button + logout button
- expect(buttons).toHaveLength(4);
- expect(buttons[0]).toHaveAttribute('aria-label', 'Close menu');
- expect(buttons[3]).toHaveTextContent(/logout/i);
+ // 3 buttons: theme toggle + settings button + logout button
+ expect(buttons).toHaveLength(3);
+ expect(buttons[0]).toHaveAttribute('aria-label', expect.stringMatching(/switch to .+ mode/i));
+ expect(buttons[2]).toHaveTextContent(/logout/i);
});
it('Settings button appears immediately before the Logout button in the footer', () => {
diff --git a/client/src/components/Sidebar/Sidebar.tsx b/client/src/components/Sidebar/Sidebar.tsx
index 9d39bdec2..5208a46a9 100644
--- a/client/src/components/Sidebar/Sidebar.tsx
+++ b/client/src/components/Sidebar/Sidebar.tsx
@@ -23,16 +23,6 @@ export function Sidebar({ isOpen, onClose }: SidebarProps) {
{t('appName')}
-
-
- ✕
-
-
+
+ ,
+ );
+}
+
+const BASIC_TABS: SubNavTab[] = [
+ { labelKey: 'button.save', to: '/items/save' },
+ { labelKey: 'button.cancel', to: '/items/cancel' },
+ { labelKey: 'button.edit', to: '/items/edit' },
+ { labelKey: 'button.delete', to: '/items/delete' },
+];
+
+describe('SubNav', () => {
+ // ── ariaLabel prop ────────────────────────────────────────────────────────
+
+ it('renders a nav element with the given aria-label', () => {
+ renderSubNav(BASIC_TABS, 'Test navigation');
+
+ expect(screen.getByRole('navigation', { name: 'Test navigation' })).toBeInTheDocument();
+ });
+
+ // ── visible tabs ──────────────────────────────────────────────────────────
+
+ it('renders all tabs when all are visible', () => {
+ renderSubNav(BASIC_TABS);
+
+ // 4 tabs → 4 NavLink elements with role="listitem"
+ expect(screen.getAllByRole('listitem')).toHaveLength(4);
+ });
+
+ it('filters out tabs with visible set to false', () => {
+ const tabs: SubNavTab[] = [
+ { labelKey: 'button.save', to: '/a' },
+ { labelKey: 'button.cancel', to: '/b', visible: false },
+ { labelKey: 'button.edit', to: '/c' },
+ ];
+ renderSubNav(tabs);
+
+ // Only 2 tabs should be in the DOM
+ expect(screen.getAllByRole('listitem')).toHaveLength(2);
+ });
+
+ it('renders tabs with visible set to true', () => {
+ const tabs: SubNavTab[] = [
+ { labelKey: 'button.save', to: '/a', visible: true },
+ { labelKey: 'button.cancel', to: '/b', visible: false },
+ ];
+ renderSubNav(tabs);
+
+ expect(screen.getAllByRole('listitem')).toHaveLength(1);
+ });
+
+ it('does not render the hidden tab label', () => {
+ const tabs: SubNavTab[] = [
+ { labelKey: 'button.save', to: '/a' },
+ // 'button.cancel' resolves to 'Cancel' via i18next
+ { labelKey: 'button.cancel', to: '/b', visible: false },
+ ];
+ renderSubNav(tabs);
+
+ expect(screen.queryByText('Cancel')).toBeNull();
+ });
+
+ // ── role attributes ───────────────────────────────────────────────────────
+
+ it('renders each tab with role="listitem"', () => {
+ renderSubNav(BASIC_TABS);
+
+ const listItems = screen.getAllByRole('listitem');
+ expect(listItems).toHaveLength(4);
+ listItems.forEach((item) => {
+ expect(item).toHaveAttribute('role', 'listitem');
+ });
+ });
+
+ it('renders the tab container with role="list"', () => {
+ const { container } = renderSubNav(BASIC_TABS);
+
+ const tabList = container.querySelector('[role="list"]');
+ expect(tabList).not.toBeNull();
+ });
+
+ // ── testId prop ───────────────────────────────────────────────────────────
+
+ it('applies data-testid to the NavLink when testId is provided', () => {
+ const tabs: SubNavTab[] = [{ labelKey: 'button.save', to: '/a', testId: 'my-tab' }];
+ renderSubNav(tabs);
+
+ expect(screen.getByTestId('my-tab')).toBeInTheDocument();
+ });
+
+ it('does not add data-testid when testId is omitted', () => {
+ const tabs: SubNavTab[] = [{ labelKey: 'button.save', to: '/a' }];
+ renderSubNav(tabs);
+
+ const listItem = screen.getByRole('listitem');
+ expect(listItem).not.toHaveAttribute('data-testid');
+ });
+
+ // ── href / routing ────────────────────────────────────────────────────────
+
+ it('renders NavLink with the correct href', () => {
+ const tabs: SubNavTab[] = [
+ { labelKey: 'button.save', to: '/schedule/gantt', testId: 'gantt-tab' },
+ ];
+ renderSubNav(tabs);
+
+ expect(screen.getByTestId('gantt-tab')).toHaveAttribute('href', '/schedule/gantt');
+ });
+
+ it('renders multiple NavLinks with distinct href values', () => {
+ const tabs: SubNavTab[] = [
+ { labelKey: 'button.save', to: '/a', testId: 'tab-a' },
+ { labelKey: 'button.cancel', to: '/b', testId: 'tab-b' },
+ ];
+ renderSubNav(tabs);
+
+ expect(screen.getByTestId('tab-a')).toHaveAttribute('href', '/a');
+ expect(screen.getByTestId('tab-b')).toHaveAttribute('href', '/b');
+ });
+
+ // ── active class ──────────────────────────────────────────────────────────
+
+ it('applies the active class to the tab matching the current route', () => {
+ const tabs: SubNavTab[] = [
+ { labelKey: 'button.save', to: '/schedule/gantt', testId: 'gantt-tab' },
+ { labelKey: 'button.cancel', to: '/schedule/calendar', testId: 'calendar-tab' },
+ ];
+ renderSubNav(tabs, 'Schedule nav', '/schedule/gantt');
+
+ // identity-obj-proxy returns CSS module class names as-is
+ const ganttTab = screen.getByTestId('gantt-tab');
+ expect(ganttTab.className).toContain('tabActive');
+
+ const calendarTab = screen.getByTestId('calendar-tab');
+ expect(calendarTab.className).not.toContain('tabActive');
+ });
+
+ it('does not apply the active class to tabs that do not match the current route', () => {
+ const tabs: SubNavTab[] = [
+ { labelKey: 'button.save', to: '/a', testId: 'tab-a' },
+ { labelKey: 'button.cancel', to: '/b', testId: 'tab-b' },
+ ];
+ renderSubNav(tabs, 'Nav', '/b');
+
+ expect(screen.getByTestId('tab-a').className).not.toContain('tabActive');
+ expect(screen.getByTestId('tab-b').className).toContain('tabActive');
+ });
+
+ // ── custom namespace ──────────────────────────────────────────────────────
+
+ it('renders translated label from the schedule namespace when ns="schedule"', () => {
+ // 'schedule.navigation.gantt' in the schedule namespace resolves to 'Gantt'
+ const tabs: SubNavTab[] = [
+ { labelKey: 'schedule.navigation.gantt', to: '/schedule/gantt', ns: 'schedule' },
+ ];
+ renderSubNav(tabs);
+
+ expect(screen.getByText('Gantt')).toBeInTheDocument();
+ });
+
+ it('renders translated label from default common namespace when ns is omitted', () => {
+ // 'button.save' in the common namespace resolves to 'Save'
+ const tabs: SubNavTab[] = [{ labelKey: 'button.save', to: '/a' }];
+ renderSubNav(tabs);
+
+ expect(screen.getByText('Save')).toBeInTheDocument();
+ });
+});
diff --git a/client/src/components/SubNav/SubNav.tsx b/client/src/components/SubNav/SubNav.tsx
new file mode 100644
index 000000000..735f889c7
--- /dev/null
+++ b/client/src/components/SubNav/SubNav.tsx
@@ -0,0 +1,54 @@
+import { NavLink } from 'react-router-dom';
+import { useTranslation } from 'react-i18next';
+import styles from './SubNav.module.css';
+
+export interface SubNavTab {
+ labelKey: string;
+ to: string;
+ ns?: string;
+ visible?: boolean;
+ testId?: string;
+}
+
+export interface SubNavProps {
+ tabs: SubNavTab[];
+ ariaLabel: string;
+}
+
+/**
+ * SubNav — unified horizontal tab-style navigation component.
+ *
+ * Renders a scrollable row of tab links for sub-pages.
+ * The currently active tab is highlighted using the primary design token.
+ * On mobile the row scrolls horizontally so all tabs remain reachable.
+ */
+export function SubNav({ tabs, ariaLabel }: SubNavProps) {
+ return (
+
+
+ {tabs
+ .filter((tab) => tab.visible !== false)
+ .map((tab) => (
+
+ ))}
+
+
+ );
+}
+
+function TabLink({ tab }: { tab: SubNavTab }) {
+ const { t } = useTranslation(tab.ns ?? 'common');
+ return (
+ `${styles.tab} ${isActive ? styles.tabActive : ''}`}
+ role="listitem"
+ {...(tab.testId ? { 'data-testid': tab.testId } : {})}
+ >
+ {t(tab.labelKey)}
+
+ );
+}
+
+export default SubNav;
diff --git a/client/src/components/SubNav/index.ts b/client/src/components/SubNav/index.ts
new file mode 100644
index 000000000..4d8064860
--- /dev/null
+++ b/client/src/components/SubNav/index.ts
@@ -0,0 +1 @@
+export { SubNav, type SubNavTab, type SubNavProps } from './SubNav.js';
diff --git a/client/src/components/TradePicker/TradePicker.tsx b/client/src/components/TradePicker/TradePicker.tsx
index f94ee884a..f84738b9e 100644
--- a/client/src/components/TradePicker/TradePicker.tsx
+++ b/client/src/components/TradePicker/TradePicker.tsx
@@ -1,6 +1,8 @@
+import { useTranslation } from 'react-i18next';
import type { TradeResponse } from '@cornerstone/shared';
import { SearchPicker } from '../SearchPicker/SearchPicker.js';
import type { SearchPickerProps } from '../SearchPicker/SearchPicker.js';
+import { getCategoryDisplayName } from '../../lib/categoryUtils.js';
export interface TradePickerProps extends Omit<
SearchPickerProps,
@@ -19,14 +21,22 @@ export function TradePicker({
initialTitle,
...rest
}: TradePickerProps) {
+ const { t } = useTranslation('settings');
+
const searchFn = async (query: string): Promise => {
const lowerQuery = query.toLowerCase();
- return trades.filter((trade) => trade.name.toLowerCase().includes(lowerQuery));
+ return trades.filter(
+ (trade) =>
+ trade.name.toLowerCase().includes(lowerQuery) ||
+ getCategoryDisplayName(t, trade.name, trade.translationKey)
+ .toLowerCase()
+ .includes(lowerQuery),
+ );
};
const renderItem = (trade: TradeResponse) => ({
id: trade.id,
- label: trade.name,
+ label: getCategoryDisplayName(t, trade.name, trade.translationKey),
});
return (
diff --git a/client/src/components/budget/BudgetLineCard.tsx b/client/src/components/budget/BudgetLineCard.tsx
index cba7a41c0..ee8659300 100644
--- a/client/src/components/budget/BudgetLineCard.tsx
+++ b/client/src/components/budget/BudgetLineCard.tsx
@@ -3,6 +3,7 @@ import { useTranslation } from 'react-i18next';
import type { BaseBudgetLine, ConfidenceLevel } from '@cornerstone/shared';
import { CONFIDENCE_MARGINS } from '../../lib/budgetConstants.js';
import { useFormatters } from '../../lib/formatters.js';
+import { getCategoryDisplayName } from '../../lib/categoryUtils.js';
import styles from './BudgetLineCard.module.css';
export interface BudgetLineCardProps {
@@ -30,6 +31,7 @@ export function BudgetLineCard({
}: BudgetLineCardProps) {
const { formatCurrency } = useFormatters();
const { t } = useTranslation('budget');
+ const { t: tSettings } = useTranslation('settings');
const showInvoicedAmount = line.invoiceCount > 0;
const isQuotation = line.invoiceLink?.invoiceStatus === 'quotation';
@@ -75,7 +77,13 @@ export function BudgetLineCard({
{line.budgetCategory && (
-
{line.budgetCategory.name}
+
+ {getCategoryDisplayName(
+ tSettings,
+ line.budgetCategory.name,
+ line.budgetCategory.translationKey,
+ )}
+
)}
{line.budgetSource &&
{line.budgetSource.name} }
{line.vendor &&
{line.vendor.name} }
diff --git a/client/src/components/budget/BudgetLineForm.tsx b/client/src/components/budget/BudgetLineForm.tsx
index 03e03a3b3..dc26860e1 100644
--- a/client/src/components/budget/BudgetLineForm.tsx
+++ b/client/src/components/budget/BudgetLineForm.tsx
@@ -2,6 +2,7 @@ import { type FormEvent, type ReactNode } from 'react';
import { useTranslation } from 'react-i18next';
import type { ConfidenceLevel, Vendor, BudgetSource, BudgetCategory } from '@cornerstone/shared';
import type { BudgetLineFormState } from '../../hooks/useBudgetSection.js';
+import { getCategoryDisplayName } from '../../lib/categoryUtils.js';
import { FormError } from '../FormError/index.js';
import styles from './BudgetLineForm.module.css';
@@ -37,6 +38,7 @@ export function BudgetLineForm({
children,
}: BudgetLineFormProps) {
const { t } = useTranslation('budget');
+ const { t: tSettings } = useTranslation('settings');
return (
@@ -227,7 +229,7 @@ export function BudgetLineForm({
{t('budgetLineForm.categoryNone')}
{budgetCategories.map((cat) => (
- {cat.name}
+ {getCategoryDisplayName(tSettings, cat.name, cat.translationKey)}
))}
@@ -268,7 +270,9 @@ export function BudgetLineForm({
{vendors.map((v) => (
{v.name}
- {v.trade?.name ? ` — ${v.trade.name}` : ''}
+ {v.trade?.name
+ ? ` — ${getCategoryDisplayName(tSettings, v.trade.name, v.trade.translationKey)}`
+ : ''}
))}
diff --git a/client/src/components/budget/BudgetSection.tsx b/client/src/components/budget/BudgetSection.tsx
index f02043a2e..011b559a4 100644
--- a/client/src/components/budget/BudgetSection.tsx
+++ b/client/src/components/budget/BudgetSection.tsx
@@ -161,34 +161,51 @@ export function BudgetSection
({
{/* Unlinked budget lines */}
{unlinkedLines.map((line) => (
- openEditBudgetForm(line)}
- onDelete={() => handleDeleteBudgetLine(line.id)}
- isDeleting={deletingBudgetId === line.id}
- onConfirmDelete={onConfirmDeleteBudgetLine}
- onCancelDelete={() => setDeletingBudgetId(null)}
- >
- {/* Link to invoice button */}
- {budgetLineType && onLinkInvoice && (
- onLinkInvoice(line.id)}
- >
- {budgetLineType === 'household_item'
- ? t('detail.budget.linkInvoiceButton')
- : 'Link to Invoice'}
-
- )}
-
+ {editingBudgetId === line.id ? (
+
+ ) : (
+ openEditBudgetForm(line)}
+ onDelete={() => handleDeleteBudgetLine(line.id)}
+ isDeleting={deletingBudgetId === line.id}
+ onConfirmDelete={onConfirmDeleteBudgetLine}
+ onCancelDelete={() => setDeletingBudgetId(null)}
+ >
+ {/* Link to invoice button */}
+ {budgetLineType && onLinkInvoice && (
+ onLinkInvoice(line.id)}
+ >
+ {budgetLineType === 'household_item'
+ ? t('detail.budget.linkInvoiceButton')
+ : 'Link to Invoice'}
+
+ )}
+
+ )}
))}
- {/* Budget line form (inline) */}
- {showBudgetForm && (
+ {/* Budget line form for adding new lines (NOT editing — editing is handled inline above) */}
+ {showBudgetForm && editingBudgetId === null && (
({
onCancel={closeBudgetForm}
error={budgetFormError}
isSaving={isSavingBudget}
- isEditing={editingBudgetId !== null}
+ isEditing={false}
confidenceLabels={CONFIDENCE_LABELS}
budgetSources={budgetSources}
vendors={vendors}
diff --git a/client/src/components/budget/InvoiceLinkModal.test.tsx b/client/src/components/budget/InvoiceLinkModal.test.tsx
index 8f6fca77e..f5a6fdeb7 100644
--- a/client/src/components/budget/InvoiceLinkModal.test.tsx
+++ b/client/src/components/budget/InvoiceLinkModal.test.tsx
@@ -214,6 +214,7 @@ describe('InvoiceLinkModal', () => {
categoryId: null,
categoryName: null,
categoryColor: null,
+ categoryTranslationKey: null,
parentItemId: 'wi-1',
parentItemTitle: 'Test Item',
parentItemType: 'work_item',
@@ -272,6 +273,7 @@ describe('InvoiceLinkModal', () => {
categoryId: null,
categoryName: null,
categoryColor: null,
+ categoryTranslationKey: null,
parentItemId: 'hi-1',
parentItemTitle: 'Test HI',
parentItemType: 'household_item',
@@ -326,6 +328,7 @@ describe('InvoiceLinkModal', () => {
categoryId: null,
categoryName: null,
categoryColor: null,
+ categoryTranslationKey: null,
parentItemId: 'wi-1',
parentItemTitle: 'Test Item',
parentItemType: 'work_item',
diff --git a/client/src/hooks/useColumnPreferences.test.ts b/client/src/hooks/useColumnPreferences.test.ts
new file mode 100644
index 000000000..6a0c905e5
--- /dev/null
+++ b/client/src/hooks/useColumnPreferences.test.ts
@@ -0,0 +1,265 @@
+import { renderHook, act, waitFor } from '@testing-library/react';
+import { describe, it, expect, jest, beforeEach, afterEach } from '@jest/globals';
+
+// Mock the usePreferences hook that useColumnPreferences depends on
+const mockUpsert = jest.fn<(key: string, value: string) => Promise>();
+const mockRemove = jest.fn<(key: string) => Promise>();
+const mockRefresh = jest.fn();
+const mockUsePreferences = jest.fn();
+
+jest.unstable_mockModule('./usePreferences.js', () => ({
+ usePreferences: mockUsePreferences,
+}));
+
+import type * as UseColumnPreferencesModule from './useColumnPreferences.js';
+
+let useColumnPreferences: (typeof UseColumnPreferencesModule)['useColumnPreferences'];
+
+interface TestItem {
+ id: string;
+ title: string;
+ amount: number;
+}
+
+const COLUMNS: Array<{
+ key: string;
+ label: string;
+ defaultVisible?: boolean;
+ render: () => string;
+}> = [
+ { key: 'title', label: 'Title', defaultVisible: true, render: () => '' },
+ { key: 'amount', label: 'Amount', defaultVisible: true, render: () => '' },
+ { key: 'id', label: 'ID', defaultVisible: false, render: () => '' },
+];
+
+function makePreference(key: string, value: string) {
+ return { key, value, updatedAt: '2026-01-01T00:00:00Z' };
+}
+
+function makeUsePreferencesResult(preferences = [] as ReturnType[]) {
+ return {
+ preferences,
+ isLoading: false,
+ error: null,
+ upsert: mockUpsert,
+ remove: mockRemove,
+ refresh: mockRefresh,
+ };
+}
+
+beforeEach(async () => {
+ ({ useColumnPreferences } =
+ (await import('./useColumnPreferences.js')) as typeof UseColumnPreferencesModule);
+ mockUsePreferences.mockReset();
+ mockUpsert.mockReset();
+ mockUsePreferences.mockReturnValue(makeUsePreferencesResult());
+ mockUpsert.mockResolvedValue(undefined);
+});
+
+afterEach(() => {
+ jest.useRealTimers();
+});
+
+describe('useColumnPreferences', () => {
+ describe('initial state from defaults', () => {
+ it('initializes visibleColumns from columns with defaultVisible !== false', () => {
+ const { result } = renderHook(() => useColumnPreferences('test-page', COLUMNS as any));
+
+ expect(result.current.visibleColumns.has('title')).toBe(true);
+ expect(result.current.visibleColumns.has('amount')).toBe(true);
+ expect(result.current.visibleColumns.has('id')).toBe(false);
+ });
+
+ it('includes columns without explicit defaultVisible (treated as true)', () => {
+ const columns = [
+ { key: 'name', label: 'Name', render: () => '' }, // no defaultVisible
+ { key: 'hidden', label: 'Hidden', defaultVisible: false, render: () => '' },
+ ];
+ const { result } = renderHook(() => useColumnPreferences('test-page', columns as any));
+
+ expect(result.current.visibleColumns.has('name')).toBe(true);
+ expect(result.current.visibleColumns.has('hidden')).toBe(false);
+ });
+
+ it('returns isLoaded=true immediately (preferences available synchronously)', () => {
+ const { result } = renderHook(() => useColumnPreferences('test-page', COLUMNS as any));
+ expect(result.current.isLoaded).toBe(true);
+ });
+ });
+
+ describe('loading from preferences', () => {
+ it('loads visible columns from stored preferences when key matches', async () => {
+ mockUsePreferences.mockReturnValue(
+ makeUsePreferencesResult([
+ makePreference('table.test-page.columns', JSON.stringify(['title', 'id'])),
+ ]),
+ );
+
+ const { result } = renderHook(() => useColumnPreferences('test-page', COLUMNS as any));
+
+ await waitFor(() => {
+ expect(result.current.visibleColumns.has('title')).toBe(true);
+ expect(result.current.visibleColumns.has('id')).toBe(true);
+ expect(result.current.visibleColumns.has('amount')).toBe(false);
+ });
+ });
+
+ it('falls back to defaults when no matching preference exists', async () => {
+ mockUsePreferences.mockReturnValue(makeUsePreferencesResult([]));
+
+ const { result } = renderHook(() => useColumnPreferences('test-page', COLUMNS as any));
+
+ await waitFor(() => {
+ expect(result.current.visibleColumns.has('title')).toBe(true);
+ expect(result.current.visibleColumns.has('amount')).toBe(true);
+ expect(result.current.visibleColumns.has('id')).toBe(false);
+ });
+ });
+
+ it('falls back to defaults when stored JSON is invalid', async () => {
+ mockUsePreferences.mockReturnValue(
+ makeUsePreferencesResult([makePreference('table.test-page.columns', 'not-valid-json{{{')]),
+ );
+
+ const { result } = renderHook(() => useColumnPreferences('test-page', COLUMNS as any));
+
+ await waitFor(() => {
+ // Should use defaults when JSON parse fails
+ expect(result.current.visibleColumns.has('title')).toBe(true);
+ expect(result.current.visibleColumns.has('amount')).toBe(true);
+ });
+ });
+
+ it('uses pageKey to construct preference key "table..columns"', async () => {
+ mockUsePreferences.mockReturnValue(
+ makeUsePreferencesResult([
+ makePreference('table.invoices.columns', JSON.stringify(['amount'])),
+ makePreference('table.test-page.columns', JSON.stringify(['title'])),
+ ]),
+ );
+
+ const { result } = renderHook(() => useColumnPreferences('test-page', COLUMNS as any));
+
+ await waitFor(() => {
+ // Should use the 'test-page' key, not 'invoices'
+ expect(result.current.visibleColumns.has('title')).toBe(true);
+ expect(result.current.visibleColumns.has('amount')).toBe(false);
+ });
+ });
+ });
+
+ describe('toggleColumn', () => {
+ it('removes a visible column from visibleColumns', async () => {
+ jest.useFakeTimers();
+ const { result } = renderHook(() => useColumnPreferences('test-page', COLUMNS as any));
+
+ act(() => {
+ result.current.toggleColumn('title');
+ });
+
+ expect(result.current.visibleColumns.has('title')).toBe(false);
+ });
+
+ it('adds a hidden column to visibleColumns', () => {
+ jest.useFakeTimers();
+ const { result } = renderHook(() => useColumnPreferences('test-page', COLUMNS as any));
+
+ act(() => {
+ result.current.toggleColumn('id'); // id is hidden by default
+ });
+
+ expect(result.current.visibleColumns.has('id')).toBe(true);
+ });
+
+ it('debounces upsert — rapid toggles result in one upsert call', async () => {
+ jest.useFakeTimers();
+ const { result } = renderHook(() => useColumnPreferences('test-page', COLUMNS as any));
+
+ act(() => {
+ result.current.toggleColumn('title');
+ result.current.toggleColumn('amount');
+ result.current.toggleColumn('title');
+ result.current.toggleColumn('amount');
+ result.current.toggleColumn('id');
+ });
+
+ // Before debounce fires: no upsert call
+ expect(mockUpsert).not.toHaveBeenCalled();
+
+ // After 500ms debounce
+ await act(async () => {
+ jest.advanceTimersByTime(500);
+ });
+
+ expect(mockUpsert).toHaveBeenCalledTimes(1);
+ });
+
+ it('saves updated visible columns as JSON after debounce', async () => {
+ jest.useFakeTimers();
+ const { result } = renderHook(() => useColumnPreferences('test-page', COLUMNS as any));
+
+ act(() => {
+ result.current.toggleColumn('id'); // add id to visible
+ });
+
+ await act(async () => {
+ jest.advanceTimersByTime(500);
+ });
+
+ expect(mockUpsert).toHaveBeenCalledWith('table.test-page.columns', expect.any(String));
+ const savedValue = JSON.parse((mockUpsert.mock.calls[0] as [string, string])[1]) as {
+ visible: string[];
+ order: string[];
+ };
+ expect(savedValue.visible).toContain('id');
+ });
+ });
+
+ describe('resetToDefaults', () => {
+ it('resets visibleColumns to default-visible columns', async () => {
+ jest.useFakeTimers();
+ // Start with saved preference that hides 'title'
+ mockUsePreferences.mockReturnValue(
+ makeUsePreferencesResult([
+ makePreference('table.test-page.columns', JSON.stringify(['amount'])),
+ ]),
+ );
+
+ const { result } = renderHook(() => useColumnPreferences('test-page', COLUMNS as any));
+
+ await waitFor(() => {
+ expect(result.current.visibleColumns.has('title')).toBe(false);
+ });
+
+ act(() => {
+ result.current.resetToDefaults();
+ });
+
+ expect(result.current.visibleColumns.has('title')).toBe(true);
+ expect(result.current.visibleColumns.has('amount')).toBe(true);
+ expect(result.current.visibleColumns.has('id')).toBe(false);
+ });
+
+ it('saves defaults to preferences after debounce', async () => {
+ jest.useFakeTimers();
+ const { result } = renderHook(() => useColumnPreferences('test-page', COLUMNS as any));
+
+ act(() => {
+ result.current.resetToDefaults();
+ });
+
+ await act(async () => {
+ jest.advanceTimersByTime(500);
+ });
+
+ expect(mockUpsert).toHaveBeenCalledWith('table.test-page.columns', expect.any(String));
+ const savedValue = JSON.parse((mockUpsert.mock.calls[0] as [string, string])[1]) as {
+ visible: string[];
+ order: string[];
+ };
+ expect(savedValue.visible).toContain('title');
+ expect(savedValue.visible).toContain('amount');
+ expect(savedValue.visible).not.toContain('id');
+ });
+ });
+});
diff --git a/client/src/hooks/useColumnPreferences.ts b/client/src/hooks/useColumnPreferences.ts
new file mode 100644
index 000000000..d93433958
--- /dev/null
+++ b/client/src/hooks/useColumnPreferences.ts
@@ -0,0 +1,134 @@
+import { useState, useEffect, useRef, useCallback } from 'react';
+import type { ColumnDef } from '../components/DataTable/DataTable.js';
+import { usePreferences } from './usePreferences.js';
+
+export interface UseColumnPreferencesResult {
+ visibleColumns: Set;
+ columnOrder: string[];
+ isLoaded: boolean;
+ toggleColumn: (key: string) => void;
+ moveColumn: (from: number, to: number) => void;
+ resetToDefaults: () => void;
+}
+
+/**
+ * Hook managing column visibility and ordering preferences
+ *
+ * Persists column preferences to user preferences under key `table.${pageKey}.columns`.
+ * Value is JSON-encoded object: { visible: string[], order: string[] }
+ * For backwards compatibility, if stored value is a plain array, it's treated as visible list.
+ *
+ * @param pageKey Unique key for this table (e.g. "work-items", "invoices")
+ * @param columns Column definitions
+ * @returns Visible columns, column order, and control functions
+ */
+export function useColumnPreferences(
+ pageKey: string,
+ columns: ColumnDef[],
+): UseColumnPreferencesResult {
+ const preferenceKey = `table.${pageKey}.columns`;
+ const { preferences, upsert } = usePreferences();
+
+ const defaultColumnOrder = columns.map((col) => col.key);
+ const defaultVisibleColumns = new Set(
+ columns.filter((col) => col.defaultVisible !== false).map((col) => col.key),
+ );
+
+ const [visibleColumns, setVisibleColumns] = useState>(defaultVisibleColumns);
+ const [columnOrder, setColumnOrder] = useState(defaultColumnOrder);
+
+ const [isLoaded, setIsLoaded] = useState(false);
+ const saveDebounceRef = useRef(null);
+
+ // Load preferences on mount
+ useEffect(() => {
+ const pref = preferences.find((p) => p.key === preferenceKey);
+ if (pref) {
+ try {
+ const saved = JSON.parse(pref.value);
+
+ // Handle backwards compatibility: if saved value is an array, treat as visible list
+ if (Array.isArray(saved)) {
+ setVisibleColumns(new Set(saved));
+ setColumnOrder(defaultColumnOrder);
+ } else if (saved && typeof saved === 'object') {
+ // New format: { visible: string[], order: string[] }
+ if (Array.isArray(saved.visible)) {
+ setVisibleColumns(new Set(saved.visible));
+ }
+ if (Array.isArray(saved.order)) {
+ setColumnOrder(saved.order);
+ }
+ }
+ } catch {
+ // If JSON parse fails, use defaults
+ }
+ }
+ setIsLoaded(true);
+ }, [preferences, preferenceKey]);
+
+ const savePreferences = useCallback(
+ (newVisible: Set, newOrder: string[]) => {
+ if (saveDebounceRef.current) {
+ clearTimeout(saveDebounceRef.current);
+ }
+ saveDebounceRef.current = setTimeout(() => {
+ void upsert(
+ preferenceKey,
+ JSON.stringify({
+ visible: Array.from(newVisible),
+ order: newOrder,
+ }),
+ );
+ }, 500);
+ },
+ [preferenceKey, upsert],
+ );
+
+ const toggleColumn = useCallback(
+ (key: string) => {
+ setVisibleColumns((prev) => {
+ const updated = new Set(prev);
+ if (updated.has(key)) {
+ updated.delete(key);
+ } else {
+ updated.add(key);
+ }
+ savePreferences(updated, columnOrder);
+ return updated;
+ });
+ },
+ [columnOrder, savePreferences],
+ );
+
+ const moveColumn = useCallback(
+ (from: number, to: number) => {
+ setColumnOrder((prev) => {
+ const updated = [...prev];
+ const [item] = updated.splice(from, 1);
+ updated.splice(to, 0, item);
+ savePreferences(visibleColumns, updated);
+ return updated;
+ });
+ },
+ [visibleColumns, savePreferences],
+ );
+
+ const resetToDefaults = useCallback(() => {
+ const defaults = new Set(
+ columns.filter((col) => col.defaultVisible !== false).map((col) => col.key),
+ );
+ setVisibleColumns(defaults);
+ setColumnOrder(defaultColumnOrder);
+ savePreferences(defaults, defaultColumnOrder);
+ }, [columns, defaultColumnOrder, savePreferences]);
+
+ return {
+ visibleColumns,
+ columnOrder,
+ isLoaded,
+ toggleColumn,
+ moveColumn,
+ resetToDefaults,
+ };
+}
diff --git a/client/src/hooks/useTableState.test.ts b/client/src/hooks/useTableState.test.ts
new file mode 100644
index 000000000..c7bde53eb
--- /dev/null
+++ b/client/src/hooks/useTableState.test.ts
@@ -0,0 +1,406 @@
+import { renderHook, act } from '@testing-library/react';
+import { describe, it, expect, jest, beforeEach, afterEach } from '@jest/globals';
+import React from 'react';
+import { MemoryRouter } from 'react-router-dom';
+
+import { useTableState } from './useTableState.js';
+
+// Wrapper that provides React Router context required by useSearchParams
+function makeWrapper(initialEntries: string[] = ['/']) {
+ return ({ children }: { children: React.ReactNode }) =>
+ React.createElement(MemoryRouter, { initialEntries }, children);
+}
+
+describe('useTableState', () => {
+ afterEach(() => {
+ jest.useRealTimers();
+ });
+
+ describe('initial state', () => {
+ it('initializes with default values when URL has no params', () => {
+ const { result } = renderHook(() => useTableState(), {
+ wrapper: makeWrapper(),
+ });
+
+ expect(result.current.tableState.search).toBe('');
+ expect(result.current.tableState.sortBy).toBeNull();
+ expect(result.current.tableState.sortDir).toBeNull();
+ expect(result.current.tableState.page).toBe(1);
+ expect(result.current.tableState.pageSize).toBe(25);
+ expect(result.current.tableState.filters.size).toBe(0);
+ });
+
+ it('respects custom defaultPageSize option', () => {
+ const { result } = renderHook(() => useTableState({ defaultPageSize: 50 }), {
+ wrapper: makeWrapper(),
+ });
+ expect(result.current.tableState.pageSize).toBe(50);
+ });
+
+ it('initializes search from URL ?q= param', () => {
+ const { result } = renderHook(() => useTableState(), {
+ wrapper: makeWrapper(['/?q=test+query']),
+ });
+ expect(result.current.tableState.search).toBe('test query');
+ });
+
+ it('initializes sortBy from URL ?sortBy= param', () => {
+ const { result } = renderHook(() => useTableState(), {
+ wrapper: makeWrapper(['/?sortBy=title&sortOrder=asc']),
+ });
+ expect(result.current.tableState.sortBy).toBe('title');
+ });
+
+ it('initializes sortDir from URL ?sortOrder= param', () => {
+ const { result } = renderHook(() => useTableState(), {
+ wrapper: makeWrapper(['/?sortBy=title&sortOrder=desc']),
+ });
+ expect(result.current.tableState.sortDir).toBe('desc');
+ });
+
+ it('initializes page from URL ?page= param', () => {
+ const { result } = renderHook(() => useTableState(), {
+ wrapper: makeWrapper(['/?page=3']),
+ });
+ expect(result.current.tableState.page).toBe(3);
+ });
+
+ it('initializes filter from custom URL param', () => {
+ const { result } = renderHook(() => useTableState(), {
+ wrapper: makeWrapper(['/?status=active']),
+ });
+ expect(result.current.tableState.filters.get('status')?.value).toBe('active');
+ });
+ });
+
+ describe('setSearch with debounce', () => {
+ it('initializes searchInput from URL q param', () => {
+ const { result } = renderHook(() => useTableState(), {
+ wrapper: makeWrapper(['/?q=initial']),
+ });
+ expect(result.current.searchInput).toBe('initial');
+ });
+
+ it('does not update tableState.search before debounce fires (299ms)', () => {
+ jest.useFakeTimers();
+ const { result } = renderHook(() => useTableState(), {
+ wrapper: makeWrapper(),
+ });
+
+ act(() => {
+ result.current.setSearch('hello');
+ });
+
+ // Advance just under the 300ms debounce threshold
+ act(() => {
+ jest.advanceTimersByTime(299);
+ });
+
+ // tableState.search should still be '' (debounce hasn't fired)
+ // The URL still has no ?q= param at this point
+ expect(result.current.tableState.search).toBe('');
+ });
+
+ it('search initialized from URL is reflected in tableState and toApiParams', () => {
+ // When the hook is initialized with a search query in the URL,
+ // tableState.search and toApiParams().q should reflect it immediately
+ const { result } = renderHook(() => useTableState(), {
+ wrapper: makeWrapper(['/?q=hello']),
+ });
+
+ expect(result.current.tableState.search).toBe('hello');
+ expect(result.current.toApiParams().q).toBe('hello');
+ });
+
+ it('search and page are both initialized correctly from URL params', () => {
+ const { result } = renderHook(() => useTableState(), {
+ wrapper: makeWrapper(['/?q=myquery&page=3']),
+ });
+
+ expect(result.current.tableState.search).toBe('myquery');
+ expect(result.current.tableState.page).toBe(3);
+ expect(result.current.searchInput).toBe('myquery');
+ });
+ });
+
+ describe('setFilter', () => {
+ it('adds a filter to the URL and tableState', () => {
+ const { result } = renderHook(() => useTableState(), {
+ wrapper: makeWrapper(),
+ });
+
+ act(() => {
+ result.current.setFilter('status', 'active');
+ });
+
+ expect(result.current.tableState.filters.get('status')?.value).toBe('active');
+ });
+
+ it('removes a filter when value is null', () => {
+ const { result } = renderHook(() => useTableState(), {
+ wrapper: makeWrapper(['/?status=active']),
+ });
+
+ act(() => {
+ result.current.setFilter('status', null);
+ });
+
+ expect(result.current.tableState.filters.has('status')).toBe(false);
+ });
+
+ it('removes a filter when value is empty string', () => {
+ const { result } = renderHook(() => useTableState(), {
+ wrapper: makeWrapper(['/?status=active']),
+ });
+
+ act(() => {
+ result.current.setFilter('status', '');
+ });
+
+ expect(result.current.tableState.filters.has('status')).toBe(false);
+ });
+
+ it('resets page to 1 when filter changes', () => {
+ const { result } = renderHook(() => useTableState(), {
+ wrapper: makeWrapper(['/?page=5']),
+ });
+
+ act(() => {
+ result.current.setFilter('status', 'active');
+ });
+
+ expect(result.current.tableState.page).toBe(1);
+ });
+ });
+
+ describe('setSort — 3-state cycling', () => {
+ it('cycles from none to asc on first call', () => {
+ const { result } = renderHook(() => useTableState(), {
+ wrapper: makeWrapper(),
+ });
+
+ act(() => {
+ result.current.setSort('title');
+ });
+
+ expect(result.current.tableState.sortBy).toBe('title');
+ expect(result.current.tableState.sortDir).toBe('asc');
+ });
+
+ it('cycles from asc to desc on second call', () => {
+ const { result } = renderHook(() => useTableState(), {
+ wrapper: makeWrapper(['/?sortBy=title&sortOrder=asc']),
+ });
+
+ act(() => {
+ result.current.setSort('title');
+ });
+
+ expect(result.current.tableState.sortBy).toBe('title');
+ expect(result.current.tableState.sortDir).toBe('desc');
+ });
+
+ it('cycles from desc to none on third call', () => {
+ const { result } = renderHook(() => useTableState(), {
+ wrapper: makeWrapper(['/?sortBy=title&sortOrder=desc']),
+ });
+
+ act(() => {
+ result.current.setSort('title');
+ });
+
+ expect(result.current.tableState.sortBy).toBeNull();
+ expect(result.current.tableState.sortDir).toBeNull();
+ });
+
+ it('resets to asc when sorting a different column', () => {
+ const { result } = renderHook(() => useTableState(), {
+ wrapper: makeWrapper(['/?sortBy=title&sortOrder=asc']),
+ });
+
+ act(() => {
+ result.current.setSort('amount');
+ });
+
+ expect(result.current.tableState.sortBy).toBe('amount');
+ expect(result.current.tableState.sortDir).toBe('asc');
+ });
+
+ it('uses columnSortKey over columnKey when provided', () => {
+ const { result } = renderHook(() => useTableState(), {
+ wrapper: makeWrapper(),
+ });
+
+ act(() => {
+ result.current.setSort('title', 'title_sort_key');
+ });
+
+ expect(result.current.tableState.sortBy).toBe('title_sort_key');
+ });
+ });
+
+ describe('setPage', () => {
+ it('updates page in tableState', () => {
+ const { result } = renderHook(() => useTableState(), {
+ wrapper: makeWrapper(),
+ });
+
+ act(() => {
+ result.current.setPage(3);
+ });
+
+ expect(result.current.tableState.page).toBe(3);
+ });
+ });
+
+ describe('setPageSize', () => {
+ it('updates pageSize in tableState', () => {
+ const { result } = renderHook(() => useTableState(), {
+ wrapper: makeWrapper(),
+ });
+
+ act(() => {
+ result.current.setPageSize(50);
+ });
+
+ expect(result.current.tableState.pageSize).toBe(50);
+ });
+
+ it('resets page to 1 when page size changes', () => {
+ const { result } = renderHook(() => useTableState(), {
+ wrapper: makeWrapper(['/?page=5']),
+ });
+
+ act(() => {
+ result.current.setPageSize(50);
+ });
+
+ expect(result.current.tableState.page).toBe(1);
+ });
+ });
+
+ describe('toApiParams', () => {
+ it('returns page and pageSize at minimum', () => {
+ const { result } = renderHook(() => useTableState(), {
+ wrapper: makeWrapper(),
+ });
+
+ const params = result.current.toApiParams();
+ expect(params.page).toBe(1);
+ expect(params.pageSize).toBe(25);
+ });
+
+ it('includes q when search is active', () => {
+ jest.useFakeTimers();
+ const { result } = renderHook(() => useTableState(), {
+ wrapper: makeWrapper(['/?q=search+term']),
+ });
+
+ const params = result.current.toApiParams();
+ expect(params.q).toBe('search term');
+ });
+
+ it('does not include q when search is empty', () => {
+ const { result } = renderHook(() => useTableState(), {
+ wrapper: makeWrapper(),
+ });
+
+ const params = result.current.toApiParams();
+ expect(params.q).toBeUndefined();
+ });
+
+ it('includes sortBy and sortOrder when sort is active', () => {
+ const { result } = renderHook(() => useTableState(), {
+ wrapper: makeWrapper(['/?sortBy=title&sortOrder=asc']),
+ });
+
+ const params = result.current.toApiParams();
+ expect(params.sortBy).toBe('title');
+ expect(params.sortOrder).toBe('asc');
+ });
+
+ it('decomposes number range filter "min:100,max:500" into titleMin and titleMax', () => {
+ const { result } = renderHook(() => useTableState(), {
+ wrapper: makeWrapper(['/?title=min:100,max:500']),
+ });
+
+ const params = result.current.toApiParams();
+ expect(params['titleMin']).toBe(100);
+ expect(params['titleMax']).toBe(500);
+ });
+
+ it('decomposes date range filter "from:2026-01-01,to:2026-12-31" into From and To', () => {
+ const { result } = renderHook(() => useTableState(), {
+ wrapper: makeWrapper(['/?date=from:2026-01-01,to:2026-12-31']),
+ });
+
+ const params = result.current.toApiParams();
+ expect(params['dateFrom']).toBe('2026-01-01');
+ expect(params['dateTo']).toBe('2026-12-31');
+ });
+
+ it('passes through string filter values as-is', () => {
+ const { result } = renderHook(() => useTableState(), {
+ wrapper: makeWrapper(['/?status=active']),
+ });
+
+ const params = result.current.toApiParams();
+ expect(params['status']).toBe('active');
+ });
+
+ it('passes through boolean filter values as-is', () => {
+ const { result } = renderHook(() => useTableState(), {
+ wrapper: makeWrapper(['/?isActive=true']),
+ });
+
+ const params = result.current.toApiParams();
+ expect(params['isActive']).toBe('true');
+ });
+
+ it('handles only min in number range filter', () => {
+ const { result } = renderHook(() => useTableState(), {
+ wrapper: makeWrapper(['/?amount=min:100']),
+ });
+
+ const params = result.current.toApiParams();
+ expect(params['amountMin']).toBe(100);
+ expect(params['amountMax']).toBeUndefined();
+ });
+
+ it('handles only to in date range filter', () => {
+ const { result } = renderHook(() => useTableState(), {
+ wrapper: makeWrapper(['/?date=to:2026-12-31']),
+ });
+
+ const params = result.current.toApiParams();
+ expect(params['dateFrom']).toBeUndefined();
+ expect(params['dateTo']).toBe('2026-12-31');
+ });
+ });
+
+ describe('resetFilters', () => {
+ it('clears all filter params from URL', () => {
+ const { result } = renderHook(() => useTableState(), {
+ wrapper: makeWrapper(['/?status=active&type=work&page=3']),
+ });
+
+ act(() => {
+ result.current.resetFilters();
+ });
+
+ expect(result.current.tableState.filters.size).toBe(0);
+ });
+
+ it('resets page to 1', () => {
+ const { result } = renderHook(() => useTableState(), {
+ wrapper: makeWrapper(['/?page=5&status=active']),
+ });
+
+ act(() => {
+ result.current.resetFilters();
+ });
+
+ expect(result.current.tableState.page).toBe(1);
+ });
+ });
+});
diff --git a/client/src/hooks/useTableState.ts b/client/src/hooks/useTableState.ts
new file mode 100644
index 000000000..613c1a460
--- /dev/null
+++ b/client/src/hooks/useTableState.ts
@@ -0,0 +1,249 @@
+import { useState, useEffect, useRef, useCallback } from 'react';
+import { useSearchParams } from 'react-router-dom';
+import type { TableState, TableApiParams, FilterType } from '../components/DataTable/DataTable.js';
+
+export interface UseTableStateOptions {
+ columns?: Array<{ filterParamKey?: string; filterType?: FilterType }>;
+ defaultPageSize?: number;
+ searchDebounceMs?: number;
+}
+
+export interface UseTableStateResult {
+ tableState: TableState;
+ searchInput: string;
+ setSearch: (q: string) => void;
+ setFilter: (paramKey: string, value: string | null) => void;
+ setSort: (columnKey: string, columnSortKey?: string) => void;
+ setPage: (page: number) => void;
+ setPageSize: (size: number) => void;
+ toApiParams: () => TableApiParams;
+ resetFilters: () => void;
+}
+
+/**
+ * Hook managing DataTable state with URL synchronization
+ *
+ * Provides:
+ * - Search with 300ms debounce
+ * - Per-column filtering
+ * - Sortable columns with 3-state cycling (none → asc → desc → none)
+ * - Pagination
+ * - Page size selection
+ * - URL parameter sync via useSearchParams
+ *
+ * @param options Configuration options
+ * @returns Table state and control functions
+ */
+export function useTableState(options: UseTableStateOptions = {}): UseTableStateResult {
+ const { defaultPageSize = 25, searchDebounceMs = 300 } = options;
+
+ const [searchParams, setSearchParams] = useSearchParams();
+
+ // Initialize table state from URL parameters
+ const [tableState, setTableState] = useState(() => {
+ const filters = new Map();
+ // Collect all URL params that aren't standard table params
+ for (const [key, value] of searchParams.entries()) {
+ if (!['q', 'sortBy', 'sortOrder', 'page', 'pageSize'].includes(key) && value) {
+ filters.set(key, { value });
+ }
+ }
+
+ return {
+ search: searchParams.get('q') || '',
+ filters,
+ sortBy: searchParams.get('sortBy'),
+ sortDir: (searchParams.get('sortOrder') as 'asc' | 'desc') || null,
+ page: parseInt(searchParams.get('page') || '1', 10),
+ pageSize: parseInt(searchParams.get('pageSize') || defaultPageSize.toString(), 10),
+ };
+ });
+
+ // Separate state for search input to enable debouncing
+ const [searchInput, setSearchInput] = useState(tableState.search);
+ const searchDebounceRef = useRef(null);
+
+ // Debounced search update
+ useEffect(() => {
+ if (searchDebounceRef.current) {
+ clearTimeout(searchDebounceRef.current);
+ }
+
+ searchDebounceRef.current = setTimeout(() => {
+ const newParams = new URLSearchParams(searchParams);
+ if (searchInput) {
+ newParams.set('q', searchInput);
+ } else {
+ newParams.delete('q');
+ }
+ newParams.set('page', '1');
+ setSearchParams(newParams);
+ setTableState((prev) => ({ ...prev, search: searchInput, page: 1 }));
+ }, searchDebounceMs);
+
+ return () => {
+ if (searchDebounceRef.current) {
+ clearTimeout(searchDebounceRef.current);
+ }
+ };
+ }, [searchInput, searchParams, setSearchParams, searchDebounceMs]);
+
+ // Sync URL changes to table state
+ useEffect(() => {
+ const newState: TableState = {
+ search: searchParams.get('q') || '',
+ filters: new Map(),
+ sortBy: searchParams.get('sortBy'),
+ sortDir: (searchParams.get('sortOrder') as 'asc' | 'desc') || null,
+ page: parseInt(searchParams.get('page') || '1', 10),
+ pageSize: parseInt(searchParams.get('pageSize') || defaultPageSize.toString(), 10),
+ };
+
+ // Collect filter params (anything not in the standard table params)
+ for (const [key, value] of searchParams.entries()) {
+ if (!['q', 'sortBy', 'sortOrder', 'page', 'pageSize'].includes(key) && value) {
+ newState.filters.set(key, { value });
+ }
+ }
+
+ setTableState(newState);
+ // Update search input to match URL (in case it changed externally)
+ if (newState.search !== searchInput) {
+ setSearchInput(newState.search);
+ }
+ }, [searchParams, defaultPageSize, searchInput]);
+
+ const setSearch = useCallback((q: string) => {
+ setSearchInput(q);
+ }, []);
+
+ const setFilter = useCallback(
+ (paramKey: string, value: string | null) => {
+ const newParams = new URLSearchParams(searchParams);
+ if (value === null || value === '') {
+ newParams.delete(paramKey);
+ } else {
+ newParams.set(paramKey, value);
+ }
+ newParams.set('page', '1');
+ setSearchParams(newParams);
+ },
+ [searchParams, setSearchParams],
+ );
+
+ const setSort = useCallback(
+ (columnKey: string, columnSortKey?: string) => {
+ const sortKey = columnSortKey || columnKey;
+ const newParams = new URLSearchParams(searchParams);
+ const currentSort = newParams.get('sortBy');
+ const currentOrder = newParams.get('sortOrder');
+
+ if (currentSort === sortKey && currentOrder === 'asc') {
+ // asc → desc
+ newParams.set('sortBy', sortKey);
+ newParams.set('sortOrder', 'desc');
+ } else if (currentSort === sortKey && currentOrder === 'desc') {
+ // desc → none
+ newParams.delete('sortBy');
+ newParams.delete('sortOrder');
+ } else {
+ // none → asc
+ newParams.set('sortBy', sortKey);
+ newParams.set('sortOrder', 'asc');
+ }
+
+ setSearchParams(newParams);
+ },
+ [searchParams, setSearchParams],
+ );
+
+ const setPage = useCallback(
+ (page: number) => {
+ const newParams = new URLSearchParams(searchParams);
+ newParams.set('page', page.toString());
+ setSearchParams(newParams);
+ },
+ [searchParams, setSearchParams],
+ );
+
+ const setPageSize = useCallback(
+ (size: number) => {
+ const newParams = new URLSearchParams(searchParams);
+ newParams.set('pageSize', size.toString());
+ newParams.set('page', '1');
+ setSearchParams(newParams);
+ },
+ [searchParams, setSearchParams],
+ );
+
+ const toApiParams = useCallback((): TableApiParams => {
+ const params: TableApiParams = {
+ page: tableState.page,
+ pageSize: tableState.pageSize,
+ };
+
+ if (tableState.search) {
+ params.q = tableState.search;
+ }
+
+ if (tableState.sortBy) {
+ params.sortBy = tableState.sortBy;
+ params.sortOrder = tableState.sortDir || 'asc';
+ }
+
+ // Decompose compound filter values based on their type
+ for (const [paramKey, filter] of tableState.filters.entries()) {
+ const value = filter.value;
+
+ // Detect and decompose number range filters (min:X,max:Y format)
+ if (value.includes('min:') || value.includes('max:')) {
+ const parts = value.split(',');
+ for (const part of parts) {
+ if (part.startsWith('min:')) {
+ params[`${paramKey}Min`] = parseFloat(part.substring(4));
+ } else if (part.startsWith('max:')) {
+ params[`${paramKey}Max`] = parseFloat(part.substring(4));
+ }
+ }
+ }
+ // Detect and decompose date range filters (from:YYYY-MM-DD,to:YYYY-MM-DD format)
+ else if (value.includes('from:') || value.includes('to:')) {
+ const parts = value.split(',');
+ for (const part of parts) {
+ if (part.startsWith('from:')) {
+ params[`${paramKey}From`] = part.substring(5);
+ } else if (part.startsWith('to:')) {
+ params[`${paramKey}To`] = part.substring(3);
+ }
+ }
+ }
+ // Passthrough for other filter types (string, enum, boolean, entity)
+ else {
+ params[paramKey] = value;
+ }
+ }
+
+ return params;
+ }, [tableState]);
+
+ const resetFilters = useCallback(() => {
+ const newParams = new URLSearchParams();
+ if (searchInput) {
+ newParams.set('q', searchInput);
+ }
+ newParams.set('page', '1');
+ setSearchParams(newParams);
+ }, [searchInput, setSearchParams]);
+
+ return {
+ tableState,
+ searchInput,
+ setSearch,
+ setFilter,
+ setSort,
+ setPage,
+ setPageSize,
+ toApiParams,
+ resetFilters,
+ };
+}
diff --git a/client/src/i18n/de/budget.json b/client/src/i18n/de/budget.json
index 8d5a5b00e..2b00b4d52 100644
--- a/client/src/i18n/de/budget.json
+++ b/client/src/i18n/de/budget.json
@@ -209,6 +209,13 @@
"retry": "Erneut versuchen"
},
"tableHeaders": {
+ "name": "Name",
+ "trade": "Gewerk",
+ "contactInfo": "Kontakt",
+ "address": "Adresse",
+ "notes": "Notizen",
+ "createdAt": "Hinzugefügt am",
+ "updatedAt": "Zuletzt aktualisiert",
"actions": "Aktionen"
},
"searchAriaLabel": "Auftragnehmer durchsuchen",
@@ -564,7 +571,9 @@
"amount": "Betrag",
"allocated": "Zugeordnet",
"dueDate": "Fälligkeitsdatum",
- "status": "Status"
+ "status": "Status",
+ "notes": "Notizen",
+ "remainingAmount": "Verbleibender Betrag"
},
"form": {
"vendor": "Auftragnehmer",
diff --git a/client/src/i18n/de/common.json b/client/src/i18n/de/common.json
index 2cf2495aa..cd5208c61 100644
--- a/client/src/i18n/de/common.json
+++ b/client/src/i18n/de/common.json
@@ -18,7 +18,8 @@
"create": "Erstellen",
"confirm": "Bestätigen",
"back": "Zurück",
- "search": "Suchen"
+ "search": "Suchen",
+ "clearFilters": "Filter zurücksetzen"
},
"aria": {
"closeMenu": "Menü schließen",
@@ -85,6 +86,7 @@
"toast": {
"dismissAriaLabel": "Benachrichtigung schließen"
},
+ "actions": "Aktionen",
"assignmentPicker": {
"usersGroup": "Benutzer",
"vendorsGroup": "Auftragnehmer"
@@ -106,7 +108,63 @@
"settings": {
"profile": "Profil",
"manage": "Verwalten",
- "userManagement": "Benutzerverwaltung"
+ "userManagement": "Benutzerverwaltung",
+ "backups": "Sicherungen"
}
+ },
+ "dataTable": {
+ "search": {
+ "placeholder": "Suchen...",
+ "ariaLabel": "Elemente suchen"
+ },
+ "filter": {
+ "all": "Alle",
+ "yes": "Ja",
+ "no": "Nein",
+ "min": "Min",
+ "max": "Max",
+ "from": "Von",
+ "to": "Bis",
+ "clearFilter": "Zurücksetzen",
+ "applyFilter": "Anwenden",
+ "filterByColumn": "Nach {{column}} filtern",
+ "textPlaceholder": "Suchen...",
+ "searchPlaceholder": "Suchen...",
+ "booleanAriaLabel": "Boolesche Filteroption",
+ "selectAll": "Alle auswählen",
+ "selectNone": "Keine auswählen",
+ "dateRangeAriaLabel": "Datumsbereichsauswahl"
+ },
+ "sort": {
+ "ascending": "Aufsteigend sortieren",
+ "descending": "Absteigend sortieren",
+ "none": "Keine Sortierung",
+ "ariaLabel": "Nach {{column}} sortieren"
+ },
+ "columnSettings": {
+ "ariaLabel": "Spalteneinstellungen",
+ "title": "Sichtbare Spalten",
+ "resetToDefaults": "Standardeinstellungen wiederherstellen",
+ "dragHandleAriaLabel": "{{column}}-Spalte zum Neuanordnen ziehen"
+ },
+ "pagination": {
+ "showing": "Zeige {{from}}–{{to}} von {{total}} Einträgen",
+ "page": "Seite {{page}} von {{totalPages}}",
+ "previous": "Zurück",
+ "next": "Weiter",
+ "pageSize": "Pro Seite"
+ },
+ "empty": {
+ "defaultMessage": "Keine Einträge gefunden",
+ "filteredMessage": "Keine Einträge entsprechen den aktuellen Filtern"
+ },
+ "loading": "Tabellendaten werden geladen..."
+ },
+ "dateRangePicker": {
+ "previousMonth": "Vorheriger Monat",
+ "nextMonth": "Nächster Monat",
+ "selectStart": "Startdatum auswählen",
+ "selectEnd": "Enddatum auswählen",
+ "calendarGridAriaLabel": "Kalender"
}
}
diff --git a/client/src/i18n/de/dashboard.json b/client/src/i18n/de/dashboard.json
index a0cb8d99f..e717c43e7 100644
--- a/client/src/i18n/de/dashboard.json
+++ b/client/src/i18n/de/dashboard.json
@@ -6,9 +6,9 @@
"showCard": "{{title}} anzeigen",
"actions": {
"addButton": "Hinzufügen",
- "addWorkItem": "Arbeitspaket Hinzufügen",
- "addHouseholdItem": "Haushaltsartikel Hinzufügen",
- "addMilestone": "Meilenstein Hinzufügen"
+ "addWorkItem": "Neues Arbeitspaket",
+ "addHouseholdItem": "Neuer Haushaltsartikel",
+ "addMilestone": "Neuer Meilenstein"
}
},
"sections": {
diff --git a/client/src/i18n/de/errors.json b/client/src/i18n/de/errors.json
index 54b0c262a..9bbf14a70 100644
--- a/client/src/i18n/de/errors.json
+++ b/client/src/i18n/de/errors.json
@@ -33,5 +33,9 @@
"ACCOUNT_LOCKED": "Dieses Konto wurde aufgrund zu vieler fehlgeschlagener Anmeldeversuche vorübergehend gesperrt.",
"INVALID_METADATA": "Die Eintragsmetadaten sind ungültig.",
"INVALID_ENTRY_TYPE": "Dieser Tagebucheintrag-Typ ist für diese Operation nicht erlaubt.",
- "IMMUTABLE_ENTRY": "Dieser Tagebucheintrag kann nicht bearbeitet werden."
+ "IMMUTABLE_ENTRY": "Dieser Tagebucheintrag kann nicht bearbeitet werden.",
+ "BACKUP_NOT_CONFIGURED": "Sicherung ist nicht konfiguriert. Setzen Sie die Umgebungsvariable BACKUP_DIR.",
+ "BACKUP_IN_PROGRESS": "Eine Sicherungs- oder Wiederherstellungsoperation läuft bereits.",
+ "BACKUP_NOT_FOUND": "Das angeforderte Sicherungsarchiv wurde nicht gefunden.",
+ "RESTORE_FAILED": "Die Wiederherstellung ist fehlgeschlagen. Der Server befindet sich möglicherweise in einem inkonsistenten Zustand."
}
diff --git a/client/src/i18n/de/householdItems.json b/client/src/i18n/de/householdItems.json
index 84183c322..2af855f99 100644
--- a/client/src/i18n/de/householdItems.json
+++ b/client/src/i18n/de/householdItems.json
@@ -14,10 +14,6 @@
"status": "Status:",
"room": "Raum:",
"vendor": "Auftragnehmer:",
- "noBudget": "Keine Budgetpositionen",
- "noBudgetAriaLabel": "Nur Haushaltsartikel ohne Budgetpositionen anzeigen",
- "noBudgetActive": "Kein-Budgetpositionen-Filter aktiv",
- "noBudgetInactive": "Kein-Budgetpositionen-Filter aufgehoben",
"sortBy": "Sortieren nach:",
"allCategories": "Alle Kategorien",
"allAreas": "Alle Bereiche",
@@ -48,14 +44,22 @@
"arrived": "Angekommen"
},
"table": {
+ "sectionTitle": "Haushaltsartikel",
"headers": {
"name": "Name",
"category": "Kategorie",
"status": "Status",
+ "area": "Bereich",
"room": "Raum",
"vendor": "Auftragnehmer",
"plannedCost": "Geplante Kosten",
"targetDelivery": "Ziellieferdatum",
+ "actualDelivery": "Tatsächliche Lieferung",
+ "actualCost": "Tatsächliche Kosten",
+ "subsidyReduction": "Förderabzug",
+ "netCost": "Nettokosten",
+ "orderDate": "Bestelldatum",
+ "budgetLines": "Budgetpositionen",
"actions": "Aktionen"
}
},
diff --git a/client/src/i18n/de/schedule.json b/client/src/i18n/de/schedule.json
index 8e742aa64..f641081f2 100644
--- a/client/src/i18n/de/schedule.json
+++ b/client/src/i18n/de/schedule.json
@@ -23,9 +23,9 @@
"zoomInColumns": "Spalten vergrößern",
"adjustColumnWidth": "Spaltenbreite anpassen (Strg+Bildlauf oder Strg+=/−)",
"addButton": "Hinzufügen",
- "addWorkItem": "Arbeitspaket Hinzufügen",
- "addHouseholdItem": "Haushaltsartikel Hinzufügen",
- "addMilestone": "Meilenstein Hinzufügen"
+ "addWorkItem": "Neues Arbeitspaket",
+ "addHouseholdItem": "Neuer Haushaltsartikel",
+ "addMilestone": "Neuer Meilenstein"
},
"zoomOptions": {
"day": "Tag",
@@ -47,7 +47,8 @@
"schedule": {
"navigation": {
"gantt": "Gantt-Diagramm",
- "calendar": "Kalender"
+ "calendar": "Kalender",
+ "ariaLabel": "Zeitplan-Abschnittsnavigation"
}
},
"ganttSidebar": {
@@ -146,6 +147,8 @@
"targetDate": "Zieldatum",
"status": "Status",
"description": "Beschreibung",
+ "linkedItems": "Verknüpfte Artikel",
+ "completedAt": "Abgeschlossen am",
"actions": "Aktionen"
}
},
diff --git a/client/src/i18n/de/settings.json b/client/src/i18n/de/settings.json
index 76bc03e69..30fec2f24 100644
--- a/client/src/i18n/de/settings.json
+++ b/client/src/i18n/de/settings.json
@@ -53,7 +53,8 @@
"role": "Rolle",
"authProvider": "Authentifizierung",
"status": "Status",
- "actions": "Aktionen"
+ "actions": "Aktionen",
+ "memberSince": "Mitglied seit"
},
"roles": {
"admin": "Administrator",
@@ -69,7 +70,8 @@
},
"actions": {
"edit": "Bearbeiten",
- "deactivate": "Deaktivieren"
+ "deactivate": "Deaktivieren",
+ "menuAriaLabel": "Menü für Benutzeraktionen"
},
"editModal": {
"title": "Benutzer bearbeiten",
@@ -194,7 +196,7 @@
"errorTitle": "Fehler",
"retry": "Erneut versuchen",
"loadError": "Budgetkategorien konnten nicht geladen werden. Bitte versuchen Sie es erneut.",
- "createTitle": "Neue Budgetkategorie",
+ "createTitle": "Neue Budgetkategorie erstellen",
"createDescription": "Budgetkategorien gruppieren Ihre Baukosten (z. B. Materialien, Arbeit, Genehmigungen).",
"nameLabel": "Name",
"nameRequired": "*",
@@ -238,7 +240,7 @@
"errorTitle": "Fehler",
"retry": "Erneut versuchen",
"loadError": "Haushaltsartikel-Kategorien konnten nicht geladen werden. Bitte versuchen Sie es erneut.",
- "createTitle": "Neue Haushaltsartikel-Kategorie",
+ "createTitle": "Neue Haushaltsartikel-Kategorie erstellen",
"createDescription": "Kategorien organisieren Ihre Haushaltsartikel und Möbelkäufe (z. B. Möbel, Geräte, Einbauten).",
"nameLabel": "Name",
"nameRequired": "*",
@@ -294,6 +296,89 @@
"generating": "Wird generiert...",
"revoking": "Wird widerrufen..."
},
+ "trades": {
+ "plumbing": "Sanitär",
+ "hvac": "Heizung, Klima, Lüftung",
+ "electrical": "Elektrik",
+ "drywall": "Trockenbau",
+ "carpentry": "Tischlerei",
+ "masonry": "Maurerarbeiten",
+ "painting": "Malerei",
+ "roofing": "Dacharbeiten",
+ "flooring": "Bodenbeläge",
+ "tiling": "Fliesenarbeiten",
+ "landscaping": "Gartengestaltung",
+ "excavation": "Erdarbeiten",
+ "generalContractor": "Generalunternehmer",
+ "architectDesign": "Architektur / Planung",
+ "other": "Sonstige"
+ },
+ "budgetCategories": {
+ "materials": "Materialien",
+ "labor": "Arbeit",
+ "permits": "Genehmigungen",
+ "design": "Planung",
+ "householdItems": "Haushaltsartikel",
+ "waste": "Entsorgung",
+ "other": "Sonstige",
+ "equipment": "Geräte",
+ "landscaping": "Gartengestaltung",
+ "utilities": "Versorgungsleistungen",
+ "insurance": "Versicherung",
+ "contingency": "Rücklagen"
+ },
+ "householdItemCategories": {
+ "furniture": "Möbel",
+ "appliances": "Geräte",
+ "fixtures": "Einbauten",
+ "decor": "Dekoration",
+ "electronics": "Elektronik",
+ "equipment": "Ausstattung",
+ "other": "Sonstige",
+ "outdoor": "Außenbereich",
+ "storage": "Aufbewahrung"
+ },
+ "backups": {
+ "pageTitle": "Sicherung & Wiederherstellung",
+ "createButton": "Sicherung erstellen",
+ "creating": "Sicherung wird erstellt...",
+ "loading": "Sicherungen werden geladen...",
+ "notConfiguredMessage": "Sicherung ist nicht konfiguriert",
+ "notConfiguredDescription": "Um Sicherungen zu aktivieren, setzen Sie die Umgebungsvariable BACKUP_DIR auf ein Verzeichnis außerhalb des Anwendungsdatenverzeichnisses.",
+ "emptyStateMessage": "Noch keine Sicherungen vorhanden",
+ "emptyStateDescription": "Klicken Sie auf 'Sicherung erstellen', um Ihre erste Sicherung anzulegen.",
+ "tableHeaders": {
+ "filename": "Dateiname",
+ "createdAt": "Erstellt",
+ "size": "Größe",
+ "actions": "Aktionen"
+ },
+ "actions": {
+ "delete": "Löschen",
+ "restore": "Wiederherstellen"
+ },
+ "deleteModal": {
+ "title": "Sicherung löschen",
+ "message": "Möchten Sie {{filename}} wirklich löschen?",
+ "warning": "Diese Aktion kann nicht rückgängig gemacht werden.",
+ "cancel": "Abbrechen",
+ "confirm": "Löschen",
+ "confirming": "Wird gelöscht...",
+ "error": "Sicherung konnte nicht gelöscht werden. Bitte versuchen Sie es erneut."
+ },
+ "restoreModal": {
+ "title": "Sicherung wiederherstellen",
+ "message": "Sie sind dabei, eine Wiederherstellung aus folgender Sicherung durchzuführen:",
+ "warning": "Dadurch werden alle aktuellen Anwendungsdaten unwiderruflich durch den Inhalt der Sicherung ersetzt. Der Server wird automatisch neu gestartet. Alle aktuellen Daten gehen verloren.",
+ "cancel": "Abbrechen",
+ "confirm": "Wiederherstellen & Neu starten",
+ "confirming": "Wird wiederhergestellt...",
+ "error": "Wiederherstellung konnte nicht gestartet werden. Bitte versuchen Sie es erneut."
+ },
+ "restartingMessage": "Der Server wird neu gestartet. Bitte warten Sie einen Moment und laden Sie dann die Seite neu.",
+ "createError": "Sicherung konnte nicht erstellt werden. Bitte versuchen Sie es erneut.",
+ "loadError": "Sicherungen konnten nicht geladen werden. Bitte versuchen Sie es erneut."
+ },
"vendors": {
"contacts": {
"heading": "Kontakte",
diff --git a/client/src/i18n/de/workItems.json b/client/src/i18n/de/workItems.json
index 3eac1e007..968b55e96 100644
--- a/client/src/i18n/de/workItems.json
+++ b/client/src/i18n/de/workItems.json
@@ -1,6 +1,7 @@
{
"list": {
"pageTitle": "Projekt",
+ "sectionTitle": "Arbeitspakete",
"newWorkItem": "Neues Arbeitspaket",
"loading": "Arbeitspakete werden geladen...",
"search": {
@@ -18,10 +19,6 @@
"allAreas": "Alle Bereiche",
"assignedVendor": "Auftragnehmer:",
"allVendors": "Alle Auftragnehmer",
- "noBudget": "Keine Budgetpositionen",
- "noBudgetAriaLabel": "Nur Arbeitspakete ohne Budgetpositionen anzeigen",
- "noBudgetActive": "Kein-Budget-Filter aktiv",
- "noBudgetInactive": "Kein-Budget-Filter aufgehoben",
"sortBy": "Sortieren nach:",
"sortAscending": "↑ Aufsteigend",
"sortDescending": "↓ Absteigend",
@@ -47,6 +44,8 @@
"title": "Titel",
"status": "Status",
"assignedTo": "Zugewiesen an",
+ "vendor": "Auftragnehmer",
+ "area": "Bereich",
"startDate": "Startdatum",
"endDate": "Enddatum",
"tags": "Tags",
diff --git a/client/src/i18n/en/budget.json b/client/src/i18n/en/budget.json
index c2de6680a..ea2314668 100644
--- a/client/src/i18n/en/budget.json
+++ b/client/src/i18n/en/budget.json
@@ -209,7 +209,13 @@
"retry": "Retry"
},
"tableHeaders": {
- "actions": "Actions"
+ "name": "Name",
+ "trade": "Trade",
+ "contactInfo": "Contact",
+ "address": "Address",
+ "notes": "Notes",
+ "createdAt": "Date Added",
+ "updatedAt": "Last Updated"
},
"searchAriaLabel": "Search vendors",
"modal": {
@@ -385,7 +391,9 @@
"amount": "Amount",
"allocated": "Allocated",
"dueDate": "Due Date",
- "status": "Status"
+ "status": "Status",
+ "notes": "Notes",
+ "remainingAmount": "Remaining Amount"
},
"form": {
"vendor": "Vendor",
@@ -413,7 +421,11 @@
"save": "Save Changes",
"saving": "Saving...",
"cancel": "Cancel",
- "view": "View"
+ "view": "View",
+ "delete": "Delete"
+ },
+ "actions": {
+ "menuAriaLabel": "Actions for invoice {{number}}"
},
"statusLabels": {
"pending": "Pending",
diff --git a/client/src/i18n/en/common.json b/client/src/i18n/en/common.json
index 410d1a78e..0bcb102ba 100644
--- a/client/src/i18n/en/common.json
+++ b/client/src/i18n/en/common.json
@@ -18,7 +18,8 @@
"create": "Create",
"confirm": "Confirm",
"back": "Back",
- "search": "Search"
+ "search": "Search",
+ "clearFilters": "Clear Filters"
},
"aria": {
"closeMenu": "Close menu",
@@ -85,6 +86,7 @@
"toast": {
"dismissAriaLabel": "Dismiss notification"
},
+ "actions": "Actions",
"assignmentPicker": {
"usersGroup": "Users",
"vendorsGroup": "Vendors"
@@ -106,7 +108,63 @@
"settings": {
"profile": "Profile",
"manage": "Manage",
- "userManagement": "User Management"
+ "userManagement": "User Management",
+ "backups": "Backups"
}
+ },
+ "dataTable": {
+ "search": {
+ "placeholder": "Search...",
+ "ariaLabel": "Search items"
+ },
+ "filter": {
+ "all": "All",
+ "yes": "Yes",
+ "no": "No",
+ "min": "Min",
+ "max": "Max",
+ "from": "From",
+ "to": "To",
+ "clearFilter": "Clear",
+ "applyFilter": "Apply",
+ "filterByColumn": "Filter by {{column}}",
+ "textPlaceholder": "Search...",
+ "searchPlaceholder": "Search...",
+ "booleanAriaLabel": "Boolean filter",
+ "selectAll": "Select All",
+ "selectNone": "Select None",
+ "dateRangeAriaLabel": "Date range picker"
+ },
+ "sort": {
+ "ascending": "Sort ascending",
+ "descending": "Sort descending",
+ "none": "No sort",
+ "ariaLabel": "Sort by {{column}}"
+ },
+ "columnSettings": {
+ "ariaLabel": "Column settings",
+ "title": "Visible columns",
+ "resetToDefaults": "Reset to defaults",
+ "dragHandleAriaLabel": "Drag to reorder {{column}} column"
+ },
+ "pagination": {
+ "showing": "Showing {{from}}–{{to}} of {{total}} items",
+ "page": "Page {{page}} of {{totalPages}}",
+ "previous": "Previous",
+ "next": "Next",
+ "pageSize": "Per page"
+ },
+ "empty": {
+ "defaultMessage": "No items found",
+ "filteredMessage": "No items match the current filters"
+ },
+ "loading": "Loading table data..."
+ },
+ "dateRangePicker": {
+ "previousMonth": "Previous month",
+ "nextMonth": "Next month",
+ "selectStart": "Select start date",
+ "selectEnd": "Select end date",
+ "calendarGridAriaLabel": "Calendar"
}
}
diff --git a/client/src/i18n/en/dashboard.json b/client/src/i18n/en/dashboard.json
index 843e35881..19888e263 100644
--- a/client/src/i18n/en/dashboard.json
+++ b/client/src/i18n/en/dashboard.json
@@ -6,9 +6,9 @@
"showCard": "Show {{title}}",
"actions": {
"addButton": "Add",
- "addWorkItem": "Add Work Item",
- "addHouseholdItem": "Add Household Item",
- "addMilestone": "Add Milestone"
+ "addWorkItem": "New Work Item",
+ "addHouseholdItem": "New Household Item",
+ "addMilestone": "New Milestone"
}
},
"sections": {
diff --git a/client/src/i18n/en/errors.json b/client/src/i18n/en/errors.json
index 9d8034f01..f5d7e7245 100644
--- a/client/src/i18n/en/errors.json
+++ b/client/src/i18n/en/errors.json
@@ -33,5 +33,9 @@
"ACCOUNT_LOCKED": "This account has been temporarily locked due to too many failed login attempts.",
"INVALID_METADATA": "The entry metadata is invalid.",
"INVALID_ENTRY_TYPE": "This diary entry type is not allowed for this operation.",
- "IMMUTABLE_ENTRY": "This diary entry cannot be modified."
+ "IMMUTABLE_ENTRY": "This diary entry cannot be modified.",
+ "BACKUP_NOT_CONFIGURED": "Backup is not configured. Set the BACKUP_DIR environment variable.",
+ "BACKUP_IN_PROGRESS": "A backup or restore operation is already in progress.",
+ "BACKUP_NOT_FOUND": "The requested backup archive was not found.",
+ "RESTORE_FAILED": "The restore operation failed. The server may be in an inconsistent state."
}
diff --git a/client/src/i18n/en/householdItems.json b/client/src/i18n/en/householdItems.json
index d9d98b54c..d48941def 100644
--- a/client/src/i18n/en/householdItems.json
+++ b/client/src/i18n/en/householdItems.json
@@ -14,10 +14,6 @@
"status": "Status:",
"room": "Room:",
"vendor": "Vendor:",
- "noBudget": "No Budget Lines",
- "noBudgetAriaLabel": "Show only household items without budget lines",
- "noBudgetActive": "No Budget Lines filter active",
- "noBudgetInactive": "No Budget Lines filter cleared",
"sortBy": "Sort by:",
"allCategories": "All Categories",
"allAreas": "All Areas",
@@ -48,14 +44,21 @@
"arrived": "Arrived"
},
"table": {
+ "sectionTitle": "Household Items",
"headers": {
"name": "Name",
"category": "Category",
"status": "Status",
- "room": "Room",
+ "area": "Area",
"vendor": "Vendor",
"plannedCost": "Planned Cost",
"targetDelivery": "Target Delivery",
+ "actualDelivery": "Actual Delivery",
+ "actualCost": "Actual Cost",
+ "subsidyReduction": "Subsidy Reduction",
+ "netCost": "Net Cost",
+ "orderDate": "Order Date",
+ "budgetLines": "Budget Lines",
"actions": "Actions"
}
},
diff --git a/client/src/i18n/en/schedule.json b/client/src/i18n/en/schedule.json
index 32616f775..8c9187334 100644
--- a/client/src/i18n/en/schedule.json
+++ b/client/src/i18n/en/schedule.json
@@ -23,9 +23,9 @@
"zoomInColumns": "Zoom in columns",
"adjustColumnWidth": "Adjust column width (Ctrl+scroll or Ctrl+=/−)",
"addButton": "Add",
- "addWorkItem": "Add Work Item",
- "addHouseholdItem": "Add Household Item",
- "addMilestone": "Add Milestone"
+ "addWorkItem": "New Work Item",
+ "addHouseholdItem": "New Household Item",
+ "addMilestone": "New Milestone"
},
"zoomOptions": {
"day": "Day",
@@ -47,7 +47,8 @@
"schedule": {
"navigation": {
"gantt": "Gantt",
- "calendar": "Calendar"
+ "calendar": "Calendar",
+ "ariaLabel": "Schedule section navigation"
}
},
"ganttSidebar": {
@@ -146,6 +147,8 @@
"targetDate": "Target Date",
"status": "Status",
"description": "Description",
+ "linkedItems": "Linked Items",
+ "completedAt": "Completed Date",
"actions": "Actions"
}
},
diff --git a/client/src/i18n/en/settings.json b/client/src/i18n/en/settings.json
index 3fdf7ea28..3b611f543 100644
--- a/client/src/i18n/en/settings.json
+++ b/client/src/i18n/en/settings.json
@@ -53,7 +53,8 @@
"role": "Role",
"authProvider": "Auth Provider",
"status": "Status",
- "actions": "Actions"
+ "actions": "Actions",
+ "memberSince": "Member Since"
},
"roles": {
"admin": "Administrator",
@@ -69,7 +70,8 @@
},
"actions": {
"edit": "Edit",
- "deactivate": "Deactivate"
+ "deactivate": "Deactivate",
+ "menuAriaLabel": "User actions menu"
},
"editModal": {
"title": "Edit User",
@@ -194,7 +196,7 @@
"errorTitle": "Error",
"retry": "Retry",
"loadError": "Failed to load budget categories. Please try again.",
- "createTitle": "New Budget Category",
+ "createTitle": "Create New Budget Category",
"createDescription": "Budget categories group your construction costs (e.g., Materials, Labor, Permits).",
"nameLabel": "Name",
"nameRequired": "*",
@@ -238,7 +240,7 @@
"errorTitle": "Error",
"retry": "Retry",
"loadError": "Failed to load household item categories. Please try again.",
- "createTitle": "New Household Item Category",
+ "createTitle": "Create New Household Item Category",
"createDescription": "Categories organize your household items and furniture purchases (e.g., Furniture, Appliances, Fixtures).",
"nameLabel": "Name",
"nameRequired": "*",
@@ -294,6 +296,89 @@
"generating": "Generating...",
"revoking": "Revoking..."
},
+ "trades": {
+ "plumbing": "Plumbing",
+ "hvac": "HVAC",
+ "electrical": "Electrical",
+ "drywall": "Drywall",
+ "carpentry": "Carpentry",
+ "masonry": "Masonry",
+ "painting": "Painting",
+ "roofing": "Roofing",
+ "flooring": "Flooring",
+ "tiling": "Tiling",
+ "landscaping": "Landscaping",
+ "excavation": "Excavation",
+ "generalContractor": "General Contractor",
+ "architectDesign": "Architect / Design",
+ "other": "Other"
+ },
+ "budgetCategories": {
+ "materials": "Materials",
+ "labor": "Labor",
+ "permits": "Permits",
+ "design": "Design",
+ "householdItems": "Household Items",
+ "waste": "Waste",
+ "other": "Other",
+ "equipment": "Equipment",
+ "landscaping": "Landscaping",
+ "utilities": "Utilities",
+ "insurance": "Insurance",
+ "contingency": "Contingency"
+ },
+ "householdItemCategories": {
+ "furniture": "Furniture",
+ "appliances": "Appliances",
+ "fixtures": "Fixtures",
+ "decor": "Decor",
+ "electronics": "Electronics",
+ "equipment": "Equipment",
+ "other": "Other",
+ "outdoor": "Outdoor",
+ "storage": "Storage"
+ },
+ "backups": {
+ "pageTitle": "Backup & Restore",
+ "createButton": "Create Backup",
+ "creating": "Creating backup...",
+ "loading": "Loading backups...",
+ "notConfiguredMessage": "Backup is not configured",
+ "notConfiguredDescription": "To enable backups, set the BACKUP_DIR environment variable to a directory outside the application data directory.",
+ "emptyStateMessage": "No backups yet",
+ "emptyStateDescription": "Click 'Create Backup' to create your first backup.",
+ "tableHeaders": {
+ "filename": "Filename",
+ "createdAt": "Created",
+ "size": "Size",
+ "actions": "Actions"
+ },
+ "actions": {
+ "delete": "Delete",
+ "restore": "Restore"
+ },
+ "deleteModal": {
+ "title": "Delete Backup",
+ "message": "Are you sure you want to delete {{filename}}?",
+ "warning": "This action cannot be undone.",
+ "cancel": "Cancel",
+ "confirm": "Delete",
+ "confirming": "Deleting...",
+ "error": "Failed to delete backup. Please try again."
+ },
+ "restoreModal": {
+ "title": "Restore Backup",
+ "message": "You are about to restore from:",
+ "warning": "This will permanently replace all current application data with the backup contents. The server will restart automatically. All current data will be lost.",
+ "cancel": "Cancel",
+ "confirm": "Restore & Restart",
+ "confirming": "Restoring...",
+ "error": "Failed to initiate restore. Please try again."
+ },
+ "restartingMessage": "Server is restarting. Please wait a few moments and then refresh the page.",
+ "createError": "Failed to create backup. Please try again.",
+ "loadError": "Failed to load backups. Please try again."
+ },
"vendors": {
"contacts": {
"heading": "Contacts",
diff --git a/client/src/i18n/en/workItems.json b/client/src/i18n/en/workItems.json
index 08ec3d432..d2c95b92e 100644
--- a/client/src/i18n/en/workItems.json
+++ b/client/src/i18n/en/workItems.json
@@ -1,6 +1,7 @@
{
"list": {
"pageTitle": "Project",
+ "sectionTitle": "Work Items",
"newWorkItem": "New Work Item",
"loading": "Loading work items...",
"search": {
@@ -18,10 +19,6 @@
"allAreas": "All Areas",
"assignedVendor": "Vendor:",
"allVendors": "All Vendors",
- "noBudget": "No Budget Lines",
- "noBudgetAriaLabel": "Show only work items without budget lines",
- "noBudgetActive": "No Budget filter active",
- "noBudgetInactive": "No Budget filter cleared",
"sortBy": "Sort by:",
"sortAscending": "↑ Asc",
"sortDescending": "↓ Desc",
@@ -47,6 +44,8 @@
"title": "Title",
"status": "Status",
"assignedTo": "Assigned To",
+ "vendor": "Vendor",
+ "area": "Area",
"startDate": "Start Date",
"endDate": "End Date",
"tags": "Tags",
diff --git a/client/src/lib/backupsApi.test.ts b/client/src/lib/backupsApi.test.ts
new file mode 100644
index 000000000..2906c446a
--- /dev/null
+++ b/client/src/lib/backupsApi.test.ts
@@ -0,0 +1,326 @@
+/**
+ * Unit tests for backupsApi.ts
+ *
+ * EPIC-19: Backup and Restore Feature
+ */
+
+import { jest, describe, it, expect, beforeEach, afterEach } from '@jest/globals';
+import { listBackups, createBackup, deleteBackup, restoreBackup } from './backupsApi.js';
+import type {
+ BackupListResponse,
+ BackupResponse,
+ RestoreInitiatedResponse,
+} from '@cornerstone/shared';
+
+describe('backupsApi', () => {
+ let mockFetch: jest.MockedFunction;
+
+ beforeEach(() => {
+ mockFetch = jest.fn();
+ globalThis.fetch = mockFetch;
+ });
+
+ afterEach(() => {
+ jest.restoreAllMocks();
+ });
+
+ // ─── listBackups ──────────────────────────────────────────────────────────
+
+ describe('listBackups()', () => {
+ it('calls GET /api/backups', async () => {
+ const mockResponse: BackupListResponse = { backups: [] };
+
+ mockFetch.mockResolvedValueOnce({
+ ok: true,
+ json: async () => mockResponse,
+ } as Response);
+
+ await listBackups();
+
+ expect(mockFetch).toHaveBeenCalledWith('/api/backups', expect.any(Object));
+ });
+
+ it('returns the response body with empty backups array', async () => {
+ const mockResponse: BackupListResponse = { backups: [] };
+
+ mockFetch.mockResolvedValueOnce({
+ ok: true,
+ json: async () => mockResponse,
+ } as Response);
+
+ const result = await listBackups();
+
+ expect(result).toEqual(mockResponse);
+ expect(result.backups).toHaveLength(0);
+ });
+
+ it('returns the response body with populated backups array', async () => {
+ const mockResponse: BackupListResponse = {
+ backups: [
+ {
+ filename: 'cornerstone-backup-2026-03-22T020000Z.tar.gz',
+ createdAt: '2026-03-22T02:00:00.000Z',
+ sizeBytes: 102400,
+ },
+ {
+ filename: 'cornerstone-backup-2026-01-01T000000Z.tar.gz',
+ createdAt: '2026-01-01T00:00:00.000Z',
+ sizeBytes: 81920,
+ },
+ ],
+ };
+
+ mockFetch.mockResolvedValueOnce({
+ ok: true,
+ json: async () => mockResponse,
+ } as Response);
+
+ const result = await listBackups();
+
+ expect(result.backups).toHaveLength(2);
+ expect(result.backups[0].filename).toBe('cornerstone-backup-2026-03-22T020000Z.tar.gz');
+ expect(result.backups[0].sizeBytes).toBe(102400);
+ });
+
+ it('throws ApiClientError when server returns 503 BACKUP_NOT_CONFIGURED', async () => {
+ mockFetch.mockResolvedValueOnce({
+ ok: false,
+ status: 503,
+ json: async () => ({
+ error: {
+ code: 'BACKUP_NOT_CONFIGURED',
+ message: 'Backup is not configured',
+ },
+ }),
+ } as Response);
+
+ await expect(listBackups()).rejects.toThrow();
+ });
+
+ it('throws ApiClientError when server returns 401 UNAUTHORIZED', async () => {
+ mockFetch.mockResolvedValueOnce({
+ ok: false,
+ status: 401,
+ json: async () => ({
+ error: { code: 'UNAUTHORIZED', message: 'Unauthorized' },
+ }),
+ } as Response);
+
+ await expect(listBackups()).rejects.toThrow();
+ });
+ });
+
+ // ─── createBackup ─────────────────────────────────────────────────────────
+
+ describe('createBackup()', () => {
+ it('calls POST /api/backups', async () => {
+ const mockResponse: BackupResponse = {
+ backup: {
+ filename: 'cornerstone-backup-2026-03-22T020000Z.tar.gz',
+ createdAt: '2026-03-22T02:00:00.000Z',
+ sizeBytes: 102400,
+ },
+ };
+
+ mockFetch.mockResolvedValueOnce({
+ ok: true,
+ status: 201,
+ json: async () => mockResponse,
+ } as Response);
+
+ await createBackup();
+
+ expect(mockFetch).toHaveBeenCalledWith(
+ '/api/backups',
+ expect.objectContaining({ method: 'POST' }),
+ );
+ });
+
+ it('returns the created backup metadata', async () => {
+ const mockResponse: BackupResponse = {
+ backup: {
+ filename: 'cornerstone-backup-2026-03-22T020000Z.tar.gz',
+ createdAt: '2026-03-22T02:00:00.000Z',
+ sizeBytes: 204800,
+ },
+ };
+
+ mockFetch.mockResolvedValueOnce({
+ ok: true,
+ status: 201,
+ json: async () => mockResponse,
+ } as Response);
+
+ const result = await createBackup();
+
+ expect(result).toEqual(mockResponse);
+ expect(result.backup.filename).toBe('cornerstone-backup-2026-03-22T020000Z.tar.gz');
+ expect(result.backup.sizeBytes).toBe(204800);
+ });
+
+ it('throws ApiClientError when server returns 503 BACKUP_NOT_CONFIGURED', async () => {
+ mockFetch.mockResolvedValueOnce({
+ ok: false,
+ status: 503,
+ json: async () => ({
+ error: { code: 'BACKUP_NOT_CONFIGURED', message: 'Backup is not configured' },
+ }),
+ } as Response);
+
+ await expect(createBackup()).rejects.toThrow();
+ });
+
+ it('throws ApiClientError when server returns 409 BACKUP_IN_PROGRESS', async () => {
+ mockFetch.mockResolvedValueOnce({
+ ok: false,
+ status: 409,
+ json: async () => ({
+ error: {
+ code: 'BACKUP_IN_PROGRESS',
+ message: 'A backup operation is already in progress',
+ },
+ }),
+ } as Response);
+
+ await expect(createBackup()).rejects.toThrow();
+ });
+ });
+
+ // ─── deleteBackup ─────────────────────────────────────────────────────────
+
+ describe('deleteBackup()', () => {
+ it('calls DELETE /api/backups/:filename with the filename', async () => {
+ mockFetch.mockResolvedValueOnce({
+ ok: true,
+ status: 204,
+ } as Response);
+
+ const filename = 'cornerstone-backup-2026-03-22T020000Z.tar.gz';
+ await deleteBackup(filename);
+
+ expect(mockFetch).toHaveBeenCalledWith(
+ `/api/backups/${encodeURIComponent(filename)}`,
+ expect.objectContaining({ method: 'DELETE' }),
+ );
+ });
+
+ it('URL-encodes the filename in the request path', async () => {
+ mockFetch.mockResolvedValueOnce({
+ ok: true,
+ status: 204,
+ } as Response);
+
+ // A filename with special chars that would need encoding
+ const filename = 'cornerstone-backup-2026-03-22T020000Z.tar.gz';
+ await deleteBackup(filename);
+
+ const calledUrl = (mockFetch.mock.calls[0] as [string, ...unknown[]])[0];
+ expect(calledUrl).toBe(`/api/backups/${encodeURIComponent(filename)}`);
+ });
+
+ it('returns void on successful deletion (204)', async () => {
+ mockFetch.mockResolvedValueOnce({
+ ok: true,
+ status: 204,
+ } as Response);
+
+ const result = await deleteBackup('cornerstone-backup-2026-03-22T020000Z.tar.gz');
+ expect(result).toBeUndefined();
+ });
+
+ it('throws ApiClientError when server returns 404 BACKUP_NOT_FOUND', async () => {
+ mockFetch.mockResolvedValueOnce({
+ ok: false,
+ status: 404,
+ json: async () => ({
+ error: { code: 'BACKUP_NOT_FOUND', message: 'Backup not found' },
+ }),
+ } as Response);
+
+ await expect(deleteBackup('cornerstone-backup-2099-01-01T000000Z.tar.gz')).rejects.toThrow();
+ });
+ });
+
+ // ─── restoreBackup ────────────────────────────────────────────────────────
+
+ describe('restoreBackup()', () => {
+ it('calls POST /api/backups/:filename/restore with the filename', async () => {
+ const mockResponse: RestoreInitiatedResponse = {
+ message: 'Restore initiated. Server is restarting.',
+ };
+
+ mockFetch.mockResolvedValueOnce({
+ ok: true,
+ status: 202,
+ json: async () => mockResponse,
+ } as Response);
+
+ const filename = 'cornerstone-backup-2026-03-22T020000Z.tar.gz';
+ await restoreBackup(filename);
+
+ expect(mockFetch).toHaveBeenCalledWith(
+ `/api/backups/${encodeURIComponent(filename)}/restore`,
+ expect.objectContaining({ method: 'POST' }),
+ );
+ });
+
+ it('URL-encodes the filename in the request path', async () => {
+ const mockResponse: RestoreInitiatedResponse = {
+ message: 'Restore initiated. Server is restarting.',
+ };
+
+ mockFetch.mockResolvedValueOnce({
+ ok: true,
+ status: 202,
+ json: async () => mockResponse,
+ } as Response);
+
+ const filename = 'cornerstone-backup-2026-03-22T020000Z.tar.gz';
+ await restoreBackup(filename);
+
+ const calledUrl = (mockFetch.mock.calls[0] as [string, ...unknown[]])[0];
+ expect(calledUrl).toBe(`/api/backups/${encodeURIComponent(filename)}/restore`);
+ });
+
+ it('returns the RestoreInitiatedResponse', async () => {
+ const mockResponse: RestoreInitiatedResponse = {
+ message: 'Restore initiated. Server is restarting.',
+ };
+
+ mockFetch.mockResolvedValueOnce({
+ ok: true,
+ status: 202,
+ json: async () => mockResponse,
+ } as Response);
+
+ const result = await restoreBackup('cornerstone-backup-2026-03-22T020000Z.tar.gz');
+
+ expect(result).toEqual(mockResponse);
+ expect(result.message).toBeTruthy();
+ });
+
+ it('throws ApiClientError when server returns 404 BACKUP_NOT_FOUND', async () => {
+ mockFetch.mockResolvedValueOnce({
+ ok: false,
+ status: 404,
+ json: async () => ({
+ error: { code: 'BACKUP_NOT_FOUND', message: 'Backup not found' },
+ }),
+ } as Response);
+
+ await expect(restoreBackup('cornerstone-backup-2099-01-01T000000Z.tar.gz')).rejects.toThrow();
+ });
+
+ it('throws ApiClientError when server returns 503 BACKUP_NOT_CONFIGURED', async () => {
+ mockFetch.mockResolvedValueOnce({
+ ok: false,
+ status: 503,
+ json: async () => ({
+ error: { code: 'BACKUP_NOT_CONFIGURED', message: 'Backup is not configured' },
+ }),
+ } as Response);
+
+ await expect(restoreBackup('cornerstone-backup-2026-03-22T020000Z.tar.gz')).rejects.toThrow();
+ });
+ });
+});
diff --git a/client/src/lib/backupsApi.ts b/client/src/lib/backupsApi.ts
new file mode 100644
index 000000000..498111a76
--- /dev/null
+++ b/client/src/lib/backupsApi.ts
@@ -0,0 +1,35 @@
+import { get, post, del } from './apiClient.js';
+import type {
+ BackupListResponse,
+ BackupResponse,
+ RestoreInitiatedResponse,
+} from '@cornerstone/shared';
+
+/**
+ * List all backup archives.
+ */
+export function listBackups(): Promise {
+ return get('/backups');
+}
+
+/**
+ * Create a new backup archive.
+ */
+export function createBackup(): Promise {
+ return post('/backups');
+}
+
+/**
+ * Delete a backup archive by filename.
+ */
+export function deleteBackup(filename: string): Promise {
+ return del(`/backups/${encodeURIComponent(filename)}`);
+}
+
+/**
+ * Restore a backup archive by filename.
+ * Initiates the restore process and restarts the server.
+ */
+export function restoreBackup(filename: string): Promise {
+ return post(`/backups/${encodeURIComponent(filename)}/restore`);
+}
diff --git a/client/src/lib/budgetCategoriesApi.test.ts b/client/src/lib/budgetCategoriesApi.test.ts
index 20579dd53..9a78a38da 100644
--- a/client/src/lib/budgetCategoriesApi.test.ts
+++ b/client/src/lib/budgetCategoriesApi.test.ts
@@ -61,6 +61,7 @@ describe('budgetCategoriesApi', () => {
name: 'Materials',
description: 'Building materials',
color: '#FF5733',
+ translationKey: null,
sortOrder: 1,
createdAt: '2026-01-01T00:00:00.000Z',
updatedAt: '2026-01-01T00:00:00.000Z',
@@ -70,6 +71,7 @@ describe('budgetCategoriesApi', () => {
name: 'Labor',
description: null,
color: '#3B82F6',
+ translationKey: null,
sortOrder: 2,
createdAt: '2026-01-02T00:00:00.000Z',
updatedAt: '2026-01-02T00:00:00.000Z',
@@ -109,6 +111,7 @@ describe('budgetCategoriesApi', () => {
name: 'Materials',
description: null,
color: null,
+ translationKey: null,
sortOrder: 0,
createdAt: '2026-01-01T00:00:00.000Z',
updatedAt: '2026-01-01T00:00:00.000Z',
@@ -138,6 +141,7 @@ describe('budgetCategoriesApi', () => {
name: 'Labor',
description: 'Construction labor',
color: '#3B82F6',
+ translationKey: null,
sortOrder: 5,
createdAt: '2026-01-01T00:00:00.000Z',
updatedAt: '2026-01-01T00:00:00.000Z',
@@ -167,6 +171,7 @@ describe('budgetCategoriesApi', () => {
name: 'Permits',
description: 'Permit costs',
color: '#10B981',
+ translationKey: null,
sortOrder: 3,
createdAt: '2026-01-01T00:00:00.000Z',
updatedAt: '2026-01-01T00:00:00.000Z',
@@ -233,6 +238,7 @@ describe('budgetCategoriesApi', () => {
name: 'Updated Materials',
description: null,
color: '#FF0000',
+ translationKey: null,
sortOrder: 0,
createdAt: '2026-01-01T00:00:00.000Z',
updatedAt: '2026-01-02T00:00:00.000Z',
@@ -261,6 +267,7 @@ describe('budgetCategoriesApi', () => {
name: 'New Name',
description: 'New description',
color: '#00FF00',
+ translationKey: null,
sortOrder: 10,
createdAt: '2026-01-01T00:00:00.000Z',
updatedAt: '2026-01-02T00:00:00.000Z',
@@ -288,6 +295,7 @@ describe('budgetCategoriesApi', () => {
name: 'Materials',
description: null,
color: '#AABBCC',
+ translationKey: null,
sortOrder: 0,
createdAt: '2026-01-01T00:00:00.000Z',
updatedAt: '2026-01-02T00:00:00.000Z',
diff --git a/client/src/lib/budgetOverviewApi.test.ts b/client/src/lib/budgetOverviewApi.test.ts
index cc097d736..c35b7c386 100644
--- a/client/src/lib/budgetOverviewApi.test.ts
+++ b/client/src/lib/budgetOverviewApi.test.ts
@@ -25,6 +25,7 @@ describe('budgetOverviewApi', () => {
categoryId: 'cat-1',
categoryName: 'Materials',
categoryColor: '#FF5733',
+ categoryTranslationKey: null,
minPlanned: 45000,
maxPlanned: 55000,
actualCost: 45000,
@@ -36,6 +37,7 @@ describe('budgetOverviewApi', () => {
categoryId: 'cat-2',
categoryName: 'Labor',
categoryColor: null,
+ categoryTranslationKey: null,
minPlanned: 45000,
maxPlanned: 55000,
actualCost: 35000,
diff --git a/client/src/lib/categoryUtils.test.ts b/client/src/lib/categoryUtils.test.ts
new file mode 100644
index 000000000..4ad2f0c19
--- /dev/null
+++ b/client/src/lib/categoryUtils.test.ts
@@ -0,0 +1,280 @@
+import { describe, it, expect, jest } from '@jest/globals';
+import { getCategoryDisplayName } from './categoryUtils.js';
+import enSettings from '../i18n/en/settings.json';
+import deSettings from '../i18n/de/settings.json';
+
+// ─── getCategoryDisplayName ────────────────────────────────────────────────────
+
+describe('getCategoryDisplayName', () => {
+ describe('when translationKey is present and translation exists', () => {
+ it('calls t() with the translationKey and name as defaultValue', () => {
+ const t = jest.fn((_key: string, _opts?: { defaultValue: string }) => 'Sanitär') as (
+ key: string,
+ opts?: { defaultValue: string },
+ ) => string;
+
+ const result = getCategoryDisplayName(t, 'Plumbing', 'trades.plumbing');
+
+ expect(t).toHaveBeenCalledTimes(1);
+ expect(t).toHaveBeenCalledWith('trades.plumbing', { defaultValue: 'Plumbing' });
+ expect(result).toBe('Sanitär');
+ });
+
+ it('returns the translated string (not the raw name)', () => {
+ const t = jest.fn(() => 'Malerarbeiten') as (
+ key: string,
+ opts?: { defaultValue: string },
+ ) => string;
+
+ const result = getCategoryDisplayName(t, 'Painting', 'trades.painting');
+
+ expect(result).toBe('Malerarbeiten');
+ });
+
+ it('uses the raw name as defaultValue so t() can fall back to it', () => {
+ const t = jest.fn(
+ (_key: string, opts?: { defaultValue: string }) => opts?.defaultValue ?? '',
+ ) as (key: string, opts?: { defaultValue: string }) => string;
+
+ const result = getCategoryDisplayName(t, 'Materials', 'budgetCategories.materials');
+
+ expect(t).toHaveBeenCalledWith('budgetCategories.materials', { defaultValue: 'Materials' });
+ expect(result).toBe('Materials');
+ });
+
+ it('works with householdItemCategories namespace keys', () => {
+ const t = jest.fn((_key: string, _opts?: { defaultValue: string }) => 'Möbel') as (
+ key: string,
+ opts?: { defaultValue: string },
+ ) => string;
+
+ const result = getCategoryDisplayName(t, 'Furniture', 'householdItemCategories.furniture');
+
+ expect(t).toHaveBeenCalledWith('householdItemCategories.furniture', {
+ defaultValue: 'Furniture',
+ });
+ expect(result).toBe('Möbel');
+ });
+ });
+
+ describe('when translationKey is null', () => {
+ it('returns the raw name without calling t()', () => {
+ const t = jest.fn() as (key: string, opts?: { defaultValue: string }) => string;
+
+ const result = getCategoryDisplayName(t, 'Custom Trade', null);
+
+ expect(t).not.toHaveBeenCalled();
+ expect(result).toBe('Custom Trade');
+ });
+
+ it('returns an empty string when name is empty and translationKey is null', () => {
+ const t = jest.fn() as (key: string, opts?: { defaultValue: string }) => string;
+
+ const result = getCategoryDisplayName(t, '', null);
+
+ expect(t).not.toHaveBeenCalled();
+ expect(result).toBe('');
+ });
+
+ it('returns the exact raw name string without modification', () => {
+ const t = jest.fn() as (key: string, opts?: { defaultValue: string }) => string;
+ const rawName = 'My Custom Budget Category 123';
+
+ const result = getCategoryDisplayName(t, rawName, null);
+
+ expect(result).toBe(rawName);
+ });
+ });
+
+ describe('when translationKey is present but translation does not exist', () => {
+ it('falls back to raw name when t() returns the key itself (i18next default)', () => {
+ // Simulate i18next returning the key when no translation exists
+ const t = jest.fn((key: string, _opts?: { defaultValue: string }) => key) as (
+ key: string,
+ opts?: { defaultValue: string },
+ ) => string;
+
+ const result = getCategoryDisplayName(t, 'Plumbing', 'trades.plumbing');
+
+ // t() is called with the key — returns the key string here
+ // The caller receives whatever t() returns (the key, not the raw name)
+ // This is standard i18next behaviour; defaultValue is only used when
+ // i18next explicitly returns the defaultValue option
+ expect(t).toHaveBeenCalledWith('trades.plumbing', { defaultValue: 'Plumbing' });
+ });
+
+ it('returns defaultValue (raw name) when t() honours the defaultValue option', () => {
+ // Simulate i18next using defaultValue when key is missing from locale
+ const t = jest.fn(
+ (_key: string, opts?: { defaultValue: string }) => opts?.defaultValue ?? '',
+ ) as (key: string, opts?: { defaultValue: string }) => string;
+
+ const result = getCategoryDisplayName(t, 'Plumbing', 'trades.missingKey');
+
+ expect(result).toBe('Plumbing');
+ });
+ });
+
+ describe('edge cases', () => {
+ it('passes the exact translationKey string to t() without modification', () => {
+ const t = jest.fn(() => 'translated') as (
+ key: string,
+ opts?: { defaultValue: string },
+ ) => string;
+
+ getCategoryDisplayName(t, 'Name', 'budgetCategories.householdItems');
+
+ expect(t).toHaveBeenCalledWith('budgetCategories.householdItems', expect.any(Object));
+ });
+
+ it('handles translationKey that is an empty string — behaves as truthy is false', () => {
+ // An empty string is falsy in JS, so getCategoryDisplayName treats it like null
+ const t = jest.fn() as (key: string, opts?: { defaultValue: string }) => string;
+
+ const result = getCategoryDisplayName(t, 'SomeName', '');
+
+ // Empty string is falsy → falls through to return name directly
+ expect(t).not.toHaveBeenCalled();
+ expect(result).toBe('SomeName');
+ });
+ });
+});
+
+// ─── i18n locale coverage ─────────────────────────────────────────────────────
+
+/**
+ * Verify that all category translation keys used by migration 0030 are present
+ * in both the `en` and `de` locale files under client/src/i18n/.
+ *
+ * The translationKey values written to the DB (e.g. 'trades.plumbing') map to
+ * settings.trades.plumbing in the i18n namespace. The prefix before the first
+ * dot is the section, the suffix after is the leaf key.
+ */
+describe('i18n locale coverage — settings namespace category keys', () => {
+ // All translation keys assigned by migration 0030 (predefined rows only)
+ const tradeKeys = [
+ 'plumbing',
+ 'hvac',
+ 'electrical',
+ 'drywall',
+ 'carpentry',
+ 'masonry',
+ 'painting',
+ 'roofing',
+ 'flooring',
+ 'tiling',
+ 'landscaping',
+ 'excavation',
+ 'generalContractor',
+ 'architectDesign',
+ 'other',
+ ];
+
+ const budgetCategoryKeys = [
+ 'materials',
+ 'labor',
+ 'permits',
+ 'design',
+ 'householdItems',
+ 'waste',
+ 'other',
+ 'equipment',
+ 'landscaping',
+ 'utilities',
+ 'insurance',
+ 'contingency',
+ ];
+
+ const householdItemCategoryKeys = [
+ 'furniture',
+ 'appliances',
+ 'fixtures',
+ 'decor',
+ 'electronics',
+ 'equipment',
+ 'other',
+ 'outdoor',
+ 'storage',
+ ];
+
+ type SettingsJson = {
+ trades: Record;
+ budgetCategories: Record;
+ householdItemCategories: Record;
+ };
+
+ const en = enSettings as unknown as SettingsJson;
+ const de = deSettings as unknown as SettingsJson;
+
+ describe('en locale — settings.trades', () => {
+ for (const key of tradeKeys) {
+ it(`has non-empty value for key "${key}"`, () => {
+ expect(typeof en.trades[key]).toBe('string');
+ expect(en.trades[key].length).toBeGreaterThan(0);
+ });
+ }
+ });
+
+ describe('de locale — settings.trades', () => {
+ for (const key of tradeKeys) {
+ it(`has non-empty value for key "${key}"`, () => {
+ expect(typeof de.trades[key]).toBe('string');
+ expect(de.trades[key].length).toBeGreaterThan(0);
+ });
+ }
+ });
+
+ describe('en locale — settings.budgetCategories', () => {
+ for (const key of budgetCategoryKeys) {
+ it(`has non-empty value for key "${key}"`, () => {
+ expect(typeof en.budgetCategories[key]).toBe('string');
+ expect(en.budgetCategories[key].length).toBeGreaterThan(0);
+ });
+ }
+ });
+
+ describe('de locale — settings.budgetCategories', () => {
+ for (const key of budgetCategoryKeys) {
+ it(`has non-empty value for key "${key}"`, () => {
+ expect(typeof de.budgetCategories[key]).toBe('string');
+ expect(de.budgetCategories[key].length).toBeGreaterThan(0);
+ });
+ }
+ });
+
+ describe('en locale — settings.householdItemCategories', () => {
+ for (const key of householdItemCategoryKeys) {
+ it(`has non-empty value for key "${key}"`, () => {
+ expect(typeof en.householdItemCategories[key]).toBe('string');
+ expect(en.householdItemCategories[key].length).toBeGreaterThan(0);
+ });
+ }
+ });
+
+ describe('de locale — settings.householdItemCategories', () => {
+ for (const key of householdItemCategoryKeys) {
+ it(`has non-empty value for key "${key}"`, () => {
+ expect(typeof de.householdItemCategories[key]).toBe('string');
+ expect(de.householdItemCategories[key].length).toBeGreaterThan(0);
+ });
+ }
+ });
+
+ describe('key parity between en and de', () => {
+ it('de has the same trades keys as en', () => {
+ expect(Object.keys(de.trades).sort()).toEqual(Object.keys(en.trades).sort());
+ });
+
+ it('de has the same budgetCategories keys as en', () => {
+ expect(Object.keys(de.budgetCategories).sort()).toEqual(
+ Object.keys(en.budgetCategories).sort(),
+ );
+ });
+
+ it('de has the same householdItemCategories keys as en', () => {
+ expect(Object.keys(de.householdItemCategories).sort()).toEqual(
+ Object.keys(en.householdItemCategories).sort(),
+ );
+ });
+ });
+});
diff --git a/client/src/lib/categoryUtils.ts b/client/src/lib/categoryUtils.ts
new file mode 100644
index 000000000..15ccf6c2b
--- /dev/null
+++ b/client/src/lib/categoryUtils.ts
@@ -0,0 +1,24 @@
+import { useTranslation } from 'react-i18next';
+
+/**
+ * Returns the display name for a category or trade.
+ * If translationKey is present, returns the translated string from the 'settings' namespace.
+ * Otherwise falls back to the raw name from the database.
+ */
+export function getCategoryDisplayName(
+ t: (key: string, options?: { defaultValue: string }) => string,
+ name: string,
+ translationKey: string | null,
+): string {
+ if (!translationKey) return name;
+ return t(translationKey, { defaultValue: name });
+}
+
+/**
+ * Hook wrapper for getCategoryDisplayName.
+ * Provides the translation function from the 'settings' namespace.
+ */
+export function useCategoryDisplayName(name: string, translationKey: string | null): string {
+ const { t } = useTranslation('settings');
+ return getCategoryDisplayName(t, name, translationKey);
+}
diff --git a/client/src/lib/colorUtils.test.ts b/client/src/lib/colorUtils.test.ts
new file mode 100644
index 000000000..6bca8b891
--- /dev/null
+++ b/client/src/lib/colorUtils.test.ts
@@ -0,0 +1,133 @@
+import { describe, it, expect, beforeEach, afterEach, jest } from '@jest/globals';
+import { generateRandomColor } from './colorUtils.js';
+
+// ─── generateRandomColor ──────────────────────────────────────────────────────
+
+describe('generateRandomColor', () => {
+ describe('output format', () => {
+ it('returns a string matching #RRGGBB hex format', () => {
+ const result = generateRandomColor();
+ expect(result).toMatch(/^#[0-9a-f]{6}$/i);
+ });
+
+ it('starts with a hash character', () => {
+ const result = generateRandomColor();
+ expect(result[0]).toBe('#');
+ });
+
+ it('has exactly 7 characters total (# plus 6 hex digits)', () => {
+ const result = generateRandomColor();
+ expect(result).toHaveLength(7);
+ });
+
+ it('contains only valid hex characters in the 6 digit portion', () => {
+ const result = generateRandomColor();
+ const hexPart = result.slice(1);
+ expect(hexPart).toMatch(/^[0-9a-f]{6}$/i);
+ });
+ });
+
+ describe('uniqueness across calls', () => {
+ it('produces at least 40 unique values in a sample of 50 calls', () => {
+ const results = new Set();
+ for (let i = 0; i < 50; i++) {
+ results.add(generateRandomColor());
+ }
+ expect(results.size).toBeGreaterThanOrEqual(40);
+ });
+
+ it('two successive calls typically differ', () => {
+ // With 360 * 26 * 21 = 196,560 possible combinations this is near-certain
+ const calls = Array.from({ length: 10 }, () => generateRandomColor());
+ const unique = new Set(calls);
+ expect(unique.size).toBeGreaterThan(1);
+ });
+ });
+
+ describe('deterministic output with mocked Math.random', () => {
+ const originalRandom = Math.random;
+
+ beforeEach(() => {
+ // Math.random is called three times per invocation:
+ // 1st call → hue floor(x * 360) → 0–359
+ // 2nd call → saturation floor(x * 26) + 65 → 65–90
+ // 3rd call → lightness floor(x * 21) + 40 → 40–60
+ let callCount = 0;
+ const fixedValues = [0.5, 0.5, 0.5];
+ Math.random = jest.fn(() => fixedValues[callCount++ % fixedValues.length]) as () => number;
+ });
+
+ afterEach(() => {
+ Math.random = originalRandom;
+ });
+
+ it('returns the same value on every call when Math.random is fixed', () => {
+ const first = generateRandomColor();
+
+ // Reset the mock to the same sequence for the second call
+ let callCount = 0;
+ const fixedValues = [0.5, 0.5, 0.5];
+ Math.random = jest.fn(() => fixedValues[callCount++ % fixedValues.length]) as () => number;
+
+ const second = generateRandomColor();
+ expect(first).toBe(second);
+ });
+
+ it('returns a valid hex color when Math.random returns 0.5', () => {
+ const result = generateRandomColor();
+ expect(result).toMatch(/^#[0-9a-f]{6}$/i);
+ });
+
+ it('returns a valid hex color when Math.random returns 0 (minimum boundary)', () => {
+ Math.random = jest.fn(() => 0) as () => number;
+ const result = generateRandomColor();
+ expect(result).toMatch(/^#[0-9a-f]{6}$/i);
+ });
+
+ it('returns a valid hex color when Math.random returns values near 1 (maximum boundary)', () => {
+ Math.random = jest.fn(() => 0.9999) as () => number;
+ const result = generateRandomColor();
+ expect(result).toMatch(/^#[0-9a-f]{6}$/i);
+ });
+
+ it('produces the expected deterministic output for Math.random = 0', () => {
+ // hue = floor(0 * 360) = 0
+ // saturation = floor(0 * 26) + 65 = 65
+ // lightness = floor(0 * 21) + 40 = 40
+ //
+ // hslToHex(0, 65, 40):
+ // sNorm = 0.65, lNorm = 0.40
+ // a = 0.65 * min(0.40, 0.60) = 0.65 * 0.40 = 0.26
+ // f(0): k = (0 + 0/30) % 12 = 0
+ // color = 0.40 - 0.26 * max(min(0-3, 9-0, 1), -1)
+ // = 0.40 - 0.26 * max(min(-3, 9, 1), -1)
+ // = 0.40 - 0.26 * max(-1, -1)
+ // = 0.40 - 0.26 * (-1) = 0.66
+ // round(255 * 0.66) = round(168.3) = 168 → "a8"
+ // f(8): k = (8 + 0) % 12 = 8
+ // color = 0.40 - 0.26 * max(min(5, 1, 1), -1)
+ // = 0.40 - 0.26 * 1 = 0.14
+ // round(255 * 0.14) = round(35.7) = 36 → "24"
+ // f(4): k = (4 + 0) % 12 = 4
+ // color = 0.40 - 0.26 * max(min(1, 5, 1), -1)
+ // = 0.40 - 0.26 * 1 = 0.14
+ // round(255 * 0.14) = round(35.7) = 36 → "24"
+ // result = "#a82424"
+ Math.random = jest.fn(() => 0) as () => number;
+ const result = generateRandomColor();
+ expect(result).toBe('#a82424');
+ });
+ });
+
+ describe('valid hex characters', () => {
+ it('all 6 hex digit characters are in [0-9a-f] across multiple calls', () => {
+ const validHexChars = new Set('0123456789abcdef');
+ for (let i = 0; i < 20; i++) {
+ const hexPart = generateRandomColor().slice(1).toLowerCase();
+ for (const ch of hexPart) {
+ expect(validHexChars.has(ch)).toBe(true);
+ }
+ }
+ });
+ });
+});
diff --git a/client/src/lib/colorUtils.ts b/client/src/lib/colorUtils.ts
new file mode 100644
index 000000000..c320f32cb
--- /dev/null
+++ b/client/src/lib/colorUtils.ts
@@ -0,0 +1,35 @@
+/**
+ * Color utilities for the Cornerstone frontend.
+ */
+
+/**
+ * Generates a random visually pleasant hex color.
+ *
+ * Uses HSL color space with constrained saturation (65–90%) and lightness
+ * (40–60%) to avoid near-white, near-black, or washed-out colors.
+ *
+ * @returns A valid CSS hex color string in the form `#RRGGBB`.
+ */
+export function generateRandomColor(): string {
+ const hue = Math.floor(Math.random() * 360);
+ const saturation = Math.floor(Math.random() * 26) + 65; // 65–90%
+ const lightness = Math.floor(Math.random() * 21) + 40; // 40–60%
+ return hslToHex(hue, saturation, lightness);
+}
+
+/**
+ * Converts HSL values to a CSS hex color string.
+ */
+function hslToHex(h: number, s: number, l: number): string {
+ const sNorm = s / 100;
+ const lNorm = l / 100;
+ const a = sNorm * Math.min(lNorm, 1 - lNorm);
+ const f = (n: number): string => {
+ const k = (n + h / 30) % 12;
+ const color = lNorm - a * Math.max(Math.min(k - 3, 9 - k, 1), -1);
+ return Math.round(255 * color)
+ .toString(16)
+ .padStart(2, '0');
+ };
+ return `#${f(0)}${f(8)}${f(4)}`;
+}
diff --git a/client/src/lib/householdItemBudgetsApi.test.ts b/client/src/lib/householdItemBudgetsApi.test.ts
index 377a8fbd9..2678cbcd7 100644
--- a/client/src/lib/householdItemBudgetsApi.test.ts
+++ b/client/src/lib/householdItemBudgetsApi.test.ts
@@ -167,6 +167,7 @@ describe('householdItemBudgetsApi', () => {
name: 'Materials',
color: '#3b82f6',
description: null,
+ translationKey: null,
sortOrder: 1,
createdAt: '2026-01-01T00:00:00.000Z',
updatedAt: '2026-01-01T00:00:00.000Z',
diff --git a/client/src/lib/householdItemCategoriesApi.test.ts b/client/src/lib/householdItemCategoriesApi.test.ts
index dca58f860..cadb6d267 100644
--- a/client/src/lib/householdItemCategoriesApi.test.ts
+++ b/client/src/lib/householdItemCategoriesApi.test.ts
@@ -53,6 +53,7 @@ describe('householdItemCategoriesApi', () => {
id: 'hic-furniture',
name: 'Furniture',
color: '#8B5CF6',
+ translationKey: null,
sortOrder: 0,
createdAt: '2026-01-01T00:00:00.000Z',
updatedAt: '2026-01-01T00:00:00.000Z',
@@ -61,6 +62,7 @@ describe('householdItemCategoriesApi', () => {
id: 'hic-appliances',
name: 'Appliances',
color: '#3B82F6',
+ translationKey: null,
sortOrder: 1,
createdAt: '2026-01-02T00:00:00.000Z',
updatedAt: '2026-01-02T00:00:00.000Z',
@@ -99,6 +101,7 @@ describe('householdItemCategoriesApi', () => {
id: 'hic-new',
name: 'Custom Category',
color: null,
+ translationKey: null,
sortOrder: 0,
createdAt: '2026-01-01T00:00:00.000Z',
updatedAt: '2026-01-01T00:00:00.000Z',
@@ -127,6 +130,7 @@ describe('householdItemCategoriesApi', () => {
id: 'hic-new',
name: 'Garden',
color: '#22C55E',
+ translationKey: null,
sortOrder: 5,
createdAt: '2026-01-01T00:00:00.000Z',
updatedAt: '2026-01-01T00:00:00.000Z',
@@ -154,6 +158,7 @@ describe('householdItemCategoriesApi', () => {
id: 'hic-full',
name: 'Pool',
color: '#06B6D4',
+ translationKey: null,
sortOrder: 3,
createdAt: '2026-01-01T00:00:00.000Z',
updatedAt: '2026-01-01T00:00:00.000Z',
@@ -213,6 +218,7 @@ describe('householdItemCategoriesApi', () => {
id: 'hic-1',
name: 'Updated Furniture',
color: '#FF0000',
+ translationKey: null,
sortOrder: 0,
createdAt: '2026-01-01T00:00:00.000Z',
updatedAt: '2026-01-02T00:00:00.000Z',
@@ -240,6 +246,7 @@ describe('householdItemCategoriesApi', () => {
id: 'hic-1',
name: 'New Name',
color: '#00FF00',
+ translationKey: null,
sortOrder: 10,
createdAt: '2026-01-01T00:00:00.000Z',
updatedAt: '2026-01-02T00:00:00.000Z',
@@ -265,6 +272,7 @@ describe('householdItemCategoriesApi', () => {
id: 'hic-1',
name: 'Furniture',
color: '#AABBCC',
+ translationKey: null,
sortOrder: 0,
createdAt: '2026-01-01T00:00:00.000Z',
updatedAt: '2026-01-02T00:00:00.000Z',
diff --git a/client/src/lib/householdItemsApi.ts b/client/src/lib/householdItemsApi.ts
index af6bb099b..cfb91b016 100644
--- a/client/src/lib/householdItemsApi.ts
+++ b/client/src/lib/householdItemsApi.ts
@@ -34,12 +34,27 @@ export function listHouseholdItems(
if (params?.vendorId) {
queryParams.set('vendorId', params.vendorId);
}
- if (params?.noBudget) {
- queryParams.set('noBudget', 'true');
- }
if (params?.areaId) {
queryParams.set('areaId', params.areaId);
}
+ if (params?.plannedCostMin !== undefined) {
+ queryParams.set('plannedCostMin', params.plannedCostMin.toString());
+ }
+ if (params?.plannedCostMax !== undefined) {
+ queryParams.set('plannedCostMax', params.plannedCostMax.toString());
+ }
+ if (params?.actualCostMin !== undefined) {
+ queryParams.set('actualCostMin', params.actualCostMin.toString());
+ }
+ if (params?.actualCostMax !== undefined) {
+ queryParams.set('actualCostMax', params.actualCostMax.toString());
+ }
+ if (params?.budgetLinesMin !== undefined) {
+ queryParams.set('budgetLinesMin', params.budgetLinesMin.toString());
+ }
+ if (params?.budgetLinesMax !== undefined) {
+ queryParams.set('budgetLinesMax', params.budgetLinesMax.toString());
+ }
if (params?.sortBy) {
queryParams.set('sortBy', params.sortBy);
}
diff --git a/client/src/lib/invoiceBudgetLinesApi.test.ts b/client/src/lib/invoiceBudgetLinesApi.test.ts
index b50762875..0f20d50c2 100644
--- a/client/src/lib/invoiceBudgetLinesApi.test.ts
+++ b/client/src/lib/invoiceBudgetLinesApi.test.ts
@@ -37,6 +37,7 @@ describe('invoiceBudgetLinesApi', () => {
categoryId: 'bc-construction',
categoryName: 'Construction',
categoryColor: '#ff0000',
+ categoryTranslationKey: null,
parentItemId: 'wi-001',
parentItemTitle: 'Foundation',
parentItemType: 'work_item',
diff --git a/client/src/lib/invoicesApi.ts b/client/src/lib/invoicesApi.ts
index 567fd6266..79099ce01 100644
--- a/client/src/lib/invoicesApi.ts
+++ b/client/src/lib/invoicesApi.ts
@@ -50,6 +50,9 @@ export function fetchAllInvoices(params?: {
q?: string;
status?: 'pending' | 'paid' | 'claimed' | 'quotation';
vendorId?: string;
+ amount?: string;
+ date?: string;
+ dueDate?: string;
sortBy?: string;
sortOrder?: 'asc' | 'desc';
}): Promise {
@@ -59,6 +62,9 @@ export function fetchAllInvoices(params?: {
if (params?.q) queryParams.set('q', params.q);
if (params?.status) queryParams.set('status', params.status);
if (params?.vendorId) queryParams.set('vendorId', params.vendorId);
+ if (params?.amount) queryParams.set('amount', params.amount);
+ if (params?.date) queryParams.set('date', params.date);
+ if (params?.dueDate) queryParams.set('dueDate', params.dueDate);
if (params?.sortBy) queryParams.set('sortBy', params.sortBy);
if (params?.sortOrder) queryParams.set('sortOrder', params.sortOrder);
const queryString = queryParams.toString();
diff --git a/client/src/lib/vendorsApi.test.ts b/client/src/lib/vendorsApi.test.ts
index b5a1b4e8a..8254b9af8 100644
--- a/client/src/lib/vendorsApi.test.ts
+++ b/client/src/lib/vendorsApi.test.ts
@@ -25,7 +25,7 @@ describe('vendorsApi', () => {
const sampleVendor: Vendor = {
id: 'vendor-1',
name: 'Smith Plumbing',
- trade: { id: 'trade-1', name: 'Plumbing', color: null },
+ trade: { id: 'trade-1', name: 'Plumbing', color: null, translationKey: null },
phone: '+1 555-1234',
email: 'smith@plumbing.com',
address: '123 Main St',
@@ -392,7 +392,7 @@ describe('vendorsApi', () => {
const updated: VendorDetail = {
...sampleVendorDetail,
name: 'Updated Vendor',
- trade: { id: 'trade-landscaping', name: 'Landscaping', color: null },
+ trade: { id: 'trade-landscaping', name: 'Landscaping', color: null, translationKey: null },
};
mockFetch.mockResolvedValueOnce({
@@ -410,7 +410,7 @@ describe('vendorsApi', () => {
it('handles partial update (only tradeId)', async () => {
const updated: VendorDetail = {
...sampleVendorDetail,
- trade: { id: 'trade-new', name: 'New Trade', color: null },
+ trade: { id: 'trade-new', name: 'New Trade', color: null, translationKey: null },
};
mockFetch.mockResolvedValueOnce({
diff --git a/client/src/lib/workItemsApi.ts b/client/src/lib/workItemsApi.ts
index 8b2135ef4..e9f87ac91 100644
--- a/client/src/lib/workItemsApi.ts
+++ b/client/src/lib/workItemsApi.ts
@@ -36,15 +36,18 @@ export function listWorkItems(params?: WorkItemListQuery): Promise();
+jest.unstable_mockModule('../../contexts/AuthContext.js', () => ({
+ useAuth: mockUseAuth,
+ AuthProvider: ({ children }: { children: ReactNode }) => children,
+}));
+
+const mockListBackups = jest.fn();
+const mockCreateBackup = jest.fn();
+const mockDeleteBackup = jest.fn();
+const mockRestoreBackup = jest.fn();
+
+jest.unstable_mockModule('../../lib/backupsApi.js', () => ({
+ listBackups: mockListBackups,
+ createBackup: mockCreateBackup,
+ deleteBackup: mockDeleteBackup,
+ restoreBackup: mockRestoreBackup,
+}));
+
+// Mock formatters to provide stable date formatting in tests
+jest.unstable_mockModule('../../lib/formatters.js', () => {
+ const fmtDate = (d: string | null | undefined, fallback = '—') => {
+ if (!d) return fallback;
+ const dt = new Date(d);
+ return dt.toLocaleDateString('en-US', {
+ year: 'numeric',
+ month: 'short',
+ day: 'numeric',
+ });
+ };
+ return {
+ formatDate: fmtDate,
+ formatCurrency: (n: number) => `€${n.toFixed(2)}`,
+ formatTime: (ts: string | null | undefined) => ts ?? '—',
+ formatDateTime: (ts: string | null | undefined) => ts ?? '—',
+ formatPercent: (n: number) => `${n.toFixed(2)}%`,
+ computeActualDuration: () => null,
+ useFormatters: () => ({
+ formatDate: fmtDate,
+ formatCurrency: (n: number) => `€${n.toFixed(2)}`,
+ formatTime: (ts: string | null | undefined) => ts ?? '—',
+ formatDateTime: (ts: string | null | undefined) => ts ?? '—',
+ formatPercent: (n: number) => `${n.toFixed(2)}%`,
+ }),
+ };
+});
+
+// ─── Fixtures ─────────────────────────────────────────────────────────────────
+
+const backup1 = {
+ filename: 'cornerstone-backup-2026-03-22T020000Z.tar.gz',
+ createdAt: '2026-03-22T02:00:00.000Z',
+ sizeBytes: 102400,
+};
+
+const backup2 = {
+ filename: 'cornerstone-backup-2026-01-01T000000Z.tar.gz',
+ createdAt: '2026-01-01T00:00:00.000Z',
+ sizeBytes: 81920,
+};
+
+const makeNotConfiguredError = () =>
+ new ApiClientError(503, { code: 'BACKUP_NOT_CONFIGURED', message: 'Backup is not configured' });
+
+// ─── Test suite ───────────────────────────────────────────────────────────────
+
+describe('BackupsPage', () => {
+ let BackupsPage: React.ComponentType;
+
+ beforeEach(async () => {
+ if (!BackupsPage) {
+ const module = await import('./BackupsPage.js');
+ BackupsPage = module.BackupsPage;
+ }
+
+ // Reset all mocks
+ mockListBackups.mockReset();
+ mockCreateBackup.mockReset();
+ mockDeleteBackup.mockReset();
+ mockRestoreBackup.mockReset();
+ mockUseAuth.mockReset();
+
+ // Default: admin user so all settings tabs are visible
+ mockUseAuth.mockReturnValue({
+ user: {
+ id: 'user-admin',
+ email: 'admin@example.com',
+ displayName: 'Admin',
+ role: 'admin' as const,
+ authProvider: 'local' as const,
+ createdAt: '2024-01-01T00:00:00.000Z',
+ updatedAt: '2024-01-01T00:00:00.000Z',
+ deactivatedAt: null,
+ },
+ oidcEnabled: false,
+ isLoading: false,
+ error: null,
+ refreshAuth: jest.fn(async () => Promise.resolve()),
+ logout: jest.fn(async () => Promise.resolve()),
+ });
+ });
+
+ function renderPage() {
+ return render(
+
+
+ ,
+ );
+ }
+
+ // ─── 503 Not Configured ──────────────────────────────────────────────────
+
+ describe('when listBackups returns 503 BACKUP_NOT_CONFIGURED', () => {
+ it('renders the not-configured EmptyState and no Create Backup button', async () => {
+ mockListBackups.mockRejectedValueOnce(makeNotConfiguredError());
+
+ renderPage();
+
+ // Wait for loading to complete
+ await waitFor(() => {
+ expect(screen.queryByRole('status')).not.toBeInTheDocument();
+ });
+
+ // Should show not-configured message
+ expect(screen.getByText(/backup is not configured/i)).toBeInTheDocument();
+
+ // Should NOT show the Create Backup button
+ expect(screen.queryByRole('button', { name: /create backup/i })).not.toBeInTheDocument();
+ });
+
+ it('shows the BACKUP_DIR configuration description', async () => {
+ mockListBackups.mockRejectedValueOnce(makeNotConfiguredError());
+
+ renderPage();
+
+ await waitFor(() => {
+ expect(screen.queryByRole('status')).not.toBeInTheDocument();
+ });
+
+ // Description text from settings.json
+ expect(screen.getByText(/set the BACKUP_DIR environment variable/i)).toBeInTheDocument();
+ });
+ });
+
+ // ─── Loading state ────────────────────────────────────────────────────────
+
+ describe('while loading', () => {
+ it('renders a Skeleton loading indicator', () => {
+ // Never resolves — stays in loading state
+ mockListBackups.mockReturnValueOnce(new Promise(() => {}));
+
+ renderPage();
+
+ // Skeleton renders with role="status"
+ expect(screen.getByRole('status')).toBeInTheDocument();
+ });
+
+ it('does not show the Create Backup button while loading', () => {
+ mockListBackups.mockReturnValueOnce(new Promise(() => {}));
+
+ renderPage();
+
+ expect(screen.queryByRole('button', { name: /create backup/i })).not.toBeInTheDocument();
+ });
+ });
+
+ // ─── Empty state (configured, no backups) ────────────────────────────────
+
+ describe('when listBackups returns empty array', () => {
+ it('renders the configured empty state message', async () => {
+ mockListBackups.mockResolvedValueOnce({ backups: [] } as BackupListResponse);
+
+ renderPage();
+
+ await waitFor(() => {
+ expect(screen.getByText(/no backups yet/i)).toBeInTheDocument();
+ });
+ });
+
+ it('renders the Create Backup button', async () => {
+ mockListBackups.mockResolvedValueOnce({ backups: [] } as BackupListResponse);
+
+ renderPage();
+
+ await waitFor(() => {
+ expect(screen.getByRole('button', { name: /create backup/i })).toBeInTheDocument();
+ });
+ });
+ });
+
+ // ─── Populated table ─────────────────────────────────────────────────────
+
+ describe('when listBackups returns 2 backups', () => {
+ it('renders a table with 2 rows', async () => {
+ mockListBackups.mockResolvedValueOnce({
+ backups: [backup1, backup2],
+ } as BackupListResponse);
+
+ renderPage();
+
+ await waitFor(() => {
+ // Find all rows in the table body — one per backup
+ const rows = screen.getAllByRole('row');
+ // 1 header row + 2 data rows
+ expect(rows).toHaveLength(3);
+ });
+ });
+
+ it('renders filenames in the table', async () => {
+ mockListBackups.mockResolvedValueOnce({
+ backups: [backup1, backup2],
+ } as BackupListResponse);
+
+ renderPage();
+
+ await waitFor(() => {
+ expect(screen.getByText(backup1.filename)).toBeInTheDocument();
+ expect(screen.getByText(backup2.filename)).toBeInTheDocument();
+ });
+ });
+
+ it('renders Restore and Delete buttons for each backup', async () => {
+ mockListBackups.mockResolvedValueOnce({
+ backups: [backup1, backup2],
+ } as BackupListResponse);
+
+ renderPage();
+
+ await waitFor(() => {
+ const restoreButtons = screen.getAllByRole('button', { name: /restore/i });
+ const deleteButtons = screen.getAllByRole('button', { name: /delete/i });
+ expect(restoreButtons).toHaveLength(2);
+ expect(deleteButtons).toHaveLength(2);
+ });
+ });
+ });
+
+ // ─── Create Backup ────────────────────────────────────────────────────────
+
+ describe('Create Backup button', () => {
+ it('calls createBackup() when clicked', async () => {
+ const user = userEvent.setup();
+ mockListBackups.mockResolvedValueOnce({ backups: [] } as BackupListResponse);
+ mockCreateBackup.mockResolvedValueOnce({
+ backup: backup1,
+ } as BackupResponse);
+
+ renderPage();
+
+ await waitFor(() => {
+ expect(screen.getByRole('button', { name: /create backup/i })).toBeInTheDocument();
+ });
+
+ await user.click(screen.getByRole('button', { name: /create backup/i }));
+
+ expect(mockCreateBackup).toHaveBeenCalledTimes(1);
+ });
+
+ it('shows creating state ("Creating backup...") while the request is in progress', async () => {
+ const user = userEvent.setup();
+ // Mock list to show configured empty state
+ mockListBackups.mockResolvedValueOnce({ backups: [] } as BackupListResponse);
+ // Create backup never resolves — stays in creating state
+ mockCreateBackup.mockReturnValueOnce(new Promise(() => {}));
+
+ renderPage();
+
+ await waitFor(() => {
+ expect(screen.getByRole('button', { name: /create backup/i })).toBeInTheDocument();
+ });
+
+ await user.click(screen.getByRole('button', { name: /create backup/i }));
+
+ // Button should now show "Creating backup..."
+ expect(screen.getByRole('button', { name: /creating backup/i })).toBeInTheDocument();
+ });
+
+ it('adds the new backup to the list after successful creation', async () => {
+ const user = userEvent.setup();
+ mockListBackups.mockResolvedValueOnce({ backups: [] } as BackupListResponse);
+ mockCreateBackup.mockResolvedValueOnce({
+ backup: backup1,
+ } as BackupResponse);
+
+ renderPage();
+
+ await waitFor(() => {
+ expect(screen.getByRole('button', { name: /create backup/i })).toBeInTheDocument();
+ });
+
+ await user.click(screen.getByRole('button', { name: /create backup/i }));
+
+ await waitFor(() => {
+ expect(screen.getByText(backup1.filename)).toBeInTheDocument();
+ });
+ });
+
+ it('resets to enabled state after a failed createBackup() call', async () => {
+ const user = userEvent.setup();
+ mockListBackups.mockResolvedValueOnce({ backups: [] } as BackupListResponse);
+ mockCreateBackup.mockRejectedValueOnce(
+ new ApiClientError(503, {
+ code: 'BACKUP_NOT_CONFIGURED',
+ message: 'Backup is not configured',
+ }),
+ );
+
+ renderPage();
+
+ await waitFor(() => {
+ expect(screen.getByRole('button', { name: /create backup/i })).toBeInTheDocument();
+ });
+
+ await user.click(screen.getByRole('button', { name: /create backup/i }));
+
+ // After failed create, button should be re-enabled (not stuck in "creating" state)
+ await waitFor(() => {
+ expect(screen.getByRole('button', { name: /create backup/i })).not.toBeDisabled();
+ });
+
+ // Error message should be displayed to the user
+ expect(screen.getByRole('alert')).toBeInTheDocument();
+ });
+ });
+
+ // ─── Delete modal ─────────────────────────────────────────────────────────
+
+ describe('Delete modal', () => {
+ it('opens delete modal with correct filename when Delete button is clicked', async () => {
+ const user = userEvent.setup();
+ mockListBackups.mockResolvedValueOnce({
+ backups: [backup1],
+ } as BackupListResponse);
+
+ renderPage();
+
+ await waitFor(() => {
+ // The filename appears in the table
+ expect(screen.getAllByText(backup1.filename).length).toBeGreaterThan(0);
+ });
+
+ // Click the Delete button for backup1
+ const deleteButton = screen.getByRole('button', { name: /delete/i });
+ await user.click(deleteButton);
+
+ // Modal should appear — the filename now appears in both table and modal
+ await waitFor(() => {
+ expect(screen.getByText(/delete backup/i)).toBeInTheDocument();
+ // Filename appears in table row + modal tag = 2 elements
+ const filenameElements = screen.getAllByText(backup1.filename);
+ expect(filenameElements.length).toBeGreaterThanOrEqual(2);
+ });
+ });
+
+ it('calls deleteBackup() with the filename when confirm button is clicked', async () => {
+ const user = userEvent.setup();
+ mockListBackups.mockResolvedValueOnce({
+ backups: [backup1],
+ } as BackupListResponse);
+ mockDeleteBackup.mockResolvedValueOnce(undefined as void);
+
+ renderPage();
+
+ await waitFor(() => {
+ expect(screen.getByText(backup1.filename)).toBeInTheDocument();
+ });
+
+ // Open delete modal
+ await user.click(screen.getByRole('button', { name: /delete/i }));
+
+ // Wait for modal
+ await waitFor(() => {
+ expect(screen.getByText(/delete backup/i)).toBeInTheDocument();
+ });
+
+ // Click confirm button in the modal footer (role button with "Delete" text, not the row button)
+ const confirmButtons = screen.getAllByRole('button', { name: /^delete$/i });
+ // The last Delete button should be in the modal confirm footer
+ const confirmButton = confirmButtons[confirmButtons.length - 1];
+ await user.click(confirmButton);
+
+ expect(mockDeleteBackup).toHaveBeenCalledWith(backup1.filename);
+ });
+
+ it('closes the modal after successful deletion', async () => {
+ const user = userEvent.setup();
+ mockListBackups.mockResolvedValueOnce({
+ backups: [backup1],
+ } as BackupListResponse);
+ mockDeleteBackup.mockResolvedValueOnce(undefined as void);
+
+ renderPage();
+
+ await waitFor(() => {
+ expect(screen.getByText(backup1.filename)).toBeInTheDocument();
+ });
+
+ // Open modal
+ await user.click(screen.getByRole('button', { name: /delete/i }));
+ await waitFor(() => {
+ expect(screen.getByText(/delete backup/i)).toBeInTheDocument();
+ });
+
+ // Confirm delete
+ const confirmButtons = screen.getAllByRole('button', { name: /^delete$/i });
+ await user.click(confirmButtons[confirmButtons.length - 1]);
+
+ // Modal should be closed
+ await waitFor(() => {
+ expect(screen.queryByText(/delete backup/i)).not.toBeInTheDocument();
+ });
+ });
+ });
+
+ // ─── Restore modal ────────────────────────────────────────────────────────
+
+ describe('Restore modal', () => {
+ it('opens restore modal with the filename when Restore button is clicked', async () => {
+ const user = userEvent.setup();
+ mockListBackups.mockResolvedValueOnce({
+ backups: [backup1],
+ } as BackupListResponse);
+
+ renderPage();
+
+ await waitFor(() => {
+ expect(screen.getByText(backup1.filename)).toBeInTheDocument();
+ });
+
+ // Click Restore
+ await user.click(screen.getByRole('button', { name: /restore/i }));
+
+ await waitFor(() => {
+ expect(screen.getByText(/restore backup/i)).toBeInTheDocument();
+ // The filename should appear in the modal
+ const filenameElements = screen.getAllByText(backup1.filename);
+ expect(filenameElements.length).toBeGreaterThan(0);
+ });
+ });
+
+ it('shows warning text in the restore modal', async () => {
+ const user = userEvent.setup();
+ mockListBackups.mockResolvedValueOnce({
+ backups: [backup1],
+ } as BackupListResponse);
+
+ renderPage();
+
+ await waitFor(() => {
+ expect(screen.getByText(backup1.filename)).toBeInTheDocument();
+ });
+
+ await user.click(screen.getByRole('button', { name: /restore/i }));
+
+ await waitFor(() => {
+ // Warning text from settings.json restoreModal.warning
+ expect(
+ screen.getByText(/permanently replace all current application data/i),
+ ).toBeInTheDocument();
+ });
+ });
+
+ it('calls restoreBackup() with the filename when confirm button is clicked', async () => {
+ const user = userEvent.setup();
+ mockListBackups.mockResolvedValueOnce({
+ backups: [backup1],
+ } as BackupListResponse);
+ mockRestoreBackup.mockResolvedValueOnce({
+ message: 'Restore initiated. Server is restarting.',
+ } as RestoreInitiatedResponse);
+
+ renderPage();
+
+ await waitFor(() => {
+ expect(screen.getByText(backup1.filename)).toBeInTheDocument();
+ });
+
+ // Open restore modal
+ await user.click(screen.getByRole('button', { name: /restore/i }));
+
+ await waitFor(() => {
+ expect(screen.getByText(/restore backup/i)).toBeInTheDocument();
+ });
+
+ // Click "Restore & Restart" confirm button
+ await user.click(screen.getByRole('button', { name: /restore & restart/i }));
+
+ expect(mockRestoreBackup).toHaveBeenCalledWith(backup1.filename);
+ });
+
+ it('shows restarting message after successful restore initiation', async () => {
+ const user = userEvent.setup();
+ mockListBackups.mockResolvedValueOnce({
+ backups: [backup1],
+ } as BackupListResponse);
+ mockRestoreBackup.mockResolvedValueOnce({
+ message: 'Restore initiated. Server is restarting.',
+ } as RestoreInitiatedResponse);
+
+ renderPage();
+
+ await waitFor(() => {
+ expect(screen.getByText(backup1.filename)).toBeInTheDocument();
+ });
+
+ await user.click(screen.getByRole('button', { name: /restore/i }));
+
+ await waitFor(() => {
+ expect(screen.getByText(/restore backup/i)).toBeInTheDocument();
+ });
+
+ await user.click(screen.getByRole('button', { name: /restore & restart/i }));
+
+ // Should now show the restarting message
+ await waitFor(() => {
+ expect(screen.getByText(/server is restarting/i)).toBeInTheDocument();
+ });
+ });
+ });
+});
diff --git a/client/src/pages/BackupsPage/BackupsPage.tsx b/client/src/pages/BackupsPage/BackupsPage.tsx
new file mode 100644
index 000000000..175ae5a7c
--- /dev/null
+++ b/client/src/pages/BackupsPage/BackupsPage.tsx
@@ -0,0 +1,370 @@
+import { useState, useEffect } from 'react';
+import { useTranslation } from 'react-i18next';
+import type { BackupMeta } from '@cornerstone/shared';
+import { useAuth } from '../../contexts/AuthContext.js';
+import { PageLayout } from '../../components/PageLayout/PageLayout.js';
+import { SubNav, type SubNavTab } from '../../components/SubNav/SubNav.js';
+import { Modal } from '../../components/Modal/Modal.js';
+import { EmptyState } from '../../components/EmptyState/EmptyState.js';
+import { Skeleton } from '../../components/Skeleton/Skeleton.js';
+import { ApiClientError } from '../../lib/apiClient.js';
+import { useFormatters } from '../../lib/formatters.js';
+import { listBackups, createBackup, deleteBackup, restoreBackup } from '../../lib/backupsApi.js';
+import sharedStyles from '../../styles/shared.module.css';
+import styles from './BackupsPage.module.css';
+
+/**
+ * Format a file size in bytes to a human-readable string.
+ */
+function formatFileSize(bytes: number): string {
+ if (bytes < 1024) return `${bytes} B`;
+ if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
+ return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
+}
+
+export function BackupsPage() {
+ const { t } = useTranslation('settings');
+ const { formatDate } = useFormatters();
+ const { user } = useAuth();
+
+ const isAdmin = user?.role === 'admin';
+
+ const settingsTabs: SubNavTab[] = [
+ { labelKey: 'subnav.settings.profile', to: '/settings/profile', ns: 'common' },
+ { labelKey: 'subnav.settings.manage', to: '/settings/manage', ns: 'common' },
+ {
+ labelKey: 'subnav.settings.userManagement',
+ to: '/settings/users',
+ ns: 'common',
+ visible: isAdmin,
+ },
+ {
+ labelKey: 'subnav.settings.backups',
+ to: '/settings/backups',
+ ns: 'common',
+ visible: isAdmin,
+ },
+ ];
+
+ // Data state
+ const [backups, setBackups] = useState([]);
+ const [isLoading, setIsLoading] = useState(true);
+ const [isNotConfigured, setIsNotConfigured] = useState(false);
+ const [loadError, setLoadError] = useState('');
+
+ // Create backup state
+ const [isCreating, setIsCreating] = useState(false);
+ const [createError, setCreateError] = useState('');
+
+ // Delete modal state
+ const [deleteTarget, setDeleteTarget] = useState(null);
+ const [isDeleting, setIsDeleting] = useState(false);
+ const [deleteError, setDeleteError] = useState('');
+
+ // Restore modal state
+ const [restoreTarget, setRestoreTarget] = useState(null);
+ const [isRestoring, setIsRestoring] = useState(false);
+ const [restoreInitiated, setRestoreInitiated] = useState(false);
+ const [restoreError, setRestoreError] = useState('');
+
+ // Load backups on mount
+ useEffect(() => {
+ const loadBackupsData = async () => {
+ setIsLoading(true);
+ setLoadError('');
+ setIsNotConfigured(false);
+
+ try {
+ const response = await listBackups();
+ setBackups(response.backups);
+ } catch (err) {
+ if (err instanceof ApiClientError) {
+ if (err.statusCode === 503 && err.error.code === 'BACKUP_NOT_CONFIGURED') {
+ setIsNotConfigured(true);
+ } else {
+ setLoadError(err.error.message);
+ }
+ } else {
+ setLoadError(t('backups.loadError'));
+ }
+ } finally {
+ setIsLoading(false);
+ }
+ };
+
+ void loadBackupsData();
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, [t]);
+
+ const handleCreateBackup = async () => {
+ setIsCreating(true);
+ setCreateError('');
+
+ try {
+ const response = await createBackup();
+ setBackups([response.backup, ...backups]);
+ } catch (err) {
+ if (err instanceof ApiClientError) {
+ setCreateError(err.error.message);
+ } else {
+ setCreateError(t('backups.createError'));
+ }
+ } finally {
+ setIsCreating(false);
+ }
+ };
+
+ const handleDeleteConfirm = async () => {
+ if (!deleteTarget) return;
+
+ setIsDeleting(true);
+ setDeleteError('');
+
+ try {
+ await deleteBackup(deleteTarget.filename);
+ setBackups(backups.filter((b) => b.filename !== deleteTarget.filename));
+ setDeleteTarget(null);
+ } catch (err) {
+ if (err instanceof ApiClientError) {
+ setDeleteError(err.error.message);
+ } else {
+ setDeleteError(t('backups.deleteModal.error'));
+ }
+ } finally {
+ setIsDeleting(false);
+ }
+ };
+
+ const handleRestoreConfirm = async () => {
+ if (!restoreTarget) return;
+
+ setIsRestoring(true);
+ setRestoreError('');
+
+ try {
+ await restoreBackup(restoreTarget.filename);
+ setRestoreInitiated(true);
+ setRestoreTarget(null);
+ } catch (err) {
+ if (err instanceof ApiClientError) {
+ setRestoreError(err.error.message);
+ } else {
+ setRestoreError(t('backups.restoreModal.error'));
+ }
+ } finally {
+ setIsRestoring(false);
+ }
+ };
+
+ // If restore has been initiated, show the restarting message
+ if (restoreInitiated) {
+ return (
+ }
+ >
+
+
+ );
+ }
+
+ // If backup is not configured, show informational empty state
+ if (isNotConfigured && !isLoading) {
+ return (
+ }
+ >
+
+
+ );
+ }
+
+ return (
+ }
+ >
+ {/* Loading state */}
+ {isLoading && }
+
+ {/* Error state */}
+ {!isLoading && loadError && (
+
+ {loadError}
+
+ )}
+
+ {/* Backups content */}
+ {!isLoading && !isNotConfigured && (
+ <>
+
+
+ {isCreating ? t('backups.creating') : t('backups.createButton')}
+
+
+
+ {createError && (
+
+ {createError}
+
+ )}
+
+ {backups.length === 0 ? (
+
+ ) : (
+
+
+
+
+ {t('backups.tableHeaders.filename')}
+ {t('backups.tableHeaders.createdAt')}
+ {t('backups.tableHeaders.size')}
+ {t('backups.tableHeaders.actions')}
+
+
+
+ {backups.map((backup) => (
+
+ {backup.filename}
+ {formatDate(backup.createdAt)}
+ {formatFileSize(backup.sizeBytes)}
+
+
+ setRestoreTarget(backup)}
+ aria-label={t('backups.actions.restore')}
+ >
+ {t('backups.actions.restore')}
+
+ setDeleteTarget(backup)}
+ aria-label={t('backups.actions.delete')}
+ >
+ {t('backups.actions.delete')}
+
+
+
+
+ ))}
+
+
+
+ )}
+ >
+ )}
+
+ {/* Delete Confirmation Modal */}
+ {deleteTarget && (
+ !isDeleting && setDeleteTarget(null)}
+ footer={
+ <>
+ setDeleteTarget(null)}
+ disabled={isDeleting}
+ >
+ {t('backups.deleteModal.cancel')}
+
+
+ {isDeleting
+ ? t('backups.deleteModal.confirming')
+ : t('backups.deleteModal.confirm')}
+
+ >
+ }
+ >
+ {deleteError && (
+
+ {deleteError}
+
+ )}
+
+ {(() => {
+ const parts = t('backups.deleteModal.message', {
+ filename: '\u0000',
+ }).split('\u0000');
+ return (
+ <>
+ {parts[0]}
+ {deleteTarget.filename}
+ {parts[1]}
+ >
+ );
+ })()}
+
+ {t('backups.deleteModal.warning')}
+
+ )}
+
+ {/* Restore Confirmation Modal */}
+ {restoreTarget && (
+ !isRestoring && setRestoreTarget(null)}
+ footer={
+ <>
+ setRestoreTarget(null)}
+ disabled={isRestoring}
+ >
+ {t('backups.restoreModal.cancel')}
+
+
+ {isRestoring
+ ? t('backups.restoreModal.confirming')
+ : t('backups.restoreModal.confirm')}
+
+ >
+ }
+ >
+ {restoreError && (
+
+ {restoreError}
+
+ )}
+ {t('backups.restoreModal.message')}
+ {restoreTarget.filename}
+ {t('backups.restoreModal.warning')}
+
+ )}
+
+ );
+}
+
+export default BackupsPage;
diff --git a/client/src/pages/BudgetOverviewPage/BudgetOverviewPage.module.css b/client/src/pages/BudgetOverviewPage/BudgetOverviewPage.module.css
index fecbf4cd1..6889764c1 100644
--- a/client/src/pages/BudgetOverviewPage/BudgetOverviewPage.module.css
+++ b/client/src/pages/BudgetOverviewPage/BudgetOverviewPage.module.css
@@ -2,34 +2,6 @@
* BudgetOverviewPage — CSS Module
* ============================================================ */
-.container {
- padding: var(--spacing-8);
- max-width: 1200px;
- margin: 0 auto;
-}
-
-.content {
- display: flex;
- flex-direction: column;
- gap: var(--spacing-6);
-}
-
-/* ---- Page header ---- */
-
-.pageHeader {
- display: flex;
- align-items: center;
- justify-content: space-between;
- gap: var(--spacing-4);
-}
-
-.pageTitle {
- font-size: var(--font-size-4xl);
- font-weight: var(--font-weight-bold);
- color: var(--color-text-primary);
- margin: 0;
-}
-
/* ---- Add dropdown button ---- */
.addContainer {
diff --git a/client/src/pages/BudgetOverviewPage/BudgetOverviewPage.test.tsx b/client/src/pages/BudgetOverviewPage/BudgetOverviewPage.test.tsx
index 8745f3682..0cb63fd4f 100644
--- a/client/src/pages/BudgetOverviewPage/BudgetOverviewPage.test.tsx
+++ b/client/src/pages/BudgetOverviewPage/BudgetOverviewPage.test.tsx
@@ -143,6 +143,7 @@ describe('BudgetOverviewPage', () => {
categoryId: 'cat-1',
categoryName: 'Materials',
categoryColor: '#FF5733',
+ categoryTranslationKey: null,
minPlanned: 64000,
maxPlanned: 96000,
actualCost: 70000,
@@ -154,6 +155,7 @@ describe('BudgetOverviewPage', () => {
categoryId: 'cat-2',
categoryName: 'Labor',
categoryColor: null,
+ categoryTranslationKey: null,
minPlanned: 56000,
maxPlanned: 84000,
actualCost: 50000,
@@ -688,6 +690,7 @@ describe('BudgetOverviewPage', () => {
categoryId: 'cat-3',
categoryName: 'Permits',
categoryColor: null,
+ categoryTranslationKey: null,
minPlanned: 0,
maxPlanned: 0,
actualCost: 0,
@@ -699,6 +702,7 @@ describe('BudgetOverviewPage', () => {
categoryId: 'cat-4',
categoryName: 'Design',
categoryColor: null,
+ categoryTranslationKey: null,
minPlanned: 0,
maxPlanned: 0,
actualCost: 0,
@@ -877,6 +881,7 @@ describe('BudgetOverviewPage', () => {
categoryId: 'cat-1',
categoryName: 'Materials',
categoryColor: '#FF5733',
+ categoryTranslationKey: null,
projectedMin: 72000,
projectedMax: 88000,
actualCost: 70000,
@@ -890,6 +895,7 @@ describe('BudgetOverviewPage', () => {
categoryId: 'cat-2',
categoryName: 'Labor',
categoryColor: null,
+ categoryTranslationKey: null,
projectedMin: 68000,
projectedMax: 72000,
actualCost: 50000,
@@ -1184,6 +1190,7 @@ describe('BudgetOverviewPage', () => {
categoryId: 'cat-1',
categoryName: 'Materials',
categoryColor: null,
+ categoryTranslationKey: null,
projectedMin: 5000,
projectedMax: 8000,
actualCost: 0,
diff --git a/client/src/pages/BudgetOverviewPage/BudgetOverviewPage.tsx b/client/src/pages/BudgetOverviewPage/BudgetOverviewPage.tsx
index a04efe92a..7849c8572 100644
--- a/client/src/pages/BudgetOverviewPage/BudgetOverviewPage.tsx
+++ b/client/src/pages/BudgetOverviewPage/BudgetOverviewPage.tsx
@@ -11,13 +11,23 @@ import { fetchBudgetOverview, fetchBudgetBreakdown } from '../../lib/budgetOverv
import { fetchBudgetSources } from '../../lib/budgetSourcesApi.js';
import { ApiClientError } from '../../lib/apiClient.js';
import { useFormatters } from '../../lib/formatters.js';
-import { BudgetSubNav } from '../../components/BudgetSubNav/BudgetSubNav.js';
+import { getCategoryDisplayName } from '../../lib/categoryUtils.js';
+import { PageLayout } from '../../components/PageLayout/PageLayout.js';
+import { SubNav, type SubNavTab } from '../../components/SubNav/SubNav.js';
import { BudgetBar } from '../../components/BudgetBar/BudgetBar.js';
import type { BudgetBarSegment } from '../../components/BudgetBar/BudgetBar.js';
import { Tooltip } from '../../components/Tooltip/Tooltip.js';
import { CostBreakdownTable } from '../../components/CostBreakdownTable/CostBreakdownTable.js';
import styles from './BudgetOverviewPage.module.css';
+const BUDGET_TABS: SubNavTab[] = [
+ { labelKey: 'subnav.budget.overview', to: '/budget/overview' },
+ { labelKey: 'subnav.budget.invoices', to: '/budget/invoices' },
+ { labelKey: 'subnav.budget.vendors', to: '/budget/vendors' },
+ { labelKey: 'subnav.budget.sources', to: '/budget/sources' },
+ { labelKey: 'subnav.budget.subsidies', to: '/budget/subsidies' },
+];
+
/** Stable empty set passed to CostBreakdownTable so it always shows all categories. */
const emptyCategories = new Set();
@@ -39,7 +49,8 @@ interface CategoryFilterProps {
}
function CategoryFilter({ categories, selectedIds, onChange }: CategoryFilterProps) {
- const { t } = useTranslation('budget');
+ const { t: tBudget } = useTranslation('budget');
+ const { t: tSettings } = useTranslation('settings');
const [open, setOpen] = useState(false);
const containerRef = useRef(null);
const allSelected = selectedIds.size === categories.length;
@@ -87,13 +98,13 @@ function CategoryFilter({ categories, selectedIds, onChange }: CategoryFilterPro
// Button label
let label: string;
if (allSelected) {
- label = t('overview.allCategories');
+ label = tBudget('overview.allCategories');
} else if (selectedIds.size === 0) {
- label = t('overview.noCategories');
+ label = tBudget('overview.noCategories');
} else if (selectedIds.size <= 2) {
label = categories
.filter((c) => selectedIds.has(c.categoryId))
- .map((c) => c.categoryName)
+ .map((c) => getCategoryDisplayName(tSettings, c.categoryName, c.categoryTranslationKey))
.join(', ');
} else {
label = `${selectedIds.size} of ${categories.length} categories`;
@@ -109,7 +120,7 @@ function CategoryFilter({ categories, selectedIds, onChange }: CategoryFilterPro
onClick={() => setOpen((v) => !v)}
>
- {t('overview.categories')}: {label}
+ {tBudget('overview.categories')}: {label}
{open ? '▲' : '▼'}
@@ -121,10 +132,10 @@ function CategoryFilter({ categories, selectedIds, onChange }: CategoryFilterPro
{/* Select All / Clear All */}
- {t('overview.selectAll')}
+ {tBudget('overview.selectAll')}
- {t('overview.clearAll')}
+ {tBudget('overview.clearAll')}
@@ -151,7 +162,9 @@ function CategoryFilter({ categories, selectedIds, onChange }: CategoryFilterPro
) : (
)}
- {cat.categoryName}
+
+ {getCategoryDisplayName(tSettings, cat.categoryName, cat.categoryTranslationKey)}
+
);
})}
@@ -423,89 +436,82 @@ export function BudgetOverviewPage() {
setMobileBarOpen((v) => !v);
}, []);
- // Page header JSX (reused across loading, error, and main states)
- const pageHeaderJsx = (
-
-
{t('overview.title')}
-
-
setAddOpen((v) => !v)}
- aria-haspopup="menu"
- aria-expanded={addOpen}
- aria-label={t('overview.actions.addButton')}
- data-testid="budget-overview-add-button"
- >
- {t('overview.actions.addButton')}
-
- {addOpen && (
-
- {
- setAddOpen(false);
- void navigate('/budget/invoices');
- }}
- data-testid="budget-overview-add-invoice"
- >
- {t('overview.actions.addInvoice')}
-
- {
- setAddOpen(false);
- void navigate('/budget/vendors');
- }}
- data-testid="budget-overview-add-vendor"
- >
- {t('overview.actions.addVendor')}
-
-
- )}
-
+ // Action dropdown (reused across loading, error, and main states)
+ const actionDropdown = (
+
+
setAddOpen((v) => !v)}
+ aria-haspopup="menu"
+ aria-expanded={addOpen}
+ aria-label={t('overview.actions.addButton')}
+ data-testid="budget-overview-add-button"
+ >
+ {t('overview.actions.addButton')}
+
+ {addOpen && (
+
+ {
+ setAddOpen(false);
+ void navigate('/budget/invoices');
+ }}
+ data-testid="budget-overview-add-invoice"
+ >
+ {t('overview.actions.addInvoice')}
+
+ {
+ setAddOpen(false);
+ void navigate('/budget/vendors');
+ }}
+ data-testid="budget-overview-add-vendor"
+ >
+ {t('overview.actions.addVendor')}
+
+
+ )}
);
// ---- Loading state ----
if (isLoading) {
return (
-
-
- {pageHeaderJsx}
-
-
- {t('overview.loading')}
-
+
}
+ >
+
+ {t('overview.loading')}
-
+
);
}
// ---- Error state ----
if (error) {
return (
-
-
- {pageHeaderJsx}
-
-
-
{t('overview.error')}
-
{error}
-
void loadOverview()}
- >
- {t('overview.retry')}
-
-
+
}
+ >
+
+
{t('overview.error')}
+
{error}
+
void loadOverview()}>
+ {t('overview.retry')}
+
-
+
);
}
@@ -643,181 +649,170 @@ export function BudgetOverviewPage() {
);
return (
-
-
- {/* Page header */}
- {pageHeaderJsx}
-
- {/* Budget sub-navigation */}
-
-
- {/* Empty state */}
- {!hasData && (
-
-
{t('overview.emptyStateTitle')}
-
{t('overview.emptyStateDescription')}
+
}
+ >
+ {/* Empty state */}
+ {!hasData && (
+
+
{t('overview.emptyStateTitle')}
+
{t('overview.emptyStateDescription')}
+
+ )}
+
+ {/* ========================================================
+ * Budget Health Hero Card
+ * ======================================================== */}
+
+ {/* Key metrics row */}
+
+ {/* Available Funds */}
+
+ {t('overview.availableFunds')}
+ {formatCurrency(overview.availableFunds)}
- )}
- {/* ========================================================
- * Budget Health Hero Card
- * ======================================================== */}
-
- {/* Key metrics row */}
-
- {/* Available Funds */}
-
- {t('overview.availableFunds')}
- {formatCurrency(overview.availableFunds)}
-
+ {/* Projected Cost Range */}
+
+ {t('overview.projectedCostRange')}
+
+
+ {formatShort(filtered.minPlanned)}
+ –
+ {formatShort(filtered.maxPlanned)}
+
+
+
- {/* Projected Cost Range */}
+ {/* Expected Payback (only when hasPayback) */}
+ {hasPayback && (
- {t('overview.projectedCostRange')}
-
+ {t('overview.expectedPayback')}
+
- {formatShort(filtered.minPlanned)}
- –
- {formatShort(filtered.maxPlanned)}
+ {formatShort(overview.subsidySummary.minTotalPayback)}
+ {overview.subsidySummary.minTotalPayback !==
+ overview.subsidySummary.maxTotalPayback ? (
+ <>
+ –
+ {formatShort(overview.subsidySummary.maxTotalPayback)}
+ >
+ ) : null}
+ {overview.subsidySummary.oversubscribedSubsidies?.length > 0 && (
+ {t('overview.paybackCapped')}
+ )}
+ )}
- {/* Expected Payback (only when hasPayback) */}
- {hasPayback && (
-
-
{t('overview.expectedPayback')}
-
-
- {formatShort(overview.subsidySummary.minTotalPayback)}
- {overview.subsidySummary.minTotalPayback !==
- overview.subsidySummary.maxTotalPayback ? (
- <>
- –
- {formatShort(overview.subsidySummary.maxTotalPayback)}
- >
- ) : null}
-
+ {/* Remaining (best/worst) — with detail on hover/tap */}
+
+ {t('overview.remaining')}
+
+ setRemainingDetailOpen((v) => !v)}
+ >
+ = 0 ? styles.metricPositive : styles.metricNegative}>
+ {formatShort(remainingMin)}
- {overview.subsidySummary.oversubscribedSubsidies?.length > 0 && (
- {t('overview.paybackCapped')}
- )}
-
- )}
+ –
+ = 0 ? styles.metricPositive : styles.metricNegative}>
+ {formatShort(remainingMax)}
+
+
+ ⓘ
+
+
+
- {/* Remaining (best/worst) — with detail on hover/tap */}
-
-
{t('overview.remaining')}
-
- setRemainingDetailOpen((v) => !v)}
- >
- = 0 ? styles.metricPositive : styles.metricNegative}
- >
- {formatShort(remainingMin)}
-
- –
- = 0 ? styles.metricPositive : styles.metricNegative}
- >
- {formatShort(remainingMax)}
-
-
- ⓘ
-
-
-
-
- {/* Mobile inline remaining detail — toggled by tap */}
-
-
-
+ {/* Mobile inline remaining detail — toggled by tap */}
+
+
+
- {/* Stacked bar */}
-
-
-
- {/* Desktop floating tooltip anchored below bar */}
- {hoveredSegment && (
-
-
-
- )}
-
-
- {/* Mobile bar detail panel */}
-
-
-
+ {/* Stacked bar */}
+
+
- {/* Category filter */}
- {overview.categorySummaries.length > 0 && (
-
-
+
)}
-
+
- {/* Cost Breakdown Table */}
- {overview &&
- (isBreakdownLoading ? (
-
-
{t('overview.costBreakdown.loading')}
-
- ) : breakdown ? (
-
+
+
+
+ {/* Category filter */}
+ {overview.categorySummaries.length > 0 && (
+
+
- ) : null)}
-
-
+
+ )}
+
+
+ {/* Cost Breakdown Table */}
+ {overview &&
+ (isBreakdownLoading ? (
+
+
{t('overview.costBreakdown.loading')}
+
+ ) : breakdown ? (
+
+ ) : null)}
+
);
}
diff --git a/client/src/pages/BudgetSourcesPage/BudgetSourcesPage.module.css b/client/src/pages/BudgetSourcesPage/BudgetSourcesPage.module.css
index 7852eeafc..1e7be27ce 100644
--- a/client/src/pages/BudgetSourcesPage/BudgetSourcesPage.module.css
+++ b/client/src/pages/BudgetSourcesPage/BudgetSourcesPage.module.css
@@ -1,31 +1,3 @@
-.container {
- padding: var(--spacing-8);
- max-width: 1200px;
- margin: 0 auto;
-}
-
-.content {
- display: flex;
- flex-direction: column;
- gap: var(--spacing-6);
-}
-
-/* ---- Page header ---- */
-
-.pageHeader {
- display: flex;
- align-items: center;
- justify-content: space-between;
- gap: var(--spacing-4);
-}
-
-.pageTitle {
- font-size: var(--font-size-4xl);
- font-weight: var(--font-weight-bold);
- color: var(--color-text-primary);
- margin: 0;
-}
-
/* ---- Section header (below sub-nav) ---- */
.sectionHeader {
@@ -774,20 +746,11 @@
* ============================================================ */
@media (max-width: 767px) {
- .container {
- padding: var(--spacing-4);
- }
-
- .pageTitle {
- font-size: var(--font-size-2xl);
- }
-
.card {
padding: var(--spacing-4);
}
- /* Stack page header and section header vertically on mobile */
- .pageHeader,
+ /* Stack section header vertically on mobile */
.sectionHeader {
flex-direction: column;
align-items: stretch;
@@ -861,10 +824,6 @@
* ============================================================ */
@media (min-width: 768px) and (max-width: 1024px) {
- .container {
- padding: var(--spacing-6);
- }
-
/* Touch-friendly minimum heights */
.button,
.saveButton,
diff --git a/client/src/pages/BudgetSourcesPage/BudgetSourcesPage.test.tsx b/client/src/pages/BudgetSourcesPage/BudgetSourcesPage.test.tsx
index cc91af9e0..0c106a37a 100644
--- a/client/src/pages/BudgetSourcesPage/BudgetSourcesPage.test.tsx
+++ b/client/src/pages/BudgetSourcesPage/BudgetSourcesPage.test.tsx
@@ -191,14 +191,13 @@ describe('BudgetSourcesPage', () => {
// ─── Page structure ──────────────────────────────────────────────────────────
describe('page structure', () => {
- it('renders the page heading "Budget" and section heading "Sources"', async () => {
+ it('renders the page heading "Budget"', async () => {
mockFetchBudgetSources.mockResolvedValueOnce(emptyResponse);
renderPage();
await waitFor(() => {
expect(screen.getByRole('heading', { name: /^budget$/i, level: 1 })).toBeInTheDocument();
- expect(screen.getByRole('heading', { name: /^sources$/i, level: 2 })).toBeInTheDocument();
});
});
diff --git a/client/src/pages/BudgetSourcesPage/BudgetSourcesPage.tsx b/client/src/pages/BudgetSourcesPage/BudgetSourcesPage.tsx
index 670fc9315..0543ed935 100644
--- a/client/src/pages/BudgetSourcesPage/BudgetSourcesPage.tsx
+++ b/client/src/pages/BudgetSourcesPage/BudgetSourcesPage.tsx
@@ -14,11 +14,20 @@ import {
} from '../../lib/budgetSourcesApi.js';
import { ApiClientError } from '../../lib/apiClient.js';
import { useFormatters } from '../../lib/formatters.js';
-import { BudgetSubNav } from '../../components/BudgetSubNav/BudgetSubNav.js';
+import { PageLayout } from '../../components/PageLayout/PageLayout.js';
+import { SubNav, type SubNavTab } from '../../components/SubNav/SubNav.js';
import { BudgetBar } from '../../components/BudgetBar/BudgetBar.js';
import type { BudgetBarSegment } from '../../components/BudgetBar/BudgetBar.js';
import styles from './BudgetSourcesPage.module.css';
+const BUDGET_TABS: SubNavTab[] = [
+ { labelKey: 'subnav.budget.overview', to: '/budget/overview' },
+ { labelKey: 'subnav.budget.invoices', to: '/budget/invoices' },
+ { labelKey: 'subnav.budget.vendors', to: '/budget/vendors' },
+ { labelKey: 'subnav.budget.sources', to: '/budget/sources' },
+ { labelKey: 'subnav.budget.subsidies', to: '/budget/subsidies' },
+];
+
// ---- Display helpers ----
function getSourceTypeClass(styles: Record
, sourceType: BudgetSourceType): string {
@@ -468,501 +477,485 @@ export function BudgetSourcesPage() {
if (isLoading) {
return (
-
-
-
-
{t('sources.title')}
-
-
-
{t('sources.loading')}
-
-
+ }
+ >
+ {t('sources.loading')}
+
);
}
if (error && sources.length === 0) {
return (
-
-
-
-
{t('sources.title')}
-
-
-
-
{t('sources.error')}
-
{error}
-
void loadSources()}>
- {t('sources.retry')}
-
-
+
}
+ >
+
+
{t('sources.error')}
+
{error}
+
void loadSources()}>
+ {t('sources.retry')}
+
-
+
);
}
return (
-
-
- {/* Page header */}
-
-
{t('sources.title')}
+
{
+ setShowCreateForm(true);
+ setCreateError('');
+ }}
+ disabled={showCreateForm}
+ >
+ {t('sources.addSource')}
+
+ }
+ subNav={ }
+ >
+ {successMessage && (
+
+ {successMessage}
+ )}
- {/* Budget sub-navigation */}
-
-
- {/* Section header */}
-
-
{t('sources.sectionTitle')}
-
{
- setShowCreateForm(true);
- setCreateError('');
- }}
- disabled={showCreateForm}
- >
- {t('sources.addSource')}
-
+ {error && (
+
+ {error}
+ )}
- {successMessage && (
-
- {successMessage}
-
- )}
-
- {error && (
-
- {error}
-
- )}
+ {/* Create form */}
+ {showCreateForm && (
+
+ {t('sources.newBudgetSource')}
+ {t('sources.newBudgetSourceDescription')}
- {/* Create form */}
- {showCreateForm && (
-
- {t('sources.newBudgetSource')}
- {t('sources.newBudgetSourceDescription')}
+ {createError && (
+
+ {createError}
+
+ )}
- {createError && (
-