diff --git a/client/src/components/CostBreakdownTable/CostBreakdownTable.module.css b/client/src/components/CostBreakdownTable/CostBreakdownTable.module.css index 48a24381..6cbfde22 100644 --- a/client/src/components/CostBreakdownTable/CostBreakdownTable.module.css +++ b/client/src/components/CostBreakdownTable/CostBreakdownTable.module.css @@ -659,6 +659,11 @@ .rowLevel3 td { border-bottom: 1pt dotted var(--color-border); } + + /* Hide deselected source rows in print */ + .rowSourceDetailToggle[aria-pressed='false'] { + display: none !important; + } } /* ============================================================ diff --git a/client/src/components/CostBreakdownTable/CostBreakdownTable.test.tsx b/client/src/components/CostBreakdownTable/CostBreakdownTable.test.tsx index 0218d17a..d11d4574 100644 --- a/client/src/components/CostBreakdownTable/CostBreakdownTable.test.tsx +++ b/client/src/components/CostBreakdownTable/CostBreakdownTable.test.tsx @@ -274,6 +274,7 @@ function buildBreakdownWithWI( workItemId?: string; description?: string | null; hasInvoice?: boolean; + budgetSources?: BudgetSourceSummaryBreakdown[]; // Legacy params — kept for backward compat but ignored (area is always No Area) categoryId?: string | null; categoryName?: string; @@ -358,7 +359,7 @@ function buildBreakdownWithWI( }, }, subsidyAdjustments: [], - budgetSources: [], + budgetSources: opts.budgetSources ?? [], }; } @@ -593,9 +594,15 @@ describe('CostBreakdownTable', () => { // ── 16. Summary rows show totals ────────────────────────────────────────── it('shows Available funds row with formatted currency value', () => { + // budgetSources must include a source with totalAmount=50000 so that + // filteredAvailableFunds (sum of non-deselected sources) equals €50,000.00. + const breakdown = { + ...buildBreakdownWithWI({ projectedMin: 800, projectedMax: 1200 }), + budgetSources: [buildSourceSummary({ id: 'src-test', totalAmount: 50000 })], + }; render( {}} @@ -1087,7 +1094,7 @@ describe('CostBreakdownTable', () => { // availableFunds=100000, projectedMax=1200 → remaining = 98800 > 0 const { container } = render( {}} @@ -1887,6 +1894,7 @@ describe('CostBreakdownTable', () => { rawProjectedMax: 5000, subsidyPayback: 1200, minSubsidyPayback: 800, + budgetSources: [buildSourceSummary({ totalAmount: 10000 })], })} overview={buildOverview(10000)} deselectedSourceIds={new Set()} @@ -1913,6 +1921,7 @@ describe('CostBreakdownTable', () => { rawProjectedMax: 8000, subsidyPayback: 2000, minSubsidyPayback: 1000, + budgetSources: [buildSourceSummary({ totalAmount: 20000 })], })} overview={buildOverview(20000)} deselectedSourceIds={new Set()} @@ -1942,6 +1951,7 @@ describe('CostBreakdownTable', () => { rawProjectedMax: 8000, subsidyPayback: 2000, minSubsidyPayback: 1000, + budgetSources: [buildSourceSummary({ totalAmount: 20000 })], })} overview={buildOverview(20000)} deselectedSourceIds={new Set()} @@ -1971,6 +1981,7 @@ describe('CostBreakdownTable', () => { rawProjectedMax: 8000, subsidyPayback: 2000, minSubsidyPayback: 1000, + budgetSources: [buildSourceSummary({ totalAmount: 20000 })], })} overview={buildOverview(20000)} deselectedSourceIds={new Set()} @@ -2252,6 +2263,7 @@ describe('CostBreakdownTable', () => { projectedMax: 5000, rawProjectedMin: 3000, rawProjectedMax: 5000, + budgetSources: [buildSourceSummary({ totalAmount: 10000 })], })} overview={buildOverview(10000)} deselectedSourceIds={new Set()} @@ -2282,6 +2294,7 @@ describe('CostBreakdownTable', () => { rawProjectedMax: 5000, subsidyPayback: 200, minSubsidyPayback: 100, + budgetSources: [buildSourceSummary({ totalAmount: 10000 })], })} overview={buildOverview(10000)} deselectedSourceIds={new Set()} @@ -2308,6 +2321,7 @@ describe('CostBreakdownTable', () => { projectedMax: 5000, rawProjectedMin: 3000, rawProjectedMax: 5000, + budgetSources: [buildSourceSummary({ totalAmount: 10000 })], })} overview={buildOverview(10000)} deselectedSourceIds={new Set()} @@ -3850,11 +3864,15 @@ describe('Server-driven render path (#1360)', () => { }, subsidyAdjustments: [], budgetSources: [ - // Source A: selected (not in deselectedSourceIds), has payback + // Source A: selected (not in deselectedSourceIds), has payback. + // totalAmount=200000 so that filteredAvailableFunds = 200000 (matching + // overview.availableFunds) — this keeps Scenario 23 internally consistent + // now that Available Funds uses sum-of-visible-sources rather than + // overview.availableFunds directly. buildSourceSummary({ id: 'src-1360-a', name: 'Green Fund', - totalAmount: 100000, + totalAmount: 200000, projectedMin: 40000, projectedMax: 60000, subsidyPaybackMin: 500, @@ -3933,26 +3951,25 @@ describe('Server-driven render path (#1360)', () => { expect(costCell!.textContent?.replace(/\s+/g, '')).toBe('-€50,000.00'); }); - // ── Scenario 23: Remaining Budget row uses overview.availableFunds (not filtered) ─ - // With deselectedSourceIds=new Set(['src-1360-b']) (hypothetical — src-1360-b not in breakdown - // because server already excluded it), the row still reads: - // overview.availableFunds - totalRawProjected + adjustedTotalPayback - // = 200000 - 50000 + 750 = 150750 - // The point: remaining = overview.availableFunds (not a filtered sub-amount). - - it('Remaining Budget row uses overview.availableFunds directly (Scenario 23)', () => { + // ── Scenario 23: Remaining Budget row uses filteredAvailableFunds ──────────── + // filteredAvailableFunds = sum of budgetSources not in deselectedSourceIds. + // With deselectedSourceIds=new Set(['src-1360-b']) (hypothetical — src-1360-b is + // not present in the breakdown so no source is filtered out), the computation is: + // filteredAvailableFunds = src-1360-a.totalAmount(200000) + unassigned.totalAmount(0) = 200000 + // totalRawProjected avg = (40000+60000)/2 = 50000 (WI only, no HI) + // adjustedTotalPayback = resolvedTotalPayback - resolvedTotalExcess + // totalMinPayback = 500 (wiTotals) + 0 (hiTotals) = 500 + // totalMaxPayback = 1000 + 0 = 1000 + // resolvedTotalPayback (avg) = (500+1000)/2 = 750 + // no subsidyAdjustments → excess = 0 + // adjustedTotalPayback = 750 + // Remaining Net = 200000 - 50000 + 750 = 150750 → '€150,750.00' + + it('Remaining Budget row uses filteredAvailableFunds (Scenario 23)', () => { const breakdown = buildServerFilteredBreakdown(); - // availableFunds = 200000 - // totalRawProjected avg = (40000+60000)/2 = 50000 (WI only, no HI) - // adjustedTotalPayback = resolvedTotalPayback - resolvedTotalExcess - // totalMinPayback = 500 (wiTotals) + 0 (hiTotals) = 500 - // totalMaxPayback = 1000 + 0 = 1000 - // resolvedTotalPayback (avg) = (500+1000)/2 = 750 - // no subsidyAdjustments → excess = 0 - // adjustedTotalPayback = 750 - // Remaining Net = 200000 - 50000 + 750 = 150750 → '€150,750.00' + // filteredAvailableFunds = 200000 (src-1360-a.totalAmount=200000; src-1360-b not in sources) renderWithRouter(breakdown, buildOverview(200000), { - deselectedSourceIds: new Set(['src-1360-b']), // hypothetical deselection + deselectedSourceIds: new Set(['src-1360-b']), // hypothetical deselection (src not in breakdown) }); const remainingRow = screen.getByRole('row', { name: /remaining budget/i }); @@ -4030,3 +4047,426 @@ describe('Server-driven render path (#1360)', () => { expect(container.querySelector('table')).toBeInTheDocument(); }); }); + +// ── filteredAvailableFunds — Available Funds + Remaining Budget rows ────────── +// +// After the bug fix in #1362, the Available Funds row and Remaining Budget row +// use filteredAvailableFunds = sum of totalAmount for sources NOT in deselectedSourceIds, +// computed client-side from breakdown.budgetSources. These tests verify all three +// filter states: all selected, partial deselection, and all deselected. + +describe('filteredAvailableFunds — Available Funds row and Remaining Budget row (#1362)', () => { + /** + * Builds a two-source breakdown fixture for filter-aware summary row tests. + * + * Source A: id='src-a', name='Bank Loan', totalAmount=150000 + * Source B: id='src-b', name='Equity', totalAmount=100000 + * Total available funds (all selected): 250000 + * + * rawProjectedMin = rawProjectedMax = 50000 (so avg perspective = 50000 exactly, + * making Remaining Budget Cost = filteredAvailableFunds - 50000, deterministic). + * + * No subsidies → adjustedTotalPayback = 0. + */ + function buildTwoSourceBreakdown(): BudgetBreakdown { + return { + workItems: { + areas: [ + { + areaId: null, + name: 'Unassigned', + parentId: null, + color: null, + projectedMin: 50000, + projectedMax: 50000, + actualCost: 0, + subsidyPayback: 0, + rawProjectedMin: 50000, + rawProjectedMax: 50000, + minSubsidyPayback: 0, + items: [ + { + workItemId: 'wi-filter-1', + title: 'Foundation Work', + projectedMin: 50000, + projectedMax: 50000, + actualCost: 0, + subsidyPayback: 0, + rawProjectedMin: 50000, + rawProjectedMax: 50000, + minSubsidyPayback: 0, + costDisplay: 'projected', + budgetLines: [ + { + id: 'line-filter-1', + description: null, + plannedAmount: 50000, + confidence: 'own_estimate', + actualCost: 0, + hasInvoice: false, + isQuotation: false, + budgetSourceId: 'src-a', + }, + ], + }, + ], + children: [], + }, + ], + totals: { + projectedMin: 50000, + projectedMax: 50000, + actualCost: 0, + subsidyPayback: 0, + rawProjectedMin: 50000, + rawProjectedMax: 50000, + minSubsidyPayback: 0, + }, + }, + householdItems: { + areas: [], + totals: { + projectedMin: 0, + projectedMax: 0, + actualCost: 0, + subsidyPayback: 0, + rawProjectedMin: 0, + rawProjectedMax: 0, + minSubsidyPayback: 0, + }, + }, + subsidyAdjustments: [], + budgetSources: [ + buildSourceSummary({ + id: 'src-a', + name: 'Bank Loan', + totalAmount: 150000, + projectedMin: 40000, + projectedMax: 60000, + subsidyPaybackMin: 0, + subsidyPaybackMax: 0, + }), + buildSourceSummary({ + id: 'src-b', + name: 'Equity', + totalAmount: 100000, + projectedMin: 10000, + projectedMax: 10000, + subsidyPaybackMin: 0, + subsidyPaybackMax: 0, + }), + ], + }; + } + + // Helper: get the Available Funds row + function getAvailFundsRow(): HTMLElement { + return screen.getByText('Available funds').closest('tr') as HTMLElement; + } + + // Helper: get the Remaining Budget row + function getRemainingBudgetRow(): HTMLElement { + return screen.getByRole('row', { name: /remaining budget/i }); + } + + // ── Scenario 1: All sources selected (deselectedSourceIds = empty set) ─────── + // filteredAvailableFunds = 150000 + 100000 = 250000 + + it('Available Funds row displays full sum of all source totalAmounts when deselectedSourceIds is empty (Scenario 1)', () => { + const breakdown = buildTwoSourceBreakdown(); + renderWithRouter(breakdown, buildOverview(250000), { + deselectedSourceIds: new Set(), + }); + + const availFundsRow = getAvailFundsRow(); + const costCell = availFundsRow.querySelector('td[class*="colBudget"]'); + expect(costCell).not.toBeNull(); + // filteredAvailableFunds = 150000 + 100000 = 250000 + expect(costCell!.textContent?.replace(/\s+/g, '')).toContain('€250,000.00'); + }); + + // ── Scenario 2: One source deselected ──────────────────────────────────────── + // deselectedSourceIds = {'src-b'} → filteredAvailableFunds = 150000 only + + it('Available Funds row displays sum of selected sources only when one source is deselected (Scenario 2)', () => { + const breakdown = buildTwoSourceBreakdown(); + renderWithRouter(breakdown, buildOverview(250000), { + deselectedSourceIds: new Set(['src-b']), + }); + + const availFundsRow = getAvailFundsRow(); + const costCell = availFundsRow.querySelector('td[class*="colBudget"]'); + expect(costCell).not.toBeNull(); + // filteredAvailableFunds = 150000 (src-b excluded) + expect(costCell!.textContent?.replace(/\s+/g, '')).toContain('€150,000.00'); + // Must NOT contain the unfiltered total + expect(costCell!.textContent).not.toContain('€250,000.00'); + }); + + // ── Scenario 3: All sources deselected ──────────────────────────────────────── + // deselectedSourceIds = {'src-a', 'src-b'} → filteredAvailableFunds = 0 + + it('Available Funds row displays €0.00 when all sources are deselected (Scenario 3)', () => { + const breakdown = buildTwoSourceBreakdown(); + renderWithRouter(breakdown, buildOverview(250000), { + deselectedSourceIds: new Set(['src-a', 'src-b']), + }); + + const availFundsRow = getAvailFundsRow(); + const costCell = availFundsRow.querySelector('td[class*="colBudget"]'); + expect(costCell).not.toBeNull(); + // filteredAvailableFunds = 0 + expect(costCell!.textContent?.replace(/\s+/g, '')).toContain('€0.00'); + // Must NOT be NaN or undefined + expect(costCell!.textContent).not.toContain('NaN'); + expect(costCell!.textContent).not.toContain('undefined'); + // Must NOT show the unfiltered total + expect(costCell!.textContent).not.toContain('€250,000.00'); + }); + + // ── Scenario 4: Remaining Budget Cost column — all selected ────────────────── + // filteredAvailableFunds = 250000, totalRawProjected = 50000 (avg of 50000/50000) + // Remaining Budget Cost = 250000 - 50000 = 200000 + + it('Remaining Budget Cost column = filteredAvailableFunds - totalRawProjected when all sources selected (Scenario 4)', () => { + const breakdown = buildTwoSourceBreakdown(); + renderWithRouter(breakdown, buildOverview(250000), { + deselectedSourceIds: new Set(), + }); + + const remainingRow = getRemainingBudgetRow(); + const costCell = remainingRow.querySelector('td[class*="colBudget"]'); + expect(costCell).not.toBeNull(); + // 250000 - 50000 = 200000 + expect(costCell!.textContent?.replace(/\s+/g, '')).toContain('€200,000.00'); + }); + + // ── Scenario 5: Remaining Budget Cost column — one source deselected ───────── + // deselectedSourceIds = {'src-b'} → filteredAvailableFunds = 150000 + // Remaining Budget Cost = 150000 - 50000 = 100000 (NOT 250000 - 50000 = 200000) + + it('Remaining Budget Cost column uses filteredAvailableFunds (not total) when one source is deselected (Scenario 5)', () => { + const breakdown = buildTwoSourceBreakdown(); + renderWithRouter(breakdown, buildOverview(250000), { + deselectedSourceIds: new Set(['src-b']), + }); + + const remainingRow = getRemainingBudgetRow(); + const costCell = remainingRow.querySelector('td[class*="colBudget"]'); + expect(costCell).not.toBeNull(); + // filteredAvailableFunds (150000) - totalRawProjected (50000) = 100000 + expect(costCell!.textContent?.replace(/\s+/g, '')).toContain('€100,000.00'); + // Must NOT use the unfiltered total: 250000 - 50000 = 200000 + expect(costCell!.textContent).not.toContain('€200,000.00'); + }); + + // ── Scenario 6: Remaining Budget Cost column — all sources deselected ──────── + // deselectedSourceIds = {'src-a', 'src-b'} → filteredAvailableFunds = 0 + // Remaining Budget Cost = 0 - 50000 = -50000 → negative, uses valueNegative CSS class + + it('Remaining Budget Cost column is negative when all sources are deselected (Scenario 6)', () => { + const breakdown = buildTwoSourceBreakdown(); + renderWithRouter(breakdown, buildOverview(250000), { + deselectedSourceIds: new Set(['src-a', 'src-b']), + }); + + const remainingRow = getRemainingBudgetRow(); + const costCell = remainingRow.querySelector('td[class*="colBudget"]'); + expect(costCell).not.toBeNull(); + // 0 - 50000 = -50000: value is negative + const costSpan = costCell!.querySelector('span'); + expect(costSpan).not.toBeNull(); + // Span text should contain the cost amount and a minus sign + expect(costSpan!.textContent).toContain('50,000.00'); + expect(costSpan!.textContent).toContain('-'); + // Span should have the valueNegative class (filteredAvailableFunds - totalRawProjected < 0) + expect(costSpan).toHaveClass('valueNegative'); + }); + + // ── Scenario 7: Remaining Budget Net column uses filteredAvailableFunds ─────── + // Build breakdown with adjustedTotalPayback > 0 to verify it's included. + // deselectedSourceIds = {'src-b'} → filteredAvailableFunds = 150000 + // totalRawProjected = 50000, adjustedTotalPayback = 5000 (from wi subsidyPayback) + // Remaining Budget Net = 150000 - 50000 + 5000 = 105000 + + it('Remaining Budget Net column = filteredAvailableFunds - totalRawProjected + adjustedTotalPayback when source is deselected (Scenario 7)', () => { + // Use a breakdown with WI subsidy payback to make adjustedTotalPayback > 0 + const breakdown: BudgetBreakdown = { + workItems: { + areas: [ + { + areaId: null, + name: 'Unassigned', + parentId: null, + color: null, + projectedMin: 50000, + projectedMax: 50000, + actualCost: 0, + subsidyPayback: 5000, + rawProjectedMin: 50000, + rawProjectedMax: 50000, + minSubsidyPayback: 5000, + items: [ + { + workItemId: 'wi-payback-1', + title: 'Subsidized Work', + projectedMin: 50000, + projectedMax: 50000, + actualCost: 0, + subsidyPayback: 5000, + rawProjectedMin: 50000, + rawProjectedMax: 50000, + minSubsidyPayback: 5000, + costDisplay: 'projected', + budgetLines: [ + { + id: 'line-payback-1', + description: null, + plannedAmount: 50000, + confidence: 'own_estimate', + actualCost: 0, + hasInvoice: false, + isQuotation: false, + budgetSourceId: 'src-a', + }, + ], + }, + ], + children: [], + }, + ], + totals: { + projectedMin: 50000, + projectedMax: 50000, + actualCost: 0, + subsidyPayback: 5000, + rawProjectedMin: 50000, + rawProjectedMax: 50000, + minSubsidyPayback: 5000, + }, + }, + householdItems: { + areas: [], + totals: { + projectedMin: 0, + projectedMax: 0, + actualCost: 0, + subsidyPayback: 0, + rawProjectedMin: 0, + rawProjectedMax: 0, + minSubsidyPayback: 0, + }, + }, + subsidyAdjustments: [], + budgetSources: [ + buildSourceSummary({ + id: 'src-a', + name: 'Bank Loan', + totalAmount: 150000, + projectedMin: 40000, + projectedMax: 60000, + subsidyPaybackMin: 5000, + subsidyPaybackMax: 5000, + }), + buildSourceSummary({ + id: 'src-b', + name: 'Equity', + totalAmount: 100000, + projectedMin: 10000, + projectedMax: 10000, + subsidyPaybackMin: 0, + subsidyPaybackMax: 0, + }), + ], + }; + + renderWithRouter(breakdown, buildOverview(250000), { + deselectedSourceIds: new Set(['src-b']), + }); + + const remainingRow = getRemainingBudgetRow(); + const netCell = remainingRow.querySelector('td[class*="colRemaining"]'); + expect(netCell).not.toBeNull(); + // filteredAvailableFunds (150000) - totalRawProjected (50000) + adjustedTotalPayback (5000) = 105000 + expect(netCell!.textContent?.replace(/\s+/g, '')).toContain('€105,000.00'); + }); + + // ── Scenario 8: unassigned source deselected — totalAmount=0, no effect ────── + // The 'unassigned' source has totalAmount=0. Deselecting it changes filteredAvailableFunds + // by zero (subtracts 0 from the sum), so the totals stay the same as with it selected. + + it('deselecting the unassigned source (totalAmount=0) does not change filteredAvailableFunds (Scenario 8)', () => { + const breakdown: BudgetBreakdown = { + ...buildTwoSourceBreakdown(), + budgetSources: [ + buildSourceSummary({ + id: 'src-a', + name: 'Bank Loan', + totalAmount: 150000, + projectedMin: 40000, + projectedMax: 60000, + }), + buildSourceSummary({ + id: 'unassigned', + name: 'Unassigned', + totalAmount: 0, + projectedMin: 0, + projectedMax: 0, + subsidyPaybackMin: 0, + subsidyPaybackMax: 0, + }), + ], + }; + + // Deselect unassigned (totalAmount=0) — Available Funds should still = 150000 + renderWithRouter(breakdown, buildOverview(150000), { + deselectedSourceIds: new Set(['unassigned']), + }); + + const availFundsRow = getAvailFundsRow(); + const costCell = availFundsRow.querySelector('td[class*="colBudget"]'); + expect(costCell).not.toBeNull(); + // unassigned contributes 0, so filteredAvailableFunds = 150000 + expect(costCell!.textContent?.replace(/\s+/g, '')).toContain('€150,000.00'); + }); + + // ── Scenario 9: toggling back to all-selected restores unfiltered values ────── + // First render with src-b deselected (filteredAvailableFunds=150000), then + // re-render with empty deselectedSourceIds (filteredAvailableFunds=250000). + + it('Available Funds and Remaining Budget restore to unfiltered values when deselectedSourceIds becomes empty (Scenario 9)', () => { + const breakdown = buildTwoSourceBreakdown(); + + // Phase 1: src-b deselected → filteredAvailableFunds = 150000 + const { rerender } = renderWithRouter(breakdown, buildOverview(250000), { + deselectedSourceIds: new Set(['src-b']), + }); + + const availFundsRow1 = getAvailFundsRow(); + const costCell1 = availFundsRow1.querySelector('td[class*="colBudget"]'); + expect(costCell1!.textContent?.replace(/\s+/g, '')).toContain('€150,000.00'); + + // Phase 2: re-render with no deselection → filteredAvailableFunds = 250000 + rerender( + + {}} + onSelectAllSources={() => {}} + /> + , + ); + + const availFundsRow2 = getAvailFundsRow(); + const costCell2 = availFundsRow2.querySelector('td[class*="colBudget"]'); + expect(costCell2!.textContent?.replace(/\s+/g, '')).toContain('€250,000.00'); + + // Remaining Budget Cost should also restore + const remainingRow = getRemainingBudgetRow(); + const remainingCostCell = remainingRow.querySelector('td[class*="colBudget"]'); + // 250000 - 50000 = 200000 + expect(remainingCostCell!.textContent?.replace(/\s+/g, '')).toContain('€200,000.00'); + }); +}); diff --git a/client/src/components/CostBreakdownTable/CostBreakdownTable.tsx b/client/src/components/CostBreakdownTable/CostBreakdownTable.tsx index 3d44a883..d63ccd4b 100644 --- a/client/src/components/CostBreakdownTable/CostBreakdownTable.tsx +++ b/client/src/components/CostBreakdownTable/CostBreakdownTable.tsx @@ -682,7 +682,7 @@ function ChevronSvg({ className }: { className: string }) { */ export function CostBreakdownTable({ breakdown, - overview, + overview: _overview, deselectedSourceIds, onSourceToggle, onSelectAllSources, @@ -785,9 +785,16 @@ export function CostBreakdownTable({ ); /** - * Sum = availableFunds - totalRawProjected + adjustedTotalPayback. + * Compute filtered available funds: sum of totalAmount for sources that are not deselected. */ - const sum = overview.availableFunds - totalRawProjected + adjustedTotalPayback; + const filteredAvailableFunds = budgetSources + .filter((s) => !deselectedSourceIds.has(s.id)) + .reduce((sum: number, s) => sum + s.totalAmount, 0); + + /** + * Sum = filteredAvailableFunds - totalRawProjected + adjustedTotalPayback. + */ + const sum = filteredAvailableFunds - totalRawProjected + adjustedTotalPayback; // Empty state: only show early-return empty state if there are NO sources configured AND no items. // If sources are configured (even if all deselected, which prunes items), render the full table @@ -1120,7 +1127,7 @@ export function CostBreakdownTable({ - {formatCurrency(overview.availableFunds)} + {formatCurrency(filteredAvailableFunds)} {deselectedSourceIds.size > 0 && ( {t('overview.costBreakdown.availableFundsFilter.activeFilterCaption', { @@ -1233,25 +1240,25 @@ export function CostBreakdownTable({ = 0 + filteredAvailableFunds - totalRawProjected >= 0 ? styles.valuePositive : styles.valueNegative } > - {formatCurrency(overview.availableFunds - totalRawProjected)} + {formatCurrency(filteredAvailableFunds - totalRawProjected)} = 0 + filteredAvailableFunds - totalRawProjected + adjustedTotalPayback >= 0 ? styles.valuePositive : styles.valueNegative } > {formatCurrency( - overview.availableFunds - totalRawProjected + adjustedTotalPayback, + filteredAvailableFunds - totalRawProjected + adjustedTotalPayback, )} diff --git a/e2e/tests/budget/budget-source-filter.spec.ts b/e2e/tests/budget/budget-source-filter.spec.ts index 448c5102..bf14b296 100644 --- a/e2e/tests/budget/budget-source-filter.spec.ts +++ b/e2e/tests/budget/budget-source-filter.spec.ts @@ -2272,3 +2272,443 @@ test.describe('Responsive layout', { tag: '@responsive' }, () => { } }); }); + +// ───────────────────────────────────────────────────────────────────────────── +// Filter-aware summary rows (Available Funds + Remaining Budget) +// AC refs: #4 (restore on re-select), #5 (zero sources), #8 (print hiding) +// ───────────────────────────────────────────────────────────────────────────── +test.describe('Filter-aware summary rows (Available Funds + Remaining Budget)', () => { + /** + * Build a breakdown with two real sources (Source A: 150 000, Source B: 100 000) + * and a deterministic projected cost (rawProjectedMin === rawProjectedMax === 20 000) + * so that "avg" perspective gives the same value as min and max. + * Unassigned has totalAmount=0 and does not affect the Available Funds total. + */ + function makeTwoSourceBreakdown() { + return { + workItems: { + areas: [ + { + areaId: 'area-main', + name: 'Main Area', + parentId: null, + color: '#3B82F6', + projectedMin: 20000, + projectedMax: 20000, + actualCost: 0, + subsidyPayback: 0, + rawProjectedMin: 20000, + rawProjectedMax: 20000, + minSubsidyPayback: 0, + items: [ + { + workItemId: 'wi-main-1', + title: 'Main Work Item', + projectedMin: 20000, + projectedMax: 20000, + actualCost: 0, + subsidyPayback: 0, + rawProjectedMin: 20000, + rawProjectedMax: 20000, + minSubsidyPayback: 0, + costDisplay: 'projected', + budgetLines: [ + { + id: 'line-a1', + description: 'Line A1', + plannedAmount: 10000, + confidence: 'own_estimate', + actualCost: 0, + hasInvoice: false, + isQuotation: false, + budgetSourceId: SOURCE_A_ID, + }, + { + id: 'line-b1', + description: 'Line B1', + plannedAmount: 10000, + confidence: 'own_estimate', + actualCost: 0, + hasInvoice: false, + isQuotation: false, + budgetSourceId: SOURCE_B_ID, + }, + ], + }, + ], + children: [], + }, + ], + totals: { + projectedMin: 20000, + projectedMax: 20000, + actualCost: 0, + subsidyPayback: 0, + rawProjectedMin: 20000, + rawProjectedMax: 20000, + minSubsidyPayback: 0, + }, + }, + householdItems: { + areas: [], + totals: makeEmptyTotals(), + }, + subsidyAdjustments: [], + budgetSources: [ + { + id: SOURCE_A_ID, + name: 'Bank Loan', + totalAmount: 150000, + projectedMin: 10000, + projectedMax: 10000, + subsidyPaybackMin: 0, + subsidyPaybackMax: 0, + }, + { + id: SOURCE_B_ID, + name: 'Equity', + totalAmount: 100000, + projectedMin: 10000, + projectedMax: 10000, + subsidyPaybackMin: 0, + subsidyPaybackMax: 0, + }, + ], + }; + } + + /** + * Filtered breakdown returned when Source B (Equity) is deselected. + * Server prunes Source B lines — only Source A lines remain. + * budgetSources still contains both (server always returns unfiltered projectedMin/Max), + * so source toggle rows remain visible after the refetch. + */ + function makeTwoSourceBreakdownEquityDeselected() { + return { + workItems: { + areas: [ + { + areaId: 'area-main', + name: 'Main Area', + parentId: null, + color: '#3B82F6', + projectedMin: 10000, + projectedMax: 10000, + actualCost: 0, + subsidyPayback: 0, + rawProjectedMin: 10000, + rawProjectedMax: 10000, + minSubsidyPayback: 0, + items: [ + { + workItemId: 'wi-main-1', + title: 'Main Work Item', + projectedMin: 10000, + projectedMax: 10000, + actualCost: 0, + subsidyPayback: 0, + rawProjectedMin: 10000, + rawProjectedMax: 10000, + minSubsidyPayback: 0, + costDisplay: 'projected', + budgetLines: [ + { + id: 'line-a1', + description: 'Line A1', + plannedAmount: 10000, + confidence: 'own_estimate', + actualCost: 0, + hasInvoice: false, + isQuotation: false, + budgetSourceId: SOURCE_A_ID, + }, + ], + }, + ], + children: [], + }, + ], + totals: { + projectedMin: 10000, + projectedMax: 10000, + actualCost: 0, + subsidyPayback: 0, + rawProjectedMin: 10000, + rawProjectedMax: 10000, + minSubsidyPayback: 0, + }, + }, + householdItems: { + areas: [], + totals: makeEmptyTotals(), + }, + subsidyAdjustments: [], + // Server returns both sources regardless of filter + budgetSources: [ + { + id: SOURCE_A_ID, + name: 'Bank Loan', + totalAmount: 150000, + projectedMin: 10000, + projectedMax: 10000, + subsidyPaybackMin: 0, + subsidyPaybackMax: 0, + }, + { + id: SOURCE_B_ID, + name: 'Equity', + totalAmount: 100000, + projectedMin: 10000, + projectedMax: 10000, + subsidyPaybackMin: 0, + subsidyPaybackMax: 0, + }, + ], + }; + } + + test('Scenario 1 — Available Funds updates when a source is deselected', async ({ page }) => { + // Source A: totalAmount=150 000, Source B: totalAmount=100 000 → combined=250 000. + // After deselecting Source B (Equity): filteredAvailableFunds = 150 000 (Source A only). + const overviewPage = new BudgetOverviewPage(page); + const teardown = await mountOverviewRoutes( + page, + makeBudgetOverviewResponse(), + makeTwoSourceBreakdown(), + makeTwoSourceBreakdownEquityDeselected(), + ); + + try { + await overviewPage.goto(); + await overviewPage.waitForLoaded(); + + // Initial state: both sources selected → Available Funds = 250 000 + const afValue = overviewPage.availableFundsValue(); + await expect(afValue).toBeVisible(); + const initialText = await afValue.textContent(); + // Combined total must contain "250" (as in €250,000) + expect(initialText).toContain('250'); + + // Expand source rows + await overviewPage.availableFundsButton().click(); + + // Deselect Equity — register waitForResponse BEFORE the click + const refetchPromise = page.waitForResponse( + (resp) => + resp.url().includes('/api/budget/breakdown') && resp.url().includes('deselectedSources='), + ); + await overviewPage.sourceRow('Equity').click(); + await expect(overviewPage.sourceRow('Equity')).toHaveAttribute('aria-pressed', 'false'); + await refetchPromise; + + // Available Funds now shows only Source A total (150 000) + const filteredText = await afValue.textContent(); + expect(filteredText).toContain('150'); + // Must NOT still show 250 (combined) + expect(filteredText).not.toContain('250'); + } finally { + await teardown(); + } + }); + + test('Scenario 2 — Remaining Budget Cost updates when a source is deselected', async ({ + page, + }) => { + // Source A: totalAmount=150 000, Source B: totalAmount=100 000 + // Initial (both selected): totalRawProjected = (20 000+20 000)/2 = 20 000 + // Remaining Budget Cost = 250 000 - 20 000 = 230 000 + // After deselecting Source B (Equity): server returns filtered breakdown with + // wiTotals.rawProjectedMin/Max = 10 000 → totalRawProjected = 10 000 + // filteredAvailableFunds = 150 000 (Bank Loan only) + // Remaining Budget Cost = 150 000 - 10 000 = 140 000 + const overviewPage = new BudgetOverviewPage(page); + const teardown = await mountOverviewRoutes( + page, + makeBudgetOverviewResponse(), + makeTwoSourceBreakdown(), + makeTwoSourceBreakdownEquityDeselected(), + ); + + try { + await overviewPage.goto(); + await overviewPage.waitForLoaded(); + + // Locate the "Remaining Budget" row by its label text + const remainingRow = overviewPage.costBreakdownCard + .getByRole('row') + .filter({ hasText: /remaining budget/i }); + + // Remaining Budget Cost cell (td index 1 = "Cost" column) + const costCell = remainingRow.locator('td').nth(1); + await expect(costCell).toBeVisible(); + const initialText = await costCell.textContent(); + // Should reflect 230 000 (250 000 - 20 000) + expect(initialText).toContain('230'); + + // Expand source rows + await overviewPage.availableFundsButton().click(); + + // Deselect Equity — register waitForResponse BEFORE the click + const refetchPromise = page.waitForResponse( + (resp) => + resp.url().includes('/api/budget/breakdown') && resp.url().includes('deselectedSources='), + ); + await overviewPage.sourceRow('Equity').click(); + await expect(overviewPage.sourceRow('Equity')).toHaveAttribute('aria-pressed', 'false'); + await refetchPromise; + + // Remaining Budget Cost = 150 000 - 10 000 = 140 000 + const filteredText = await costCell.textContent(); + expect(filteredText).toContain('140'); + expect(filteredText).not.toContain('230'); + } finally { + await teardown(); + } + }); + + test('Scenario 3 — Available Funds restores to full total on re-select (AC #4)', async ({ + page, + }) => { + // Start with Source B (Equity) deselected via URL — Available Funds = 150 000 (Source A only). + // Re-select Equity → refetch fires without deselectedSources → Available Funds = 250 000. + const overviewPage = new BudgetOverviewPage(page); + const teardown = await mountOverviewRoutes( + page, + makeBudgetOverviewResponse(), + makeTwoSourceBreakdown(), + makeTwoSourceBreakdownEquityDeselected(), + ); + + try { + // Navigate with Equity pre-deselected + await page.goto(`${BUDGET_OVERVIEW_ROUTE}?deselectedSources=${SOURCE_B_ID}`); + await overviewPage.waitForLoaded(); + + const afValue = overviewPage.availableFundsValue(); + await expect(afValue).toBeVisible(); + + // Pre-deselected state: Available Funds = 150 000 (Bank Loan only) + const deselectedText = await afValue.textContent(); + expect(deselectedText).toContain('150'); + + // Expand and re-select Equity + await overviewPage.availableFundsButton().click(); + await expect(overviewPage.sourceRow('Equity')).toHaveAttribute('aria-pressed', 'false'); + + const reSelectPromise = page.waitForResponse( + (resp) => + resp.url().includes('/api/budget/breakdown') && + !resp.url().includes('deselectedSources='), + ); + await overviewPage.sourceRow('Equity').click(); + await expect(overviewPage.sourceRow('Equity')).toHaveAttribute('aria-pressed', 'true'); + await reSelectPromise; + + // Available Funds restored to 250 000 (both sources selected) + const restoredText = await afValue.textContent(); + expect(restoredText).toContain('250'); + expect(restoredText).not.toContain('150'); + } finally { + await teardown(); + } + }); + + test('Scenario 4 — Zero sources selected: Available Funds shows €0 (AC #5)', async ({ + page, + }) => { + // When all sources are deselected, filteredAvailableFunds = 0. + // Server returns empty areas (all lines belong to deselected sources). + // The Available Funds row must show €0.00 — not NaN and not the stale combined value. + const overviewPage = new BudgetOverviewPage(page); + + // Use makeBreakdownSourceAOnly (single source: Bank Loan, totalAmount=150 000). + // Filtered response is makeFilteredEmptyBreakdown which has empty areas[]. + const teardown = await mountOverviewRoutes( + page, + makeBudgetOverviewResponse(), + makeBreakdownSourceAOnly(), + makeFilteredEmptyBreakdown(), + ); + + try { + await overviewPage.goto(); + await overviewPage.waitForLoaded(); + + await overviewPage.availableFundsButton().click(); + + // Deselect the only source (Bank Loan) + const refetchPromise = page.waitForResponse( + (resp) => + resp.url().includes('/api/budget/breakdown') && resp.url().includes('deselectedSources='), + ); + await overviewPage.sourceRow('Bank Loan').click(); + await expect(overviewPage.sourceRow('Bank Loan')).toHaveAttribute('aria-pressed', 'false'); + await refetchPromise; + + // Available Funds must show €0.00 — not NaN, not the stale 150 000 value + const afValue = overviewPage.availableFundsValue(); + const valueText = await afValue.textContent(); + // Must contain "0" as a number (e.g. "€0.00" or "€0,00") + expect(valueText).toMatch(/\b0\b|0,00|0\.00/); + // Must not contain "NaN" + expect(valueText).not.toContain('NaN'); + // Must not contain the stale 150 000 total + expect(valueText).not.toContain('150'); + } finally { + await teardown(); + } + }); + + test('Scenario 5 — Print: deselected source rows hidden, selected source rows visible (AC #8)', async ({ + page, + }) => { + // The @media print rule `.rowSourceDetailToggle[aria-pressed='false'] { display: none !important }` + // must hide deselected source rows in print and leave selected rows visible. + const overviewPage = new BudgetOverviewPage(page); + const teardown = await mountOverviewRoutes( + page, + makeBudgetOverviewResponse(), + makeTwoSourceBreakdown(), + makeTwoSourceBreakdownEquityDeselected(), + ); + + try { + await overviewPage.goto(); + await overviewPage.waitForLoaded(); + + // Expand Available Funds section to show source toggle rows + await overviewPage.availableFundsButton().click(); + await expect(overviewPage.sourceRow('Bank Loan')).toBeVisible(); + await expect(overviewPage.sourceRow('Equity')).toBeVisible(); + + // Deselect Equity — register waitForResponse BEFORE the click + const refetchPromise = page.waitForResponse( + (resp) => + resp.url().includes('/api/budget/breakdown') && resp.url().includes('deselectedSources='), + ); + await overviewPage.sourceRow('Equity').click(); + await expect(overviewPage.sourceRow('Equity')).toHaveAttribute('aria-pressed', 'false'); + await refetchPromise; + + // Switch to print media + await page.emulateMedia({ media: 'print' }); + + try { + // Deselected source row (Equity, aria-pressed='false') must be hidden by @media print + await expect(overviewPage.sourceRow('Equity')).toHaveCSS('display', 'none'); + + // Selected source row (Bank Loan, aria-pressed='true') must NOT be display:none + const bankLoanDisplay = await overviewPage + .sourceRow('Bank Loan') + .evaluate((el) => getComputedStyle(el).display); + expect(bankLoanDisplay).not.toBe('none'); + } finally { + // Restore screen media — must be in finally to prevent print-mode from leaking + // into subsequent tests running in the same worker. + await page.emulateMedia({ media: 'screen' }); + } + } finally { + await teardown(); + } + }); +});