diff --git a/.claude/agent-memory/e2e-test-engineer/MEMORY.md b/.claude/agent-memory/e2e-test-engineer/MEMORY.md index d44554801..2fc2a06c5 100644 --- a/.claude/agent-memory/e2e-test-engineer/MEMORY.md +++ b/.claude/agent-memory/e2e-test-engineer/MEMORY.md @@ -3,6 +3,21 @@ > 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`, `milestones-e2e.md`, `story-1248-mass-move.md` +## Budget Source Filter E2E (Story #1354, 2026-04-25) + +- BudgetSourceChip `aria-label` = `"Filter: {name} (selected)"` / `"Filter: {name} (not selected)"` — use `new RegExp(name)` in `getByRole('button', { name })`. +- Filter toolbar `aria-label` = `"Filter by source"` (i18n key `overview.costBreakdown.sourceFilter.label`). +- Clear filter button `aria-label` = `"Clear source filter — show all sources"` — use `/Clear source filter/i`. +- Available Funds expand button `aria-label` = `"Expand available funds sources"` (hardcoded, not i18n). +- Source badge in Level 3 rows: ``. Unassigned: `aria-label="Budget source: Unassigned"`. +- Long name truncation: badge label text ends with `…`. Full name in `title` attribute. +- Mobile "badge dot-only" behavior: IMPLEMENTED. `` (aria-hidden) shown at ≤767px; `` hidden (display:none). Select via `[class*="sourceBadgeDot"]` / `[class*="sourceBadgeLabel"]` (hashed CSS module names). The Badge aria-label stays in DOM inside the hidden label span for screen readers. +- CSS module class for selected source detail row: `rowSourceDetailSelected` — Playwright sees hashed class e.g. `rowSourceDetailSelected_xyz`. Use `.toMatch(/rowSourceDetailSelected/)` regex on class attribute. +- `overflow-x: auto` on `.sourceFilterStrip` — testable via `getComputedStyle(el).overflowX === 'auto'`. +- `budgetSources` array comes from `breakdown.budgetSources` (in the breakdown response), NOT from the `/api/budget-sources` endpoint — mock both if needed. +- Escape clears filter: IMPLEMENTED. `handleToolbarKeyDown` on `
` checks `e.key === 'Escape' && selectedSourceIds.size > 0`, calls `onClearSources()` + `availFundsButtonRef.current?.focus()`. No-op when no sources selected. Test via `chip.focus(); keyboard.press('Escape')` then assert `aria-pressed=false`, URL clean, and `availFundsButton.toBeFocused()`. +- Dark mode color check: create throw-away element to normalize `rgb()` format (see Print E2E Patterns note). + ## Print E2E Patterns (Issue #1310, 2026-04-19) - `page.emulateMedia({ media: 'print' })` makes CSS `@media print` rules apply without dispatching window events. diff --git a/.claude/agent-memory/qa-integration-tester/MEMORY.md b/.claude/agent-memory/qa-integration-tester/MEMORY.md index 413e18583..1fd9eb627 100644 --- a/.claude/agent-memory/qa-integration-tester/MEMORY.md +++ b/.claude/agent-memory/qa-integration-tester/MEMORY.md @@ -3,6 +3,14 @@ > 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 #1354 — CostBreakdownTable Props Refactor Pattern (2026-04-25) + +`CostBreakdownTable` had `budgetSources={[]}` prop replaced with `selectedSourceIds={new Set()} onSourceToggle={() => {}} onClearSources={() => {}}`. When a component's prop API changes, use `replace_all: true` on Edit tool to update all test usages in one pass (28 occurrences updated at once). Also add new required fixture fields (`budgetSources: []` on BudgetBreakdown, `budgetSourceId: null` on BreakdownBudgetLine) via Python `sed`-style script when the pattern is uniform across many objects. + +**Fix Loop Round 1 (2026-04-25)**: Tests at lines ~1844/1859/1884 still passed `budgetSources` as a JSX prop AND were missing required props. Fix: move source data into `breakdown={{ ...buildBreakdownWithWI(), budgetSources: [buildSourceSummary(...)] }}` and add `selectedSourceIds onSourceToggle onClearSources`. Also removed obsolete `buildBudgetSource()` helper (used `BudgetSource` full type — now use `buildSourceSummary()` with `BudgetSourceSummaryBreakdown`). In `BudgetOverviewPage.test.tsx`, Scenario 30 was testing the old `budgetSources` prop flow; updated to populate `breakdown.budgetSources` instead. Added Escape key tests for new `handleToolbarKeyDown` behavior. + +**Stale dist warning**: `node_modules/@cornerstone/shared/dist/` must be rebuilt (`tsc -p shared/tsconfig.json --outDir node_modules/@cornerstone/shared/dist`) when shared types change. Without rebuild, `tsc --noEmit` on client shows false positives for `budgetSourceId`, `budgetSources`, `BudgetSourceSummaryBreakdown`. Jest is unaffected (maps to source). + ## BudgetBar Module-Level Mock Anti-Pattern (2026-04-20) **Critical**: Mocking `BudgetBar` at module level (`jest.unstable_mockModule('../../components/BudgetBar/BudgetBar.js', ...)`) breaks ALL existing tests that rely on BudgetBar rendering content (labels, role="img", segment text). BudgetBar renders segment labels (e.g. "Paid (unclaimed)", "Claimed") that existing tests assert on. The fix: test segment keys via observable behavior (aria-label, summaryLabel text) rather than mock capture. For segment structure verification, use `container.querySelectorAll('[class*="summaryRow"]')` to check rows and their label text order. diff --git a/.claude/agent-memory/translator/MEMORY.md b/.claude/agent-memory/translator/MEMORY.md index 3217980ea..c7b156635 100644 --- a/.claude/agent-memory/translator/MEMORY.md +++ b/.claude/agent-memory/translator/MEMORY.md @@ -132,3 +132,17 @@ Note: `claimed` here uses "Beantragt" (applied/requested for subsidy) rather tha - `summaryClaimedLabel` → "Eingereicht" (bar chart summary; consistent with `barChart.claimed` = "Eingereicht") - `srOnly` screen reader text: "Eingereicht {{claimed}}, Bezahlt {{paid}}, Projiziert {{projectedMin}} bis {{projectedMax}}, von Gesamt {{total}}" - Obsolete keys removed in this update: `allocated`, `total`, `available`, `planned` + +## Source Filter & Source Badge Patterns — Issue #1354 (2026-04-25) + +- `overview.costBreakdown.sourceFilter.*`, `sourceImpact.*`, `sourceBadge.*` added to `de/budget.json` +- "Filter by source" → "Nach Quelle filtern" (matches `filterByStatus` = "Nach Status filtern" / `filterByVendor` = "Nach Auftragnehmer filtern" pattern) +- "All sources" → "Alle Quellen" (matches `allStatuses` = "Alle Status" / `allVendors` = "Alle Auftragnehmer" pattern) +- "Clear filters" (button label) → "Filter Löschen" (exact match with `invoices.clearFilters`) +- "clearAriaLabel" (descriptive aria label) → sentence-style with en-dash: "Quellenfilter löschen – alle Quellen anzeigen" +- "Unassigned" (source filter / source badge context) → "Nicht zugewiesen" (glossary `Unassigned` term, not "Kein X" pattern which is used for area/category absence) +- Live-region announcement sentence structure: "Es werden {{count}} von {{total}} Budgetpositionen angezeigt" / "Es werden alle Budgetpositionen angezeigt" +- "Budget source: {{name}}" (aria label) → "Budgetquelle: {{name}}" — always use full glossary term "Budgetquelle" in aria labels, short "Quelle" only in UI labels +- `sourceImpact.allocated` → "Zugeordnet" (consistent with `invoices.tableHeaders.allocated` = "Zugeordnet") +- `sourceImpact.remaining` → "Verbleibend" (consistent with established `barChart.remaining` / `summary.remainingLabel`) +- Chip aria labels: "Filter: {{name}} (ausgewählt)" / "Filter: {{name}} (nicht ausgewählt)" diff --git a/.claude/agent-memory/ux-designer/MEMORY.md b/.claude/agent-memory/ux-designer/MEMORY.md index 11667d1ca..320531573 100644 --- a/.claude/agent-memory/ux-designer/MEMORY.md +++ b/.claude/agent-memory/ux-designer/MEMORY.md @@ -274,3 +274,7 @@ Zero new tokens, zero new CSS, zero new components. Text replacement only. Key r - German currency trailing `€` with space may widen budget table cells — acceptable, no layout fix needed - SearchPicker default prop strings (`placeholder`, `emptyHint`, etc.) replaced with `t()` — no CSS change - Language selector UI is Story #917 (ProfilePage) — not this story + +## Story #1354 — Source Badges & Filter (Budget Overview) + +See [story-1354-source-badges-filter.md](story-1354-source-badges-filter.md). Key: 10-slot `--color-source-N-{bg,text,dot}` token family; `BudgetSourceChip` new shared component; `role="toolbar"` for chip strip; scoped `--chip-*` CSS custom properties pattern for per-slot colors. diff --git a/.claude/agent-memory/ux-designer/story-1354-source-badges-filter.md b/.claude/agent-memory/ux-designer/story-1354-source-badges-filter.md new file mode 100644 index 000000000..5d272bef5 --- /dev/null +++ b/.claude/agent-memory/ux-designer/story-1354-source-badges-filter.md @@ -0,0 +1,35 @@ +--- +name: Story #1354 — Source Attribution Badges and Per-Source Filter +description: Visual spec for budget source color palette, source badge in BudgetLineRow, and filter chip strip in Available Funds section +type: project +--- + +## Key Decisions + +**Source color palette**: 10-slot deterministic palette (`colorIndex = sourceId % 10`). Slot 0 = Unassigned (gray). Slots 1–9 match calendar-item hue families plus cyan. No schema change required. + +**New token family**: `--color-source-N-bg`, `--color-source-N-text`, `--color-source-N-dot` for N=0–9, with Layer 3 dark-mode overrides. Added to tokens.css Layer 2 / Layer 3. + +**Token naming**: `-dot` suffix provides a higher-saturation swatch color for the circle dot indicator (distinct from `-bg` which is low-saturation for badge background). + +**Color-blind affordance**: source name label always present alongside dot; name truncated at 20 chars (badge) / 24 chars (chip) with full name in `title` + `aria-label`. + +**Badge extension**: Add `source0`–`source9` and `sourceUnassigned` CSS classes to `Badge.module.css`. Add optional `title` prop to `Badge.tsx`. No new component needed for the badge. + +**BudgetSourceChip**: New shared component at `client/src/components/BudgetSourceChip/`. Renders as ` + ); +} diff --git a/client/src/components/BudgetSourceChip/index.ts b/client/src/components/BudgetSourceChip/index.ts new file mode 100644 index 000000000..1dcb3c629 --- /dev/null +++ b/client/src/components/BudgetSourceChip/index.ts @@ -0,0 +1,2 @@ +export { BudgetSourceChip } from './BudgetSourceChip.js'; +export type { BudgetSourceChipProps } from './BudgetSourceChip.js'; diff --git a/client/src/components/CostBreakdownTable/CostBreakdownTable.module.css b/client/src/components/CostBreakdownTable/CostBreakdownTable.module.css index 8546698d5..8af9695d4 100644 --- a/client/src/components/CostBreakdownTable/CostBreakdownTable.module.css +++ b/client/src/components/CostBreakdownTable/CostBreakdownTable.module.css @@ -646,3 +646,96 @@ border-bottom: 1pt dotted var(--color-border); } } + +/* ============================================================ + * SOURCE FILTER STYLES + * ============================================================ */ + +.sourceFilterStrip { + display: flex; + flex-wrap: wrap; + gap: var(--spacing-2); + padding: var(--spacing-3); + background-color: var(--color-bg-secondary); + border-radius: var(--radius-md); + align-items: center; + overflow-x: auto; +} + +.sourceDot { + display: inline-block; + width: var(--spacing-2); + height: var(--spacing-2); + border-radius: var(--radius-circle); + flex-shrink: 0; + margin-right: var(--spacing-1); +} + +.rowSourceDetail { + background-color: var(--color-surface-item-tint); +} + +.rowSourceDetailSelected { + border-left: 3px solid var(--chip-dot); +} + +.colAllocatedMobile { + /* Will be hidden on mobile via media query below */ +} + +.rowFiltered { + animation: fadeIn 0.15s ease; +} + +@keyframes fadeIn { + from { + opacity: 0; + } + to { + opacity: 1; + } +} + +@media (prefers-reduced-motion: reduce) { + .rowFiltered { + animation: none; + } +} + +@media (max-width: 767px) { + .colAllocatedMobile { + display: none; + } +} + +.srOnly { + position: absolute; + width: 1px; + height: 1px; + padding: 0; + margin: -1px; + overflow: hidden; + clip: rect(0, 0, 0, 0); + white-space: nowrap; + border: 0; +} + +/* ---- Mobile dot-only source badge ---- */ + +.sourceBadgeDot { + display: none; + width: var(--spacing-2); + height: var(--spacing-2); + border-radius: var(--radius-circle); + flex-shrink: 0; +} + +@media (max-width: 767px) { + .sourceBadgeLabel { + display: none; + } + + .sourceBadgeDot { + display: inline-block; + } +} diff --git a/client/src/components/CostBreakdownTable/CostBreakdownTable.test.tsx b/client/src/components/CostBreakdownTable/CostBreakdownTable.test.tsx index 02d34ac8a..273f4f46c 100644 --- a/client/src/components/CostBreakdownTable/CostBreakdownTable.test.tsx +++ b/client/src/components/CostBreakdownTable/CostBreakdownTable.test.tsx @@ -5,7 +5,8 @@ import { jest, describe, it, expect, beforeAll } from '@jest/globals'; import { render, screen, fireEvent } from '@testing-library/react'; import { MemoryRouter } from 'react-router-dom'; import type { CostBreakdownTable as CostBreakdownTableType } from './CostBreakdownTable.js'; -import type { BudgetBreakdown, BudgetOverview, BudgetSource } from '@cornerstone/shared'; +import type { BudgetBreakdown, BudgetOverview } from '@cornerstone/shared'; +import type { BudgetSourceSummaryBreakdown } from '@cornerstone/shared'; // CSS modules mocked via identity-obj-proxy @@ -86,11 +87,21 @@ beforeAll(async () => { function renderWithRouter( breakdown: BudgetBreakdown, overview: BudgetOverview, - budgetSources: BudgetSource[] = [], + opts: { + selectedSourceIds?: Set; + onSourceToggle?: (sourceId: string | null) => void; + onClearSources?: () => void; + } = {}, ) { return render( - + {})} + onClearSources={opts.onClearSources ?? (() => {})} + /> , ); } @@ -241,6 +252,7 @@ function buildEmptyBreakdown(): BudgetBreakdown { }, }, subsidyAdjustments: [], + budgetSources: [], }; } @@ -315,6 +327,7 @@ function buildBreakdownWithWI( actualCost, hasInvoice, isQuotation: false, + budgetSourceId: null, }, ], }, @@ -345,6 +358,7 @@ function buildBreakdownWithWI( }, }, subsidyAdjustments: [], + budgetSources: [], }; } @@ -429,6 +443,7 @@ function buildBreakdownWithHI( actualCost, hasInvoice: actualCost > 0, isQuotation: false, + budgetSourceId: null, }, ], }, @@ -447,37 +462,218 @@ function buildBreakdownWithHI( }, }, subsidyAdjustments: [], + budgetSources: [], }; } /** - * Build a minimal BudgetSource for tests. + * Build a BudgetSourceSummaryBreakdown for tests. */ -function buildBudgetSource( - opts: { id?: string; name?: string; totalAmount?: number } = {}, -): BudgetSource { +function buildSourceSummary( + opts: { + id?: string; + name?: string; + totalAmount?: number; + projectedMin?: number; + projectedMax?: number; + } = {}, +): BudgetSourceSummaryBreakdown { return { id: opts.id ?? 'src-1', name: opts.name ?? 'Bank Loan', - sourceType: 'bank_loan', - totalAmount: opts.totalAmount ?? 80000, - usedAmount: 0, - availableAmount: opts.totalAmount ?? 80000, - claimedAmount: 0, - unclaimedAmount: 0, - actualAvailableAmount: opts.totalAmount ?? 80000, - paidAmount: 0, - projectedAmount: 0, - projectedMinAmount: 0, - projectedMaxAmount: 0, - isDiscretionary: false, - interestRate: null, - terms: null, - notes: null, - status: 'active', - createdBy: null, - createdAt: '2025-01-01T00:00:00.000Z', - updatedAt: '2025-01-01T00:00:00.000Z', + totalAmount: opts.totalAmount ?? 100000, + projectedMin: opts.projectedMin ?? 5000, + projectedMax: opts.projectedMax ?? 8000, + }; +} + +/** + * Build a breakdown with one WI item whose budget line has a specific budgetSourceId. + * Used for source badge and filter tests. + */ +function buildBreakdownWithSourcedWI(opts: { + budgetSourceId: string | null; + lineId?: string; + budgetSources?: BudgetSourceSummaryBreakdown[]; +}): BudgetBreakdown { + return { + workItems: { + areas: [ + { + areaId: null, + name: 'Unassigned', + parentId: null, + color: null, + projectedMin: 800, + projectedMax: 1200, + actualCost: 0, + subsidyPayback: 0, + rawProjectedMin: 800, + rawProjectedMax: 1200, + minSubsidyPayback: 0, + items: [ + { + workItemId: 'wi-src-1', + title: 'Sourced Work Item', + projectedMin: 800, + projectedMax: 1200, + actualCost: 0, + subsidyPayback: 0, + rawProjectedMin: 800, + rawProjectedMax: 1200, + minSubsidyPayback: 0, + costDisplay: 'projected', + budgetLines: [ + { + id: opts.lineId ?? 'sourced-line-1', + description: 'Sourced budget line', + plannedAmount: 1000, + confidence: 'own_estimate', + actualCost: 0, + hasInvoice: false, + isQuotation: false, + budgetSourceId: opts.budgetSourceId, + }, + ], + }, + ], + children: [], + }, + ], + totals: { + projectedMin: 800, + projectedMax: 1200, + actualCost: 0, + subsidyPayback: 0, + rawProjectedMin: 800, + rawProjectedMax: 1200, + minSubsidyPayback: 0, + }, + }, + householdItems: { + areas: [], + totals: { + projectedMin: 0, + projectedMax: 0, + actualCost: 0, + subsidyPayback: 0, + rawProjectedMin: 0, + rawProjectedMax: 0, + minSubsidyPayback: 0, + }, + }, + subsidyAdjustments: [], + budgetSources: opts.budgetSources ?? [], + }; +} + +/** + * Build a breakdown with two WI items in the same area — one with a source, one without. + */ +function buildBreakdownWithMixedSourceLines(opts: { + sourceId: string; + sourceName: string; +}): BudgetBreakdown { + return { + workItems: { + areas: [ + { + areaId: null, + name: 'Unassigned', + parentId: null, + color: null, + projectedMin: 1600, + projectedMax: 2400, + actualCost: 0, + subsidyPayback: 0, + rawProjectedMin: 1600, + rawProjectedMax: 2400, + minSubsidyPayback: 0, + items: [ + { + workItemId: 'wi-mix-1', + title: 'With Source', + projectedMin: 800, + projectedMax: 1200, + actualCost: 0, + subsidyPayback: 0, + rawProjectedMin: 800, + rawProjectedMax: 1200, + minSubsidyPayback: 0, + costDisplay: 'projected', + budgetLines: [ + { + id: 'mix-line-src', + description: 'Line with source', + plannedAmount: 1000, + confidence: 'own_estimate', + actualCost: 0, + hasInvoice: false, + isQuotation: false, + budgetSourceId: opts.sourceId, + }, + ], + }, + { + workItemId: 'wi-mix-2', + title: 'Without Source', + projectedMin: 800, + projectedMax: 1200, + actualCost: 0, + subsidyPayback: 0, + rawProjectedMin: 800, + rawProjectedMax: 1200, + minSubsidyPayback: 0, + costDisplay: 'projected', + budgetLines: [ + { + id: 'mix-line-null', + description: 'Unassigned line', + plannedAmount: 1000, + confidence: 'own_estimate', + actualCost: 0, + hasInvoice: false, + isQuotation: false, + budgetSourceId: null, + }, + ], + }, + ], + children: [], + }, + ], + totals: { + projectedMin: 1600, + projectedMax: 2400, + actualCost: 0, + subsidyPayback: 0, + rawProjectedMin: 1600, + rawProjectedMax: 2400, + minSubsidyPayback: 0, + }, + }, + householdItems: { + areas: [], + totals: { + projectedMin: 0, + projectedMax: 0, + actualCost: 0, + subsidyPayback: 0, + rawProjectedMin: 0, + rawProjectedMax: 0, + minSubsidyPayback: 0, + }, + }, + subsidyAdjustments: [], + budgetSources: [ + { + id: opts.sourceId, + name: opts.sourceName, + totalAmount: 100000, + projectedMin: 800, + projectedMax: 1200, + }, + ], }; } @@ -491,7 +687,7 @@ describe('CostBreakdownTable', () => { {}} onClearSources={() => {}} />, ); @@ -505,7 +701,7 @@ describe('CostBreakdownTable', () => { {}} onClearSources={() => {}} />, ); @@ -518,7 +714,7 @@ describe('CostBreakdownTable', () => { {}} onClearSources={() => {}} />, ); @@ -532,7 +728,7 @@ describe('CostBreakdownTable', () => { {}} onClearSources={() => {}} />, ); @@ -544,7 +740,7 @@ describe('CostBreakdownTable', () => { {}} onClearSources={() => {}} />, ); @@ -558,7 +754,7 @@ describe('CostBreakdownTable', () => { {}} onClearSources={() => {}} />, ); @@ -573,7 +769,7 @@ describe('CostBreakdownTable', () => { {}} onClearSources={() => {}} />, ); @@ -588,7 +784,7 @@ describe('CostBreakdownTable', () => { {}} onClearSources={() => {}} />, ); @@ -832,10 +1028,11 @@ describe('CostBreakdownTable', () => { }, }, subsidyAdjustments: [], + budgetSources: [], }; const { container } = render( - , + {}} onClearSources={() => {}} />, ); // Expand WI section @@ -888,10 +1085,11 @@ describe('CostBreakdownTable', () => { }, }, subsidyAdjustments: [], + budgetSources: [], }; const { container } = render( - , + {}} onClearSources={() => {}} />, ); fireEvent.click(getButtonByControls(container, 'wi-section-categories')); @@ -944,10 +1142,11 @@ describe('CostBreakdownTable', () => { }, }, subsidyAdjustments: [], + budgetSources: [], }; render( - , + {}} onClearSources={() => {}} />, ); // HI section should still be visible @@ -962,7 +1161,7 @@ describe('CostBreakdownTable', () => { {}} onClearSources={() => {}} />, ); @@ -978,7 +1177,7 @@ describe('CostBreakdownTable', () => { {}} onClearSources={() => {}} />, ); @@ -993,7 +1192,7 @@ describe('CostBreakdownTable', () => { {}} onClearSources={() => {}} />, ); @@ -1005,7 +1204,7 @@ describe('CostBreakdownTable', () => { {}} onClearSources={() => {}} />, ); @@ -1017,7 +1216,7 @@ describe('CostBreakdownTable', () => { {}} onClearSources={() => {}} />, ); @@ -1031,7 +1230,7 @@ describe('CostBreakdownTable', () => { {}} onClearSources={() => {}} />, ); @@ -1044,7 +1243,7 @@ describe('CostBreakdownTable', () => { {}} onClearSources={() => {}} />, ); @@ -1057,7 +1256,7 @@ describe('CostBreakdownTable', () => { {}} onClearSources={() => {}} />, ); @@ -1070,7 +1269,7 @@ describe('CostBreakdownTable', () => { {}} onClearSources={() => {}} />, ); @@ -1105,7 +1304,7 @@ describe('CostBreakdownTable', () => { {}} onClearSources={() => {}} />, ); @@ -1139,7 +1338,7 @@ describe('CostBreakdownTable', () => { {}} onClearSources={() => {}} />, ); @@ -1162,7 +1361,7 @@ describe('CostBreakdownTable', () => { {}} onClearSources={() => {}} />, ); @@ -1176,7 +1375,7 @@ describe('CostBreakdownTable', () => { {}} onClearSources={() => {}} />, ); @@ -1188,7 +1387,7 @@ describe('CostBreakdownTable', () => { {}} onClearSources={() => {}} />, ); @@ -1259,13 +1458,14 @@ describe('CostBreakdownTable', () => { }, }, subsidyAdjustments: [], + budgetSources: [], }; render( {}} onClearSources={() => {}} />, ); @@ -1314,7 +1514,7 @@ describe('CostBreakdownTable', () => { {}} onClearSources={() => {}} />, ); @@ -1331,7 +1531,7 @@ describe('CostBreakdownTable', () => { {}} onClearSources={() => {}} />, ); @@ -1407,7 +1607,7 @@ describe('CostBreakdownTable', () => { {}} onClearSources={() => {}} />, ); @@ -1429,7 +1629,7 @@ describe('CostBreakdownTable', () => { {}} onClearSources={() => {}} />, ); @@ -1594,7 +1794,7 @@ describe('CostBreakdownTable', () => { {}} onClearSources={() => {}} />, ); @@ -1608,9 +1808,9 @@ describe('CostBreakdownTable', () => { it('Available Funds row has an expand button with aria-expanded=false when sources exist', () => { render( {}} onClearSources={() => {}} />, ); @@ -1623,23 +1823,20 @@ describe('CostBreakdownTable', () => { it('clicking Available Funds expand shows source sub-rows with name and totalAmount', () => { render( {}} onClearSources={() => {}} />, ); const expandBtn = screen.getByRole('button', { name: /expand available funds/i }); fireEvent.click(expandBtn); - // Sub-rows should show source names - expect(screen.getByText('Savings Account')).toBeInTheDocument(); - expect(screen.getByText('Bank Loan')).toBeInTheDocument(); + // Source names appear in both chip strip AND sub-rows — 2 of each + expect(screen.getAllByText('Savings Account')).toHaveLength(2); + expect(screen.getAllByText('Bank Loan')).toHaveLength(2); - // And their totalAmount values as currency + // Sub-row totalAmount values are unique to the sub-rows expect(screen.getByText('€50,000.00')).toBeInTheDocument(); expect(screen.getByText('€80,000.00')).toBeInTheDocument(); }); @@ -1648,21 +1845,19 @@ describe('CostBreakdownTable', () => { it('clicking Available Funds expand again collapses source sub-rows', () => { render( {}} onClearSources={() => {}} />, ); const expandBtn = screen.getByRole('button', { name: /expand available funds/i }); - // Expand + // Expand: source name appears in both chip strip and sub-row fireEvent.click(expandBtn); - expect(screen.getByText('Credit Line')).toBeInTheDocument(); + expect(screen.getAllByText('Credit Line')).toHaveLength(2); - // Collapse + // Collapse: chip strip and sub-row both unmount (both gated by availFundsExpanded) fireEvent.click(expandBtn); expect(screen.queryByText('Credit Line')).not.toBeInTheDocument(); }); @@ -1690,7 +1885,7 @@ describe('CostBreakdownTable', () => { minSubsidyPayback: 800, })} overview={buildOverview(10000)} - budgetSources={[]} + selectedSourceIds={new Set()} onSourceToggle={() => {}} onClearSources={() => {}} />, ); @@ -1714,7 +1909,7 @@ describe('CostBreakdownTable', () => { minSubsidyPayback: 1000, })} overview={buildOverview(20000)} - budgetSources={[]} + selectedSourceIds={new Set()} onSourceToggle={() => {}} onClearSources={() => {}} />, ); @@ -1741,7 +1936,7 @@ describe('CostBreakdownTable', () => { minSubsidyPayback: 1000, })} overview={buildOverview(20000)} - budgetSources={[]} + selectedSourceIds={new Set()} onSourceToggle={() => {}} onClearSources={() => {}} />, ); @@ -1768,7 +1963,7 @@ describe('CostBreakdownTable', () => { minSubsidyPayback: 1000, })} overview={buildOverview(20000)} - budgetSources={[]} + selectedSourceIds={new Set()} onSourceToggle={() => {}} onClearSources={() => {}} />, ); @@ -1785,7 +1980,7 @@ describe('CostBreakdownTable', () => { {}} onClearSources={() => {}} />, ); @@ -1802,7 +1997,7 @@ describe('CostBreakdownTable', () => { {}} onClearSources={() => {}} />, ); @@ -1969,7 +2164,7 @@ describe('CostBreakdownTable', () => { {}} onClearSources={() => {}} /> , ); @@ -2016,7 +2211,7 @@ describe('CostBreakdownTable', () => { {}} onClearSources={() => {}} />, ); @@ -2039,7 +2234,7 @@ describe('CostBreakdownTable', () => { rawProjectedMax: 5000, })} overview={buildOverview(10000)} - budgetSources={[]} + selectedSourceIds={new Set()} onSourceToggle={() => {}} onClearSources={() => {}} />, ); @@ -2067,7 +2262,7 @@ describe('CostBreakdownTable', () => { minSubsidyPayback: 100, })} overview={buildOverview(10000)} - budgetSources={[]} + selectedSourceIds={new Set()} onSourceToggle={() => {}} onClearSources={() => {}} />, ); @@ -2091,7 +2286,7 @@ describe('CostBreakdownTable', () => { rawProjectedMax: 5000, })} overview={buildOverview(10000)} - budgetSources={[]} + selectedSourceIds={new Set()} onSourceToggle={() => {}} onClearSources={() => {}} />, ); @@ -2114,7 +2309,7 @@ describe('CostBreakdownTable', () => { rawProjectedMax: 5000, })} overview={buildOverview(100)} - budgetSources={[]} + selectedSourceIds={new Set()} onSourceToggle={() => {}} onClearSources={() => {}} />, ); @@ -2412,6 +2607,7 @@ describe('CostBreakdownTable', () => { actualCost: 0, hasInvoice: false, isQuotation: false, + budgetSourceId: null, }, ] : [], @@ -2445,6 +2641,7 @@ describe('CostBreakdownTable', () => { }, }, subsidyAdjustments: [], + budgetSources: [], }; } @@ -2524,6 +2721,7 @@ describe('CostBreakdownTable', () => { }, }, subsidyAdjustments: [], + budgetSources: [], }; } @@ -2690,6 +2888,7 @@ describe('Bug #586 — item expand state is independent per category', () => { actualCost: 0, hasInvoice: false, isQuotation: false, + budgetSourceId: null, }, ], }; @@ -2746,6 +2945,7 @@ describe('Bug #586 — item expand state is independent per category', () => { }, }, subsidyAdjustments: [], + budgetSources: [], }; } @@ -2773,6 +2973,7 @@ describe('Bug #586 — item expand state is independent per category', () => { actualCost: 0, hasInvoice: false, isQuotation: false, + budgetSourceId: null, }, ], }; @@ -2829,6 +3030,7 @@ describe('Bug #586 — item expand state is independent per category', () => { }, }, subsidyAdjustments: [], + budgetSources: [], }; } @@ -2939,4 +3141,283 @@ describe('Bug #586 — item expand state is independent per category', () => { expect(expandedBtns[0]).toHaveAttribute('aria-expanded', 'true'); expect(expandedBtns[1]).toHaveAttribute('aria-expanded', 'true'); }); + + // ── Source badge rendering (scenario 22) ───────────────────────────────── + + it('renders a source badge on Level 3 budget line row when budgetSourceId is set', () => { + const sourceId = 'src-bank-1'; + const sourceName = 'Bank Loan'; + const breakdown = buildBreakdownWithSourcedWI({ + budgetSourceId: sourceId, + budgetSources: [buildSourceSummary({ id: sourceId, name: sourceName })], + }); + + const { container } = renderWithRouter(breakdown, buildOverview()); + + // Expand WI section → area → item + fireEvent.click(getButtonByControls(container, 'wi-section-categories')); + fireEvent.click(getButtonByControls(container, 'area:No Area')); + fireEvent.click(getButtonByLabel('Expand Sourced Work Item')); + + // The source badge should render with the source name (or truncated version) + const badgeEl = screen.getByRole('generic', { + name: new RegExp(`Budget source: ${sourceName}`, 'i'), + }); + expect(badgeEl).toBeInTheDocument(); + }); + + it('renders source badge with aria-label containing source name', () => { + const sourceId = 'src-bank-1'; + const sourceName = 'Bank Loan'; + const breakdown = buildBreakdownWithSourcedWI({ + budgetSourceId: sourceId, + budgetSources: [buildSourceSummary({ id: sourceId, name: sourceName })], + }); + + const { container } = renderWithRouter(breakdown, buildOverview()); + + // Expand to line level + fireEvent.click(getButtonByControls(container, 'wi-section-categories')); + fireEvent.click(getButtonByControls(container, 'area:No Area')); + fireEvent.click(getButtonByLabel('Expand Sourced Work Item')); + + // aria-label includes the full source name + const badgeEl = container.querySelector(`[aria-label*="${sourceName}"]`); + expect(badgeEl).toBeInTheDocument(); + }); + + // ── Unassigned badge (scenario 23) ──────────────────────────────────────── + + it('renders unassigned badge text for budget line with null budgetSourceId', () => { + const breakdown = buildBreakdownWithSourcedWI({ + budgetSourceId: null, + budgetSources: [], + }); + + const { container } = renderWithRouter(breakdown, buildOverview()); + + // Expand to line level + fireEvent.click(getButtonByControls(container, 'wi-section-categories')); + fireEvent.click(getButtonByControls(container, 'area:No Area')); + fireEvent.click(getButtonByLabel('Expand Sourced Work Item')); + + // The badge for a null source should show "Unassigned" (from translation) + const unassignedBadge = container.querySelector('[aria-label*="Unassigned"]'); + expect(unassignedBadge).toBeInTheDocument(); + }); + + // ── Filter strip (scenario 24) ───────────────────────────────────────────── + + it('renders chip strip when Available Funds is expanded and budgetSources is non-empty', () => { + const sourceId = 'src-bank-1'; + const breakdown = buildBreakdownWithSourcedWI({ + budgetSourceId: sourceId, + budgetSources: [buildSourceSummary({ id: sourceId, name: 'Bank Loan' })], + }); + + const { container } = renderWithRouter(breakdown, buildOverview()); + + // Expand Available Funds section + fireEvent.click(getButtonByControls(container, 'avail-funds')); + + // A chip for the named source should appear + const chipBtn = screen.getByRole('button', { name: /Filter: Bank Loan/i }); + expect(chipBtn).toBeInTheDocument(); + }); + + // ── Unassigned chip (scenario 25) ───────────────────────────────────────── + + it('shows Unassigned chip when at least one line has null budgetSourceId', () => { + const sourceId = 'src-bank-1'; + const breakdown = buildBreakdownWithMixedSourceLines({ + sourceId, + sourceName: 'Bank Loan', + }); + + const { container } = renderWithRouter(breakdown, buildOverview()); + + // Expand Available Funds section + fireEvent.click(getButtonByControls(container, 'avail-funds')); + + // The Unassigned chip should appear alongside the Bank Loan chip + const unassignedChip = screen.getByRole('button', { name: /Filter: Unassigned/i }); + expect(unassignedChip).toBeInTheDocument(); + }); + + // ── Filter toggle calls onSourceToggle (scenario 26) ───────────────────── + + it('calls onSourceToggle with the source ID when a chip is clicked', () => { + const sourceId = 'src-bank-1'; + const onSourceToggle = jest.fn(); + const breakdown = buildBreakdownWithSourcedWI({ + budgetSourceId: sourceId, + budgetSources: [buildSourceSummary({ id: sourceId, name: 'Bank Loan' })], + }); + + const { container } = renderWithRouter(breakdown, buildOverview(), { onSourceToggle }); + + // Expand Available Funds to reveal chips + fireEvent.click(getButtonByControls(container, 'avail-funds')); + + // Click the Bank Loan chip + const chipBtn = screen.getByRole('button', { name: /Filter: Bank Loan/i }); + fireEvent.click(chipBtn); + + expect(onSourceToggle).toHaveBeenCalledTimes(1); + expect(onSourceToggle).toHaveBeenCalledWith(sourceId); + }); + + // ── Clear button visible only when ≥1 source selected (scenario 27) ──────── + + it('shows "All sources" clear button when selectedSourceIds is non-empty', () => { + const sourceId = 'src-bank-1'; + const breakdown = buildBreakdownWithSourcedWI({ + budgetSourceId: sourceId, + budgetSources: [buildSourceSummary({ id: sourceId, name: 'Bank Loan' })], + }); + + const { container } = renderWithRouter(breakdown, buildOverview(), { + selectedSourceIds: new Set([sourceId]), + }); + + // Expand Available Funds to reveal chip strip + fireEvent.click(getButtonByControls(container, 'avail-funds')); + + // Clear/All sources button should be visible + const clearBtn = screen.getByRole('button', { name: /All sources/i }); + expect(clearBtn).toBeInTheDocument(); + }); + + it('does not show "All sources" clear button when selectedSourceIds is empty', () => { + const sourceId = 'src-bank-1'; + const breakdown = buildBreakdownWithSourcedWI({ + budgetSourceId: sourceId, + budgetSources: [buildSourceSummary({ id: sourceId, name: 'Bank Loan' })], + }); + + const { container } = renderWithRouter(breakdown, buildOverview(), { + selectedSourceIds: new Set(), + }); + + // Expand Available Funds + fireEvent.click(getButtonByControls(container, 'avail-funds')); + + // Clear button should NOT be present + expect(screen.queryByRole('button', { name: /All sources/i })).not.toBeInTheDocument(); + }); + + // ── Filter empty state (scenario 28) ───────────────────────────────────── + + it('shows empty state message when filter is active but no lines match', () => { + const sourceId = 'src-bank-1'; + const breakdown = buildBreakdownWithSourcedWI({ + budgetSourceId: null, // line has NO source + budgetSources: [buildSourceSummary({ id: sourceId, name: 'Bank Loan' })], + }); + + // Select 'src-bank-1' but the line has budgetSourceId=null → no match + renderWithRouter(breakdown, buildOverview(), { + selectedSourceIds: new Set([sourceId]), + }); + + // The empty state should appear with the "no lines match" message + expect( + screen.getByText(/No budget lines match the selected source filter/i), + ).toBeInTheDocument(); + }); + + // ── Clear sources calls onClearSources (scenario 27b) ───────────────────── + + it('calls onClearSources when the clear button is clicked', () => { + const sourceId = 'src-bank-1'; + const onClearSources = jest.fn(); + const breakdown = buildBreakdownWithSourcedWI({ + budgetSourceId: sourceId, + budgetSources: [buildSourceSummary({ id: sourceId, name: 'Bank Loan' })], + }); + + const { container } = renderWithRouter(breakdown, buildOverview(), { + selectedSourceIds: new Set([sourceId]), + onClearSources, + }); + + // Expand Available Funds to reveal chip strip + clear button + fireEvent.click(getButtonByControls(container, 'avail-funds')); + + const clearBtn = screen.getByRole('button', { name: /All sources/i }); + fireEvent.click(clearBtn); + + expect(onClearSources).toHaveBeenCalledTimes(1); + }); + + // ── Selected chip shows aria-pressed="true" ─────────────────────────────── + + it('selected chip has aria-pressed="true"', () => { + const sourceId = 'src-bank-1'; + const breakdown = buildBreakdownWithSourcedWI({ + budgetSourceId: sourceId, + budgetSources: [buildSourceSummary({ id: sourceId, name: 'Bank Loan' })], + }); + + const { container } = renderWithRouter(breakdown, buildOverview(), { + selectedSourceIds: new Set([sourceId]), + }); + + // Expand Available Funds + fireEvent.click(getButtonByControls(container, 'avail-funds')); + + // The selected chip should be pressed + const chipBtn = screen.getByRole('button', { name: /Filter: Bank Loan.*selected/i }); + expect(chipBtn).toHaveAttribute('aria-pressed', 'true'); + }); + + // ── Escape key handler (Escape clears source filter via toolbar keyDown) ── + + it('pressing Escape in the chip toolbar when a source is selected calls onClearSources', () => { + const sourceId = 'src-esc-1'; + const onClearSources = jest.fn(); + const breakdown = buildBreakdownWithSourcedWI({ + budgetSourceId: sourceId, + budgetSources: [buildSourceSummary({ id: sourceId, name: 'Credit Line' })], + }); + + const { container } = renderWithRouter(breakdown, buildOverview(), { + selectedSourceIds: new Set([sourceId]), + onClearSources, + }); + + // Expand Available Funds to show the chip toolbar + fireEvent.click(getButtonByControls(container, 'avail-funds')); + + // The toolbar should be visible + const toolbar = screen.getByRole('toolbar'); + expect(toolbar).toBeInTheDocument(); + + // Fire Escape key on the toolbar — should call onClearSources + fireEvent.keyDown(toolbar, { key: 'Escape' }); + expect(onClearSources).toHaveBeenCalledTimes(1); + }); + + it('pressing Escape in the chip toolbar when no source is selected does not call onClearSources', () => { + const sourceId = 'src-esc-2'; + const onClearSources = jest.fn(); + const breakdown = buildBreakdownWithSourcedWI({ + budgetSourceId: sourceId, + budgetSources: [buildSourceSummary({ id: sourceId, name: 'Savings' })], + }); + + const { container } = renderWithRouter(breakdown, buildOverview(), { + selectedSourceIds: new Set(), // no source selected + onClearSources, + }); + + // Expand Available Funds to show the chip toolbar + fireEvent.click(getButtonByControls(container, 'avail-funds')); + + const toolbar = screen.getByRole('toolbar'); + + // Escape with no selected sources should NOT call onClearSources + fireEvent.keyDown(toolbar, { key: 'Escape' }); + expect(onClearSources).not.toHaveBeenCalled(); + }); }); diff --git a/client/src/components/CostBreakdownTable/CostBreakdownTable.tsx b/client/src/components/CostBreakdownTable/CostBreakdownTable.tsx index 0bae0bd17..08556297f 100644 --- a/client/src/components/CostBreakdownTable/CostBreakdownTable.tsx +++ b/client/src/components/CostBreakdownTable/CostBreakdownTable.tsx @@ -1,4 +1,4 @@ -import { useState, useRef, createContext, useContext, useMemo } from 'react'; +import { useState, useRef, createContext, useContext, useMemo, useCallback } from 'react'; import { Link } from 'react-router-dom'; import { useTranslation } from 'react-i18next'; import type { @@ -9,12 +9,18 @@ import type { BreakdownBudgetLine, BreakdownHouseholdItem, ConfidenceLevel, - BudgetSource, SubsidyAdjustment, + BudgetSourceSummaryBreakdown, } from '@cornerstone/shared'; import { CONFIDENCE_MARGINS } from '@cornerstone/shared'; import { useFormatters } from '../../lib/formatters.js'; import { usePrintExpansion } from '../../hooks/usePrintExpansion.js'; +import { BudgetSourceChip } from '../BudgetSourceChip/index.js'; +import { Badge } from '../Badge/Badge.js'; +import badgeStyles from '../Badge/Badge.module.css'; +import { EmptyState } from '../EmptyState/EmptyState.js'; +import { getSourceColorIndex, getSourceBadgeStyleKey } from '../../lib/budgetSourceColors.js'; +import sharedStyles from '../../styles/shared.module.css'; import styles from './CostBreakdownTable.module.css'; // Context to pass formatCurrency down to sub-components that aren't React components (can't use hooks) @@ -28,12 +34,31 @@ function useFormatterContext() { return formatter; } +// Context for source filter state +interface BreakdownContextValue { + budgetSources: BudgetSourceSummaryBreakdown[]; + hasSourceFilter: boolean; + visibleLineIds: Set; +} + +const BreakdownContext = createContext(null); + +function useBreakdownContext() { + const context = useContext(BreakdownContext); + if (!context) { + throw new Error('useBreakdownContext must be used within CostBreakdownTable'); + } + return context; +} + type CostPerspective = 'min' | 'max' | 'avg'; interface CostBreakdownTableProps { breakdown: BudgetBreakdown; overview: BudgetOverview; - budgetSources: BudgetSource[]; + selectedSourceIds: Set; + onSourceToggle: (sourceId: string | null) => void; + onClearSources: () => void; } /** @@ -167,12 +192,19 @@ function BudgetLineRow({ }) { const { t } = useTranslation('budget'); const formatCurrencyFn = useFormatterContext(); + const { hasSourceFilter, visibleLineIds, budgetSources } = useBreakdownContext(); + + // Return null if this line is filtered out + if (hasSourceFilter && !visibleLineIds.has(line.id)) { + return null; + } + const key = `line-${line.id}`; const margin = CONFIDENCE_MARGINS[line.confidence]; const costMin = line.plannedAmount * (1 - margin); const costMax = line.plannedAmount * (1 + margin); const perspectiveValue = resolveProjected(costMin, costMax, perspective); - const rowClassName = styles.rowLevel3; + const rowClassName = `${styles.rowLevel3}${hasSourceFilter && visibleLineIds.has(line.id) ? ` ${styles.rowFiltered}` : ''}`; // Calculate quoted range (±5%) const quotedMin = line.actualCost * 0.95; @@ -185,6 +217,14 @@ function BudgetLineRow({ ? line.actualCost : perspectiveValue; + // Get source badge info + const sourceId = line.budgetSourceId ?? null; + const sourceName: string = budgetSources.find((s) => s.id === sourceId)?.name + ?? t('overview.costBreakdown.sourceFilter.unassigned'); + const styleKey = getSourceBadgeStyleKey(sourceId); + const isTruncated = sourceName.length > 20; + const label = isTruncated ? `${sourceName.slice(0, 20)}…` : sourceName; + return ( )} +
@@ -625,12 +678,16 @@ function ChevronSvg({ className }: { className: string }) { export function CostBreakdownTable({ breakdown, overview, - budgetSources, + selectedSourceIds, + onSourceToggle, + onClearSources, }: CostBreakdownTableProps) { const { t } = useTranslation('budget'); const { formatCurrency } = useFormatters(); const [expandedKeys, setExpandedKeys] = useState>(new Set()); const [perspective, setPerspective] = useState('avg'); + const availFundsButtonRef = useRef(null); + const budgetSources: BudgetSourceSummaryBreakdown[] = breakdown.budgetSources ?? []; const toggle = (key: string) => { const next = new Set(expandedKeys); @@ -728,6 +785,128 @@ export function CostBreakdownTable({ */ const sum = overview.availableFunds - totalRawProjected + adjustedTotalPayback; + // Source filter state + const hasSourceFilter = selectedSourceIds.size > 0; + + // Derive visible line IDs from filter + const visibleLineIds = useMemo>(() => { + if (!hasSourceFilter) return new Set(); // empty = all visible (optimization) + const ids = new Set(); + // Walk all lines across all areas + function collectWi(areas: BreakdownArea[]) { + for (const area of areas) { + for (const item of area.items) { + for (const line of item.budgetLines) { + const lineKey = line.budgetSourceId ?? 'unassigned'; + if (selectedSourceIds.has(lineKey)) ids.add(line.id); + } + } + collectWi(area.children); + } + } + function collectHi(areas: BreakdownArea[]) { + for (const area of areas) { + for (const item of area.items) { + for (const line of item.budgetLines) { + const lineKey = line.budgetSourceId ?? 'unassigned'; + if (selectedSourceIds.has(lineKey)) ids.add(line.id); + } + } + collectHi(area.children); + } + } + collectWi(wiAreas); + collectHi(hiAreas); + return ids; + }, [hasSourceFilter, selectedSourceIds, wiAreas, hiAreas]); + + // Compute filtered grand totals (Sum + Remaining Budget rows only) + // Individual area/item rows use backend-computed values. + const filteredRawProjected = useMemo(() => { + if (!hasSourceFilter) return totalRawProjected; + // Sum perspective-resolved cost of visible lines only + let total = 0; + function walkWi(areas: BreakdownArea[]) { + for (const area of areas) { + for (const item of area.items) { + for (const line of item.budgetLines) { + if (!visibleLineIds.has(line.id)) continue; + const margin = CONFIDENCE_MARGINS[line.confidence]; + const costMin = line.plannedAmount * (1 - margin); + const costMax = line.plannedAmount * (1 + margin); + total += line.hasInvoice + ? line.isQuotation + ? resolveProjected(line.actualCost * 0.95, line.actualCost * 1.05, perspective) + : line.actualCost + : resolveProjected(costMin, costMax, perspective); + } + } + walkWi(area.children); + } + } + function walkHi(areas: BreakdownArea[]) { + for (const area of areas) { + for (const item of area.items) { + for (const line of item.budgetLines) { + if (!visibleLineIds.has(line.id)) continue; + const margin = CONFIDENCE_MARGINS[line.confidence]; + const costMin = line.plannedAmount * (1 - margin); + const costMax = line.plannedAmount * (1 + margin); + total += line.hasInvoice + ? line.isQuotation + ? resolveProjected(line.actualCost * 0.95, line.actualCost * 1.05, perspective) + : line.actualCost + : resolveProjected(costMin, costMax, perspective); + } + } + walkHi(area.children); + } + } + walkWi(wiAreas); + walkHi(hiAreas); + return total; + }, [hasSourceFilter, visibleLineIds, wiAreas, hiAreas, perspective]); + + // Total line count for screen reader announcements + const totalLineCount = useMemo(() => { + let count = 0; + function countWi(areas: BreakdownArea[]) { + for (const area of areas) { + for (const item of area.items) { + count += item.budgetLines.length; + } + countWi(area.children); + } + } + function countHi(areas: BreakdownArea[]) { + for (const area of areas) { + for (const item of area.items) { + count += item.budgetLines.length; + } + countHi(area.children); + } + } + countWi(wiAreas); + countHi(hiAreas); + return count; + }, [wiAreas, hiAreas]); + + // Check if any lines are unassigned + const hasUnassignedLines = useMemo(() => { + function check(areas: BreakdownArea[]): boolean { + for (const area of areas) { + for (const item of area.items) { + for (const line of item.budgetLines) { + if (line.budgetSourceId === null) return true; + } + } + if (check(area.children)) return true; + } + return false; + } + return check([...(wiAreas as BreakdownArea[]), ...(hiAreas as BreakdownArea[])]); + }, [wiAreas, hiAreas]); + // Empty state const hasData = wiAreas.length > 0 || hiAreas.length > 0; @@ -749,8 +928,23 @@ export function CostBreakdownTable({ const hiSectionExpanded = expandedKeys.has(hiSectionKey); const availFundsExpanded = expandedKeys.has(availFundsKey); + function handleToolbarKeyDown(e: React.KeyboardEvent) { + if (e.key === 'Escape' && selectedSourceIds.size > 0) { + e.preventDefault(); + onClearSources(); + availFundsButtonRef.current?.focus(); + } + } + return ( +

{t('overview.costBreakdown.title')} @@ -779,7 +973,7 @@ export function CostBreakdownTable({ {/* ===== COST SECTION (with column tints) ===== */} - + {/* Work Item Budget row (expandable) */} {wiAreas.length > 0 && ( <> @@ -1011,7 +1205,10 @@ export function CostBreakdownTable({ - {formatCost(totalRawProjected, formatCurrency)} + {formatCost( + hasSourceFilter ? filteredRawProjected : totalRawProjected, + formatCurrency, + )} @@ -1024,7 +1221,12 @@ export function CostBreakdownTable({ )} - {renderNet(totalRawProjected, adjustedTotalPayback, styles, formatCurrency)} + {renderNet( + hasSourceFilter ? filteredRawProjected : totalRawProjected, + adjustedTotalPayback, + styles, + formatCurrency, + )} @@ -1034,6 +1236,7 @@ export function CostBreakdownTable({
{budgetSources.length > 0 && ( + )} +
+ + + )} + + {/* Enhanced source detail rows */} {availFundsExpanded && - budgetSources.map((source: BudgetSource) => ( - - -
- {source.name} -
- - - {formatCurrency(source.totalAmount)} - - - ))} + budgetSources.map((source: BudgetSourceSummaryBreakdown) => { + const colorIndex = getSourceColorIndex(source.id); + const isSelected = selectedSourceIds.has(source.id); + const allocatedCost = resolveProjected(source.projectedMin, source.projectedMax, perspective); + const remaining = source.totalAmount - allocatedCost; + const rowStyle = { '--chip-dot': `var(--color-source-${colorIndex}-dot)` } as React.CSSProperties; + return ( + + +
+
+ + {formatCurrency(source.totalAmount)} + + -{formatCurrency(allocatedCost)} + + + = 0 ? styles.valuePositive : styles.valueNegative}> + {formatCurrency(remaining)} + + + + ); + })} {/* Remaining Budget row */} @@ -1078,25 +1347,61 @@ export function CostBreakdownTable({ = 0 + overview.availableFunds - (hasSourceFilter ? filteredRawProjected : totalRawProjected) >= 0 ? styles.valuePositive : styles.valueNegative } > - {formatCurrency(overview.availableFunds - totalRawProjected)} + {formatCurrency( + overview.availableFunds - (hasSourceFilter ? filteredRawProjected : totalRawProjected), + )} - = 0 ? styles.valuePositive : styles.valueNegative}> - {formatCurrency(sum)} + = 0 + ? styles.valuePositive + : styles.valueNegative + } + > + {formatCurrency( + overview.availableFunds - (hasSourceFilter ? filteredRawProjected : totalRawProjected) + adjustedTotalPayback, + )} + + {/* Empty state when filter returns no results */} + {hasSourceFilter && visibleLineIds.size === 0 && ( + + + + + + + + )} + + {/* Screen reader live region for filter announcements */} +
+ {hasSourceFilter + ? t('overview.costBreakdown.sourceFilter.activeAnnouncement', { + count: String(visibleLineIds.size), + total: String(totalLineCount), + sources: [...selectedSourceIds].join(', '), + }) + : t('overview.costBreakdown.sourceFilter.allSourcesAnnouncement')} +

+
); } diff --git a/client/src/i18n/de/budget.json b/client/src/i18n/de/budget.json index 12ed4ac88..2abd32265 100644 --- a/client/src/i18n/de/budget.json +++ b/client/src/i18n/de/budget.json @@ -67,6 +67,26 @@ "unassigned": "Kein Bereich", "expandWorkItemsLabel": "Arbeitspakete nach Bereich aufklappen", "expandHouseholdItemsLabel": "Haushaltsartikel nach Bereich aufklappen" + }, + "sourceFilter": { + "label": "Nach Quelle filtern", + "allSources": "Alle Quellen", + "clear": "Filter Löschen", + "clearAriaLabel": "Quellenfilter löschen – alle Quellen anzeigen", + "unassigned": "Nicht zugewiesen", + "activeAnnouncement": "Es werden {{count}} von {{total}} Budgetpositionen angezeigt", + "allSourcesAnnouncement": "Es werden alle Budgetpositionen angezeigt", + "empty": "Keine Budgetpositionen entsprechen dem ausgewählten Quellenfilter.", + "chipSelected": "Filter: {{name}} (ausgewählt)", + "chipNotSelected": "Filter: {{name}} (nicht ausgewählt)" + }, + "sourceImpact": { + "allocated": "Zugeordnet", + "remaining": "Verbleibend" + }, + "sourceBadge": { + "ariaLabel": "Budgetquelle: {{name}}", + "unassigned": "Nicht zugewiesen" } } }, diff --git a/client/src/i18n/en/budget.json b/client/src/i18n/en/budget.json index ed58e48ef..7a13705a5 100644 --- a/client/src/i18n/en/budget.json +++ b/client/src/i18n/en/budget.json @@ -67,6 +67,26 @@ "unassigned": "No Area", "expandWorkItemsLabel": "Expand work item budget by area", "expandHouseholdItemsLabel": "Expand household item budget by area" + }, + "sourceFilter": { + "label": "Filter by source", + "allSources": "All sources", + "clear": "Clear filters", + "clearAriaLabel": "Clear source filter — show all sources", + "unassigned": "Unassigned", + "activeAnnouncement": "Showing {{count}} of {{total}} budget lines", + "allSourcesAnnouncement": "Showing all budget lines", + "empty": "No budget lines match the selected source filter.", + "chipSelected": "Filter: {{name}} (selected)", + "chipNotSelected": "Filter: {{name}} (not selected)" + }, + "sourceImpact": { + "allocated": "Allocated", + "remaining": "Remaining" + }, + "sourceBadge": { + "ariaLabel": "Budget source: {{name}}", + "unassigned": "Unassigned" } } }, diff --git a/client/src/lib/budgetSourceColors.test.ts b/client/src/lib/budgetSourceColors.test.ts new file mode 100644 index 000000000..fbfea6f3b --- /dev/null +++ b/client/src/lib/budgetSourceColors.test.ts @@ -0,0 +1,232 @@ +import { describe, it, expect } from '@jest/globals'; +import { getSourceColorIndex, getSourceBadgeStyleKey } from './budgetSourceColors.js'; + +describe('getSourceColorIndex', () => { + // ── 9. null → 0 (unassigned slot) ───────────────────────────────────────── + + it('returns 0 for null sourceId', () => { + expect(getSourceColorIndex(null)).toBe(0); + }); + + // ── 10. Always in [1, 9] ────────────────────────────────────────────────── + + it('returns a value in [1, 9] for a single UUID', () => { + const result = getSourceColorIndex('a1b2c3d4-e5f6-7890-abcd-ef1234567890'); + expect(result).toBeGreaterThanOrEqual(1); + expect(result).toBeLessThanOrEqual(9); + }); + + it('returns a value in [1, 9] for 50 random-like UUID inputs', () => { + const uuids = [ + 'f47ac10b-58cc-4372-a567-0e02b2c3d479', + '550e8400-e29b-41d4-a716-446655440000', + '6ba7b810-9dad-11d1-80b4-00c04fd430c8', + '6ba7b811-9dad-11d1-80b4-00c04fd430c8', + '6ba7b812-9dad-11d1-80b4-00c04fd430c8', + '00000000-0000-0000-0000-000000000001', + '00000000-0000-0000-0000-000000000002', + '00000000-0000-0000-0000-000000000003', + '00000000-0000-0000-0000-000000000004', + '00000000-0000-0000-0000-000000000005', + '11111111-1111-1111-1111-111111111111', + '22222222-2222-2222-2222-222222222222', + '33333333-3333-3333-3333-333333333333', + '44444444-4444-4444-4444-444444444444', + '55555555-5555-5555-5555-555555555555', + '66666666-6666-6666-6666-666666666666', + '77777777-7777-7777-7777-777777777777', + '88888888-8888-8888-8888-888888888888', + '99999999-9999-9999-9999-999999999999', + 'aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa', + 'bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb', + 'cccccccc-cccc-cccc-cccc-cccccccccccc', + 'dddddddd-dddd-dddd-dddd-dddddddddddd', + 'eeeeeeee-eeee-eeee-eeee-eeeeeeeeeeee', + 'ffffffff-ffff-ffff-ffff-ffffffffffff', + 'a0000000-0000-0000-0000-000000000000', + 'b0000000-0000-0000-0000-000000000000', + 'c0000000-0000-0000-0000-000000000000', + 'd0000000-0000-0000-0000-000000000000', + 'e0000000-0000-0000-0000-000000000000', + 'src-bank-loan-001', + 'src-equity-002', + 'src-grant-003', + 'src-subsidy-004', + 'src-mortgage-005', + 'src-personal-006', + 'src-crowdfund-007', + 'src-bond-008', + 'src-pension-009', + 'src-savings-010', + 'short', + 'a', + 'ab', + 'abc', + 'abcd', + 'abcde', + 'abcdef', + 'abcdefg', + 'abcdefgh', + 'abcdefghi', + ]; + + for (const uuid of uuids) { + const result = getSourceColorIndex(uuid); + expect(result).toBeGreaterThanOrEqual(1); + expect(result).toBeLessThanOrEqual(9); + } + }); + + it('never returns 0 (slot 0 is reserved for unassigned)', () => { + const inputs = [ + 'a', + 'b', + 'c', + 'd', + 'e', + 'f', + 'g', + 'h', + 'i', + 'j', + 'k', + 'test-id', + 'uuid-123', + 'longer-source-id-here', + ]; + for (const input of inputs) { + expect(getSourceColorIndex(input)).not.toBe(0); + } + }); + + // ── 11. Deterministic (same input → same output) ─────────────────────────── + + it('returns the same value for the same input across 100 calls', () => { + const sourceId = 'f47ac10b-58cc-4372-a567-0e02b2c3d479'; + const firstResult = getSourceColorIndex(sourceId); + + for (let i = 0; i < 99; i++) { + expect(getSourceColorIndex(sourceId)).toBe(firstResult); + } + }); + + it('returns the same value for a short string input across 100 calls', () => { + const sourceId = 'my-source'; + const firstResult = getSourceColorIndex(sourceId); + + for (let i = 0; i < 99; i++) { + expect(getSourceColorIndex(sourceId)).toBe(firstResult); + } + }); + + it('produces different results for different inputs (hash distribution)', () => { + // While not guaranteed, these specific test IDs do produce distinct results + const results = new Set([ + getSourceColorIndex('src-bank-loan-001'), + getSourceColorIndex('src-equity-002'), + getSourceColorIndex('src-grant-003'), + ]); + // We just verify each is in range, not that they're all distinct (hash collisions are valid) + for (const r of results) { + expect(r).toBeGreaterThanOrEqual(1); + expect(r).toBeLessThanOrEqual(9); + } + }); + + // ── Handles single-char and empty-ish inputs ─────────────────────────────── + + it('handles a single character input without throwing', () => { + expect(() => getSourceColorIndex('x')).not.toThrow(); + const result = getSourceColorIndex('x'); + expect(result).toBeGreaterThanOrEqual(1); + expect(result).toBeLessThanOrEqual(9); + }); + + it('handles a long string input without throwing', () => { + const long = 'a'.repeat(1000); + expect(() => getSourceColorIndex(long)).not.toThrow(); + const result = getSourceColorIndex(long); + expect(result).toBeGreaterThanOrEqual(1); + expect(result).toBeLessThanOrEqual(9); + }); +}); + +describe('getSourceBadgeStyleKey', () => { + // ── 12. null → 'sourceUnassigned' ────────────────────────────────────────── + + it("returns 'sourceUnassigned' for null sourceId", () => { + expect(getSourceBadgeStyleKey(null)).toBe('sourceUnassigned'); + }); + + // ── 13. Named source → 'source1'–'source9' (never 'source0') ─────────────── + + it("returns a key in the 'source1'–'source9' range for a non-null sourceId", () => { + const validKeys = new Set([ + 'source1', + 'source2', + 'source3', + 'source4', + 'source5', + 'source6', + 'source7', + 'source8', + 'source9', + ]); + + const testIds = [ + 'f47ac10b-58cc-4372-a567-0e02b2c3d479', + '550e8400-e29b-41d4-a716-446655440000', + 'src-bank-loan-001', + 'a', + 'x', + 'longer-source-id-that-maps-somewhere', + ]; + + for (const id of testIds) { + const key = getSourceBadgeStyleKey(id); + expect(validKeys.has(key)).toBe(true); + } + }); + + it("never returns 'source0' for a non-null sourceId", () => { + const testIds = [ + 'f47ac10b-58cc-4372-a567-0e02b2c3d479', + '550e8400-e29b-41d4-a716-446655440000', + 'src-bank-loan-001', + 'a', + 'z', + '0', + '9', + 'src', + ]; + + for (const id of testIds) { + expect(getSourceBadgeStyleKey(id)).not.toBe('source0'); + } + }); + + it('returns source key consistent with getSourceColorIndex', () => { + const sourceId = 'src-test-stable'; + const colorIndex = getSourceColorIndex(sourceId); + const expectedKey = `source${colorIndex}`; + expect(getSourceBadgeStyleKey(sourceId)).toBe(expectedKey); + }); + + it('is deterministic: same input returns same key across 100 calls', () => { + const sourceId = 'src-reproducible-456'; + const firstKey = getSourceBadgeStyleKey(sourceId); + for (let i = 0; i < 99; i++) { + expect(getSourceBadgeStyleKey(sourceId)).toBe(firstKey); + } + }); + + it('handles a single character input', () => { + const key = getSourceBadgeStyleKey('a'); + expect(key).toMatch(/^source[1-9]$/); + }); + + it('handles a long UUID-like input', () => { + const key = getSourceBadgeStyleKey('aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee'); + expect(key).toMatch(/^source[1-9]$/); + }); +}); diff --git a/client/src/lib/budgetSourceColors.ts b/client/src/lib/budgetSourceColors.ts new file mode 100644 index 000000000..c571468a8 --- /dev/null +++ b/client/src/lib/budgetSourceColors.ts @@ -0,0 +1,31 @@ +/** + * Deterministic color index for budget sources. + * Uses a stable string hash so UUIDs map consistently to one of 10 color slots. + * Slot 0 is reserved for "Unassigned" lines (budgetSourceId === null). + * Slots 1–9 are used for named sources. + * + * @param sourceId - Budget source UUID string, or null for unassigned lines + * @returns 0 for null (unassigned), or 1–9 for named sources + */ +export function getSourceColorIndex(sourceId: string | null): number { + if (sourceId === null) return 0; + let hash = 0; + for (let i = 0; i < sourceId.length; i++) { + // eslint-disable-next-line no-bitwise + hash = (hash * 31 + sourceId.charCodeAt(i)) | 0; + } + // Ensure result is in [1, 9] for named sources (slot 0 is reserved for unassigned) + return (Math.abs(hash) % 9) + 1; +} + +/** + * Returns the CSS module class name for the source badge slot. + * Pass sourceId === null for "Unassigned", or a source UUID string for named sources. + */ +export type SourceBadgeStyleKey = `source${0 | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9}` | 'sourceUnassigned'; + +export function getSourceBadgeStyleKey(sourceId: string | null): SourceBadgeStyleKey { + if (sourceId === null) return 'sourceUnassigned'; + const index = getSourceColorIndex(sourceId); + return `source${index}` as SourceBadgeStyleKey; +} diff --git a/client/src/pages/BudgetOverviewPage/BudgetOverviewPage.test.tsx b/client/src/pages/BudgetOverviewPage/BudgetOverviewPage.test.tsx index 279f8b193..7b55dc244 100644 --- a/client/src/pages/BudgetOverviewPage/BudgetOverviewPage.test.tsx +++ b/client/src/pages/BudgetOverviewPage/BudgetOverviewPage.test.tsx @@ -8,7 +8,7 @@ import { MemoryRouter, useLocation } from 'react-router-dom'; import type * as BudgetOverviewApiTypes from '../../lib/budgetOverviewApi.js'; import type * as BudgetSourcesApiTypes from '../../lib/budgetSourcesApi.js'; import { ApiClientError } from '../../lib/apiClient.js'; -import type { BudgetOverview, BudgetSource } from '@cornerstone/shared'; +import type { BudgetOverview } from '@cornerstone/shared'; // Mock the API modules BEFORE importing the component const mockFetchBudgetOverview = jest.fn(); @@ -146,41 +146,6 @@ describe('BudgetOverviewPage', () => { }, }; - /** - * Build a minimal BudgetSource for tests. - */ - function buildBudgetSource( - opts: { - id?: string; - name?: string; - totalAmount?: number; - } = {}, - ): BudgetSource { - return { - id: opts.id ?? 'src-1', - name: opts.name ?? 'Bank Loan', - sourceType: 'bank_loan', - totalAmount: opts.totalAmount ?? 80000, - usedAmount: 0, - availableAmount: opts.totalAmount ?? 80000, - claimedAmount: 0, - unclaimedAmount: 0, - actualAvailableAmount: opts.totalAmount ?? 80000, - paidAmount: 0, - projectedAmount: 0, - projectedMinAmount: 0, - projectedMaxAmount: 0, - isDiscretionary: false, - interestRate: null, - terms: null, - notes: null, - status: 'active', - createdBy: null, - createdAt: '2025-01-01T00:00:00.000Z', - updatedAt: '2025-01-01T00:00:00.000Z', - }; - } - /** Empty breakdown returned by default in all tests */ const emptyBreakdown = { workItems: { @@ -208,6 +173,7 @@ describe('BudgetOverviewPage', () => { }, }, subsidyAdjustments: [], + budgetSources: [], }; beforeEach(async () => { @@ -781,22 +747,17 @@ describe('BudgetOverviewPage', () => { expect(screen.queryByRole('alert')).not.toBeInTheDocument(); }); - // Scenario 30: budgetSources prop on CostBreakdownTable matches fetchBudgetSources data - it('passes budgetSources returned by fetchBudgetSources to CostBreakdownTable', async () => { - const sources = [ - buildBudgetSource({ id: 'src-1', name: 'Savings Account', totalAmount: 50000 }), - buildBudgetSource({ id: 'src-2', name: 'Bank Loan', totalAmount: 80000 }), - ]; - + // Scenario 30: breakdown.budgetSources drives the Available Funds expand button in CostBreakdownTable + it('shows Available Funds expand button when breakdown contains budget sources', async () => { const overviewWithData: BudgetOverview = { ...richOverview, availableFunds: 130000, }; mockFetchBudgetOverview.mockResolvedValueOnce(overviewWithData); - mockFetchBudgetSources.mockResolvedValueOnce({ budgetSources: sources }); + mockFetchBudgetSources.mockResolvedValueOnce({ budgetSources: [] }); - // Provide a non-empty breakdown so the CostBreakdownTable renders + // Provide a non-empty breakdown with budget sources so the expand button appears mockFetchBudgetBreakdown.mockResolvedValueOnce({ workItems: { areas: [ @@ -839,6 +800,10 @@ describe('BudgetOverviewPage', () => { }, }, subsidyAdjustments: [], + budgetSources: [ + { id: 'src-1', name: 'Savings Account', totalAmount: 50000, projectedMin: 0, projectedMax: 0 }, + { id: 'src-2', name: 'Bank Loan', totalAmount: 80000, projectedMin: 0, projectedMax: 0 }, + ], }); renderPage(); @@ -849,7 +814,7 @@ describe('BudgetOverviewPage', () => { }); // CostBreakdownTable should be visible with the "Available funds" expand button - // (only appears when budgetSources.length > 0) + // (only appears when breakdown.budgetSources.length > 0) await waitFor(() => { expect(screen.getByRole('button', { name: /expand available funds/i })).toBeInTheDocument(); }); diff --git a/client/src/pages/BudgetOverviewPage/BudgetOverviewPage.tsx b/client/src/pages/BudgetOverviewPage/BudgetOverviewPage.tsx index e8747dfe9..6452b11c2 100644 --- a/client/src/pages/BudgetOverviewPage/BudgetOverviewPage.tsx +++ b/client/src/pages/BudgetOverviewPage/BudgetOverviewPage.tsx @@ -1,5 +1,5 @@ -import { useState, useEffect, useRef, useCallback } from 'react'; -import { useNavigate } from 'react-router-dom'; +import { useState, useEffect, useRef, useCallback, useMemo } from 'react'; +import { useNavigate, useSearchParams } from 'react-router-dom'; import { useTranslation } from 'react-i18next'; import type { BudgetOverview, BudgetBreakdown, BudgetSource } from '@cornerstone/shared'; import { fetchBudgetOverview, fetchBudgetBreakdown } from '../../lib/budgetOverviewApi.js'; @@ -181,6 +181,47 @@ export function BudgetOverviewPage() { const [addOpen, setAddOpen] = useState(false); const addRef = useRef(null); + // Source filter state (from URL) + const [searchParams, setSearchParams] = useSearchParams(); + + // Derive selected source IDs from URL ?sources= param + // 'unassigned' is the literal key for null-source lines + const selectedSourceIds = useMemo>(() => { + const raw = searchParams.get('sources'); + if (!raw) return new Set(); + return new Set(raw.split(',').filter(Boolean)); + }, [searchParams]); + + const handleSourceToggle = useCallback( + (sourceId: string | null) => { + const key = sourceId ?? 'unassigned'; + setSearchParams((prev) => { + const current = new Set(prev.get('sources')?.split(',').filter(Boolean) ?? []); + if (current.has(key)) { + current.delete(key); + } else { + current.add(key); + } + const params = new URLSearchParams(prev); + if (current.size === 0) { + params.delete('sources'); + } else { + params.set('sources', [...current].join(',')); + } + return params; + }); + }, + [setSearchParams], + ); + + const handleClearSources = useCallback(() => { + setSearchParams((prev) => { + const params = new URLSearchParams(prev); + params.delete('sources'); + return params; + }); + }, [setSearchParams]); + // Close dropdown on outside click useEffect(() => { if (!addOpen) return; @@ -615,7 +656,9 @@ export function BudgetOverviewPage() { ) : null)} diff --git a/client/src/styles/tokens.css b/client/src/styles/tokens.css index 4f1e9959f..6dc45f2c6 100644 --- a/client/src/styles/tokens.css +++ b/client/src/styles/tokens.css @@ -354,6 +354,62 @@ /* Parent item card tint */ --color-surface-item-tint: var(--color-white); + /* ============================================================ + * LAYER 2 — BUDGET SOURCE BADGE TOKENS + * 10-slot palette: slot 0 is Unassigned (gray/neutral), + * slots 1–9 are distinct colors for named sources. + * ============================================================ */ + + /* Slot 0: Unassigned (gray) */ + --color-source-0-bg: var(--color-gray-200); + --color-source-0-text: var(--color-gray-700); + --color-source-0-dot: var(--color-gray-500); + + /* Slot 1: Blue */ + --color-source-1-bg: var(--color-blue-100); + --color-source-1-text: var(--color-blue-800); + --color-source-1-dot: var(--color-blue-500); + + /* Slot 2: Green */ + --color-source-2-bg: var(--color-green-100); + --color-source-2-text: var(--color-green-900); + --color-source-2-dot: var(--color-green-500); + + /* Slot 3: Red */ + --color-source-3-bg: var(--color-red-100); + --color-source-3-text: var(--color-red-700); + --color-source-3-dot: var(--color-red-500); + + /* Slot 4: Amber */ + --color-source-4-bg: var(--color-amber-100); + --color-source-4-text: var(--color-amber-800); + --color-source-4-dot: var(--color-amber-300); + + /* Slot 5: Purple */ + --color-source-5-bg: #e9d5ff; + --color-source-5-text: #6b21a8; + --color-source-5-dot: #a855f7; + + /* Slot 6: Cyan */ + --color-source-6-bg: #cffafe; + --color-source-6-text: #0c4a6e; + --color-source-6-dot: #06b6d4; + + /* Slot 7: Pink */ + --color-source-7-bg: var(--color-blue-100); + --color-source-7-text: var(--color-blue-800); + --color-source-7-dot: #ec4899; + + /* Slot 8: Teal */ + --color-source-8-bg: #ccfbf1; + --color-source-8-text: #134e4a; + --color-source-8-dot: #14b8a6; + + /* Slot 9: Orange */ + --color-source-9-bg: #fed7aa; + --color-source-9-text: #9a3412; + --color-source-9-dot: #f97316; + /* ============================================================ * LAYER 2 — GANTT CHART TOKENS * ============================================================ */ @@ -662,6 +718,47 @@ --color-hi-status-arrived-bg: rgba(16, 185, 129, 0.15); --color-hi-status-arrived-text: var(--color-emerald-200); + /* --- Budget source badge tokens (dark mode) --- */ + --color-source-0-bg: var(--color-slate-500); + --color-source-0-text: var(--color-slate-100); + --color-source-0-dot: var(--color-slate-300); + + --color-source-1-bg: rgba(59, 130, 246, 0.2); + --color-source-1-text: var(--color-blue-300); + --color-source-1-dot: var(--color-blue-300); + + --color-source-2-bg: rgba(16, 185, 129, 0.15); + --color-source-2-text: var(--color-emerald-200); + --color-source-2-dot: var(--color-emerald-400); + + --color-source-3-bg: rgba(239, 68, 68, 0.15); + --color-source-3-text: var(--color-red-300); + --color-source-3-dot: var(--color-red-400); + + --color-source-4-bg: rgba(245, 158, 11, 0.2); + --color-source-4-text: var(--color-amber-300); + --color-source-4-dot: var(--color-amber-300); + + --color-source-5-bg: rgba(168, 85, 247, 0.2); + --color-source-5-text: #e9d5ff; + --color-source-5-dot: #a855f7; + + --color-source-6-bg: rgba(34, 211, 238, 0.15); + --color-source-6-text: #cffafe; + --color-source-6-dot: #06b6d4; + + --color-source-7-bg: rgba(59, 130, 246, 0.2); + --color-source-7-text: var(--color-blue-300); + --color-source-7-dot: #ec4899; + + --color-source-8-bg: rgba(20, 184, 166, 0.15); + --color-source-8-text: #ccfbf1; + --color-source-8-dot: #14b8a6; + + --color-source-9-bg: rgba(249, 115, 22, 0.2); + --color-source-9-text: #fed7aa; + --color-source-9-dot: #f97316; + /* --- Shadows (deeper/darker in dark mode) --- */ --shadow-sm: 0 1px 3px rgba(0, 0, 0, 0.3), 0 1px 2px rgba(0, 0, 0, 0.2); --shadow-md: 0 4px 6px rgba(0, 0, 0, 0.3); diff --git a/e2e/pages/BudgetOverviewPage.ts b/e2e/pages/BudgetOverviewPage.ts index 688a6f893..2376f039f 100644 --- a/e2e/pages/BudgetOverviewPage.ts +++ b/e2e/pages/BudgetOverviewPage.ts @@ -17,6 +17,7 @@ import type { Page, Locator } from '@playwright/test'; export const BUDGET_OVERVIEW_ROUTE = '/budget/overview'; +export const BUDGET_OVERVIEW_URL_PATTERN = /\/budget\/overview/; export class BudgetOverviewPage { readonly page: Page; @@ -156,4 +157,61 @@ export class BudgetOverviewPage { get addButton(): Locator { return this.page.getByTestId('budget-overview-add-button'); } + + // ── Source filter helpers ───────────────────────────────────────────────── + + /** + * The source filter chip toolbar. + * i18n label: "Filter by source" + */ + filterToolbar(): Locator { + return this.costBreakdownCard.getByRole('toolbar', { name: 'Filter by source' }); + } + + /** + * A specific chip by source name (uses regex for partial match). + * Chips are