diff --git a/.claude/metrics/review-metrics.jsonl b/.claude/metrics/review-metrics.jsonl index 27ff62f44..c5a375678 100644 --- a/.claude/metrics/review-metrics.jsonl +++ b/.claude/metrics/review-metrics.jsonl @@ -186,3 +186,4 @@ {"pr":1116,"issues":[1115],"epic":null,"type":"feat","createdAt":"2026-03-21T10:49:31Z","mergedAt":null,"filesChanged":5,"linesChanged":2679,"touchesClient":true,"touchesServer":false,"fixLoopCount":0,"internalFixLoopCount":0,"fixLoopTriggers":[],"reviews":[{"agent":"product-architect","verdict":"approve","findings":{"critical":0,"high":0,"medium":0,"low":0,"informational":0},"round":1},{"agent":"ux-designer","verdict":"approve","findings":{"critical":0,"high":0,"medium":0,"low":0,"informational":0},"round":1},{"agent":"product-owner","verdict":"approve","findings":{"critical":0,"high":0,"medium":0,"low":0,"informational":0},"round":1}],"totalFindings":{"critical":0,"high":0,"medium":0,"low":0,"informational":0}} {"pr":1119,"issues":[1118],"epic":null,"type":"feat","createdAt":"2026-03-21T11:21:00Z","mergedAt":null,"filesChanged":5,"linesChanged":2315,"touchesClient":true,"touchesServer":false,"fixLoopCount":0,"internalFixLoopCount":0,"fixLoopTriggers":[],"reviews":[{"agent":"product-architect","verdict":"approve","findings":{"critical":0,"high":0,"medium":0,"low":0,"informational":0},"round":1},{"agent":"ux-designer","verdict":"approve","findings":{"critical":0,"high":0,"medium":0,"low":0,"informational":0},"round":1},{"agent":"product-owner","verdict":"approve","findings":{"critical":0,"high":0,"medium":0,"low":0,"informational":0},"round":1}],"totalFindings":{"critical":0,"high":0,"medium":0,"low":0,"informational":0}} {"pr":1141,"issues":[1135,1136,1137,1138,1139,1140],"epic":null,"type":"fix","createdAt":"2026-03-22T10:59:20Z","mergedAt":"2026-03-22T12:30:00Z","filesChanged":17,"linesChanged":865,"touchesClient":true,"touchesServer":false,"fixLoopCount":0,"internalFixLoopCount":1,"fixLoopTriggers":[{"round":1,"agents":["frontend-developer"]}],"reviews":[{"agent":"product-architect","verdict":"approve","findings":{"critical":0,"high":0,"medium":0,"low":0,"informational":2},"round":1},{"agent":"ux-designer","verdict":"approve","findings":{"critical":0,"high":0,"medium":0,"low":2,"informational":1},"round":1}],"totalFindings":{"critical":0,"high":0,"medium":0,"low":2,"informational":3}} +{"pr":1144,"issues":[1142],"epic":null,"type":"feat","createdAt":"2026-03-22T11:13:49Z","mergedAt":"2026-03-22T13:00:00Z","filesChanged":30,"linesChanged":1788,"touchesClient":true,"touchesServer":false,"fixLoopCount":0,"internalFixLoopCount":1,"fixLoopTriggers":[{"round":1,"agents":["frontend-developer","translator","e2e-test-engineer"]}],"reviews":[{"agent":"product-architect","verdict":"approve","findings":{"critical":0,"high":0,"medium":0,"low":0,"informational":0},"round":1},{"agent":"ux-designer","verdict":"approve","findings":{"critical":0,"high":0,"medium":0,"low":0,"informational":0},"round":1},{"agent":"product-owner","verdict":"approve","findings":{"critical":0,"high":0,"medium":0,"low":0,"informational":2},"round":1}],"totalFindings":{"critical":0,"high":0,"medium":0,"low":0,"informational":2}} diff --git a/client/src/i18n/de/budget.json b/client/src/i18n/de/budget.json index 2b00b4d52..8fc534cd7 100644 --- a/client/src/i18n/de/budget.json +++ b/client/src/i18n/de/budget.json @@ -8,9 +8,9 @@ "emptyStateTitle": "Noch keine Budgetdaten", "emptyStateDescription": "Beginnen Sie mit dem Hinzufügen von Budgekategorien, Arbeitspaketen mit geplanten Kosten und Finanzierungsquellen. Ihre Projektbudget-Übersicht wird hier angezeigt, sobald Daten eingegeben werden.", "actions": { - "addButton": "Hinzufügen", - "addInvoice": "Rechnung Hinzufügen", - "addVendor": "Auftragnehmer Hinzufügen" + "addButton": "Neu", + "addInvoice": "Neue Rechnung", + "addVendor": "Neuer Auftragnehmer" }, "availableFunds": "Verfügbare Mittel", "projectedCostRange": "Geplanter Kostenbereich", @@ -65,7 +65,7 @@ "sources": { "title": "Budget", "sectionTitle": "Quellen", - "addSource": "Quelle Hinzufügen", + "addSource": "Neue Quelle", "loading": "Budgetquellen werden geladen...", "error": "Fehler", "errorMessage": "Budgetquellen konnten nicht geladen werden. Bitte versuchen Sie es später erneut.", @@ -152,7 +152,7 @@ "vendors": { "title": "Budget", "sectionTitle": "Auftragnehmer", - "addVendor": "Auftragnehmer Hinzufügen", + "addVendor": "Neuer Auftragnehmer", "addFirstVendor": "Ersten Auftragnehmer Hinzufügen", "loading": "Auftragnehmer werden geladen...", "error": "Fehler", @@ -196,7 +196,7 @@ "nameTooLong": "Name des Auftragnehmers darf maximal 200 Zeichen lang sein." }, "buttons": { - "create": "Auftragnehmer Hinzufügen", + "create": "Neuer Auftragnehmer", "creating": "Wird hinzugefügt...", "save": "Änderungen Speichern", "saving": "Wird gespeichert...", @@ -415,8 +415,9 @@ }, "subsidies": { "title": "Förderprogramme", + "pageTitle": "Budget", "sectionTitle": "Förderprogramme", - "addProgram": "Programm Hinzufügen", + "addProgram": "Neues Förderprogramm", "loading": "Förderprogramme werden geladen...", "error": "Fehler", "errorMessage": "Förderprogramme konnten nicht geladen werden. Bitte versuchen Sie es später erneut.", @@ -533,7 +534,7 @@ "invoices": { "title": "Budget", "sectionTitle": "Rechnungen", - "addInvoice": "Rechnung Hinzufügen", + "addInvoice": "Neue Rechnung", "addFirstInvoice": "Erste Rechnung Hinzufügen", "loading": "Rechnungen werden geladen...", "error": "Fehler", @@ -596,12 +597,16 @@ "dateRequired": "Rechnungsdatum ist erforderlich." }, "buttons": { - "create": "Rechnung Hinzufügen", + "create": "Neue Rechnung", "creating": "Wird hinzugefügt...", "save": "Änderungen Speichern", "saving": "Wird gespeichert...", "cancel": "Abbrechen", - "view": "Anzeigen" + "view": "Anzeigen", + "delete": "Löschen" + }, + "actions": { + "menuAriaLabel": "Aktionen für Rechnung {{number}}" }, "statusLabels": { "pending": "Ausstehend", diff --git a/client/src/i18n/en/budget.json b/client/src/i18n/en/budget.json index ea2314668..fb313d74f 100644 --- a/client/src/i18n/en/budget.json +++ b/client/src/i18n/en/budget.json @@ -8,9 +8,9 @@ "emptyStateTitle": "No budget data yet", "emptyStateDescription": "Start by adding budget categories, work items with planned costs, and financing sources. Your project budget overview will appear here once data is entered.", "actions": { - "addButton": "Add", - "addInvoice": "Add Invoice", - "addVendor": "Add Vendor" + "addButton": "New", + "addInvoice": "New Invoice", + "addVendor": "New Vendor" }, "availableFunds": "Available Funds", "projectedCostRange": "Projected Cost Range", @@ -65,7 +65,7 @@ "sources": { "title": "Budget", "sectionTitle": "Sources", - "addSource": "Add Source", + "addSource": "New Source", "loading": "Loading budget sources...", "error": "Error", "errorMessage": "Failed to load budget sources. Please try again.", @@ -152,7 +152,7 @@ "vendors": { "title": "Budget", "sectionTitle": "Vendors", - "addVendor": "Add Vendor", + "addVendor": "New Vendor", "addFirstVendor": "Add First Vendor", "loading": "Loading vendors...", "error": "Error", @@ -353,7 +353,7 @@ "invoices": { "title": "Budget", "sectionTitle": "Invoices", - "addInvoice": "Add Invoice", + "addInvoice": "New Invoice", "addFirstInvoice": "Add First Invoice", "loading": "Loading invoices...", "error": "Error", @@ -416,7 +416,7 @@ "dateRequired": "Invoice date is required." }, "buttons": { - "create": "Add Invoice", + "create": "New Invoice", "creating": "Adding...", "save": "Save Changes", "saving": "Saving...", @@ -524,8 +524,9 @@ }, "subsidies": { "title": "Subsidy Programs", + "pageTitle": "Budget", "sectionTitle": "Subsidy Programs", - "addProgram": "Add Program", + "addProgram": "New Subsidy Program", "loading": "Loading subsidy programs...", "error": "Error", "errorMessage": "Failed to load subsidy programs. Please try again.", diff --git a/client/src/pages/BudgetOverviewPage/BudgetOverviewPage.module.css b/client/src/pages/BudgetOverviewPage/BudgetOverviewPage.module.css index fecbf4cd1..11fe8aa3c 100644 --- a/client/src/pages/BudgetOverviewPage/BudgetOverviewPage.module.css +++ b/client/src/pages/BudgetOverviewPage/BudgetOverviewPage.module.css @@ -3,31 +3,21 @@ * ============================================================ */ .container { - padding: var(--spacing-8); - max-width: 1200px; - margin: 0 auto; + composes: pageContainer from '../../styles/shared.module.css'; } .content { - display: flex; - flex-direction: column; - gap: var(--spacing-6); + composes: pageContent from '../../styles/shared.module.css'; } /* ---- Page header ---- */ .pageHeader { - display: flex; - align-items: center; - justify-content: space-between; - gap: var(--spacing-4); + composes: pageHeader from '../../styles/shared.module.css'; } .pageTitle { - font-size: var(--font-size-4xl); - font-weight: var(--font-weight-bold); - color: var(--color-text-primary); - margin: 0; + composes: pageTitle from '../../styles/shared.module.css'; } /* ---- Add dropdown button ---- */ @@ -619,10 +609,6 @@ * ============================================================ */ @media (min-width: 768px) and (max-width: 1024px) { - .container { - padding: var(--spacing-6); - } - .retryButton { min-height: 44px; } @@ -649,19 +635,6 @@ * ============================================================ */ @media (max-width: 767px) { - .container { - padding: var(--spacing-4); - } - - .pageHeader { - flex-direction: column; - align-items: stretch; - justify-content: flex-start; - } - - .pageTitle { - font-size: var(--font-size-2xl); - } .addButton { min-height: 44px; diff --git a/client/src/pages/BudgetOverviewPage/BudgetOverviewPage.test.tsx b/client/src/pages/BudgetOverviewPage/BudgetOverviewPage.test.tsx index 8745f3682..02968a6a8 100644 --- a/client/src/pages/BudgetOverviewPage/BudgetOverviewPage.test.tsx +++ b/client/src/pages/BudgetOverviewPage/BudgetOverviewPage.test.tsx @@ -397,6 +397,31 @@ describe('BudgetOverviewPage', () => { expect(screen.getByRole('heading', { name: /^budget$/i, level: 1 })).toBeInTheDocument(); }); }); + + it('renders a "New" dropdown trigger button accessible by role', async () => { + mockFetchBudgetOverview.mockResolvedValueOnce(richOverview); + renderPage(); + + await waitFor(() => { + const btn = screen.getByRole('button', { name: /^new$/i }); + expect(btn).toBeInTheDocument(); + }); + }); + + it('"New" button shows "New Invoice" and "New Vendor" menu items when clicked', async () => { + const user = userEvent.setup(); + mockFetchBudgetOverview.mockResolvedValueOnce(richOverview); + renderPage(); + + await waitFor(() => { + expect(screen.getByRole('button', { name: /^new$/i })).toBeInTheDocument(); + }); + + await user.click(screen.getByRole('button', { name: /^new$/i })); + + expect(screen.getByRole('menuitem', { name: /new invoice/i })).toBeInTheDocument(); + expect(screen.getByRole('menuitem', { name: /new vendor/i })).toBeInTheDocument(); + }); }); // ─── Key metrics row ───────────────────────────────────────────────────────── diff --git a/client/src/pages/BudgetSourcesPage/BudgetSourcesPage.module.css b/client/src/pages/BudgetSourcesPage/BudgetSourcesPage.module.css index 7852eeafc..da84b09a0 100644 --- a/client/src/pages/BudgetSourcesPage/BudgetSourcesPage.module.css +++ b/client/src/pages/BudgetSourcesPage/BudgetSourcesPage.module.css @@ -1,29 +1,19 @@ .container { - padding: var(--spacing-8); - max-width: 1200px; - margin: 0 auto; + composes: pageContainer from '../../styles/shared.module.css'; } .content { - display: flex; - flex-direction: column; - gap: var(--spacing-6); + composes: pageContent from '../../styles/shared.module.css'; } /* ---- Page header ---- */ .pageHeader { - display: flex; - align-items: center; - justify-content: space-between; - gap: var(--spacing-4); + composes: pageHeader from '../../styles/shared.module.css'; } .pageTitle { - font-size: var(--font-size-4xl); - font-weight: var(--font-weight-bold); - color: var(--color-text-primary); - margin: 0; + composes: pageTitle from '../../styles/shared.module.css'; } /* ---- Section header (below sub-nav) ---- */ @@ -774,13 +764,6 @@ * ============================================================ */ @media (max-width: 767px) { - .container { - padding: var(--spacing-4); - } - - .pageTitle { - font-size: var(--font-size-2xl); - } .card { padding: var(--spacing-4); diff --git a/client/src/pages/BudgetSourcesPage/BudgetSourcesPage.test.tsx b/client/src/pages/BudgetSourcesPage/BudgetSourcesPage.test.tsx index cc91af9e0..f62fa4361 100644 --- a/client/src/pages/BudgetSourcesPage/BudgetSourcesPage.test.tsx +++ b/client/src/pages/BudgetSourcesPage/BudgetSourcesPage.test.tsx @@ -202,13 +202,13 @@ describe('BudgetSourcesPage', () => { }); }); - it('renders "Add Source" button', async () => { + it('renders "New Source" button', async () => { mockFetchBudgetSources.mockResolvedValueOnce(emptyResponse); renderPage(); await waitFor(() => { - expect(screen.getByRole('button', { name: /add source/i })).toBeInTheDocument(); + expect(screen.getByRole('button', { name: /new source/i })).toBeInTheDocument(); }); }); @@ -553,34 +553,34 @@ describe('BudgetSourcesPage', () => { // ─── Create form ───────────────────────────────────────────────────────────── describe('create form', () => { - it('shows create form when "Add Source" is clicked', async () => { + it('shows create form when "New Source" is clicked', async () => { mockFetchBudgetSources.mockResolvedValueOnce(emptyResponse); const user = userEvent.setup(); renderPage(); await waitFor(() => { - expect(screen.getByRole('button', { name: /add source/i })).toBeInTheDocument(); + expect(screen.getByRole('button', { name: /new source/i })).toBeInTheDocument(); }); - await user.click(screen.getByRole('button', { name: /add source/i })); + await user.click(screen.getByRole('button', { name: /new source/i })); expect(screen.getByRole('heading', { name: /new budget source/i })).toBeInTheDocument(); }); - it('"Add Source" button is disabled while create form is shown', async () => { + it('"New Source" button is disabled while create form is shown', async () => { mockFetchBudgetSources.mockResolvedValueOnce(emptyResponse); const user = userEvent.setup(); renderPage(); await waitFor(() => { - expect(screen.getByRole('button', { name: /add source/i })).toBeInTheDocument(); + expect(screen.getByRole('button', { name: /new source/i })).toBeInTheDocument(); }); - await user.click(screen.getByRole('button', { name: /add source/i })); + await user.click(screen.getByRole('button', { name: /new source/i })); - expect(screen.getByRole('button', { name: /add source/i })).toBeDisabled(); + expect(screen.getByRole('button', { name: /new source/i })).toBeDisabled(); }); it('hides create form when Cancel is clicked', async () => { @@ -590,10 +590,10 @@ describe('BudgetSourcesPage', () => { renderPage(); await waitFor(() => { - expect(screen.getByRole('button', { name: /add source/i })).toBeInTheDocument(); + expect(screen.getByRole('button', { name: /new source/i })).toBeInTheDocument(); }); - await user.click(screen.getByRole('button', { name: /add source/i })); + await user.click(screen.getByRole('button', { name: /new source/i })); await user.click(screen.getByRole('button', { name: /cancel/i })); expect(screen.queryByRole('heading', { name: /new budget source/i })).not.toBeInTheDocument(); @@ -606,10 +606,10 @@ describe('BudgetSourcesPage', () => { renderPage(); await waitFor(() => { - expect(screen.getByRole('button', { name: /add source/i })).toBeInTheDocument(); + expect(screen.getByRole('button', { name: /new source/i })).toBeInTheDocument(); }); - await user.click(screen.getByRole('button', { name: /add source/i })); + await user.click(screen.getByRole('button', { name: /new source/i })); const createButton = screen.getByRole('button', { name: /create source/i }); expect(createButton).toBeDisabled(); @@ -622,10 +622,10 @@ describe('BudgetSourcesPage', () => { renderPage(); await waitFor(() => { - expect(screen.getByRole('button', { name: /add source/i })).toBeInTheDocument(); + expect(screen.getByRole('button', { name: /new source/i })).toBeInTheDocument(); }); - await user.click(screen.getByRole('button', { name: /add source/i })); + await user.click(screen.getByRole('button', { name: /new source/i })); // Fill name but leave totalAmount empty await user.type(screen.getByLabelText(/^name/i), 'Test Loan'); @@ -641,10 +641,10 @@ describe('BudgetSourcesPage', () => { renderPage(); await waitFor(() => { - expect(screen.getByRole('button', { name: /add source/i })).toBeInTheDocument(); + expect(screen.getByRole('button', { name: /new source/i })).toBeInTheDocument(); }); - await user.click(screen.getByRole('button', { name: /add source/i })); + await user.click(screen.getByRole('button', { name: /new source/i })); // Type spaces to enable the button (non-empty string that trims to empty) const nameInput = screen.getByLabelText(/^name/i); @@ -671,10 +671,10 @@ describe('BudgetSourcesPage', () => { renderPage(); await waitFor(() => { - expect(screen.getByRole('button', { name: /add source/i })).toBeInTheDocument(); + expect(screen.getByRole('button', { name: /new source/i })).toBeInTheDocument(); }); - await user.click(screen.getByRole('button', { name: /add source/i })); + await user.click(screen.getByRole('button', { name: /new source/i })); await user.type(screen.getByLabelText(/^name/i), 'Loan'); const amountInput = screen.getByLabelText(/total amount/i); @@ -704,10 +704,10 @@ describe('BudgetSourcesPage', () => { renderPage(); await waitFor(() => { - expect(screen.getByRole('button', { name: /add source/i })).toBeInTheDocument(); + expect(screen.getByRole('button', { name: /new source/i })).toBeInTheDocument(); }); - await user.click(screen.getByRole('button', { name: /add source/i })); + await user.click(screen.getByRole('button', { name: /new source/i })); const nameInput = screen.getByLabelText(/^name/i); await user.type(nameInput, 'New Bank Loan'); @@ -744,10 +744,10 @@ describe('BudgetSourcesPage', () => { renderPage(); await waitFor(() => { - expect(screen.getByRole('button', { name: /add source/i })).toBeInTheDocument(); + expect(screen.getByRole('button', { name: /new source/i })).toBeInTheDocument(); }); - await user.click(screen.getByRole('button', { name: /add source/i })); + await user.click(screen.getByRole('button', { name: /new source/i })); await user.type(screen.getByLabelText(/^name/i), 'Post-Create'); fireEvent.change(screen.getByLabelText(/total amount/i), { target: { value: '5000' } }); @@ -773,10 +773,10 @@ describe('BudgetSourcesPage', () => { renderPage(); await waitFor(() => { - expect(screen.getByRole('button', { name: /add source/i })).toBeInTheDocument(); + expect(screen.getByRole('button', { name: /new source/i })).toBeInTheDocument(); }); - await user.click(screen.getByRole('button', { name: /add source/i })); + await user.click(screen.getByRole('button', { name: /new source/i })); await user.type(screen.getByLabelText(/^name/i), 'Bad Source'); fireEvent.change(screen.getByLabelText(/total amount/i), { target: { value: '100' } }); @@ -796,10 +796,10 @@ describe('BudgetSourcesPage', () => { renderPage(); await waitFor(() => { - expect(screen.getByRole('button', { name: /add source/i })).toBeInTheDocument(); + expect(screen.getByRole('button', { name: /new source/i })).toBeInTheDocument(); }); - await user.click(screen.getByRole('button', { name: /add source/i })); + await user.click(screen.getByRole('button', { name: /new source/i })); await user.type(screen.getByLabelText(/^name/i), 'Error Source'); fireEvent.change(screen.getByLabelText(/total amount/i), { target: { value: '1000' } }); @@ -818,10 +818,10 @@ describe('BudgetSourcesPage', () => { renderPage(); await waitFor(() => { - expect(screen.getByRole('button', { name: /add source/i })).toBeInTheDocument(); + expect(screen.getByRole('button', { name: /new source/i })).toBeInTheDocument(); }); - await user.click(screen.getByRole('button', { name: /add source/i })); + await user.click(screen.getByRole('button', { name: /new source/i })); const typeSelect = screen.getByLabelText(/^type/i); expect(typeSelect).toBeInTheDocument(); @@ -842,10 +842,10 @@ describe('BudgetSourcesPage', () => { renderPage(); await waitFor(() => { - expect(screen.getByRole('button', { name: /add source/i })).toBeInTheDocument(); + expect(screen.getByRole('button', { name: /new source/i })).toBeInTheDocument(); }); - await user.click(screen.getByRole('button', { name: /add source/i })); + await user.click(screen.getByRole('button', { name: /new source/i })); const statusSelect = screen.getByLabelText(/^status/i); const options = statusSelect.querySelectorAll('option'); @@ -1333,10 +1333,10 @@ describe('BudgetSourcesPage', () => { renderPage(); await waitFor(() => { - expect(screen.getByRole('button', { name: /add source/i })).toBeInTheDocument(); + expect(screen.getByRole('button', { name: /new source/i })).toBeInTheDocument(); }); - await user.click(screen.getByRole('button', { name: /add source/i })); + await user.click(screen.getByRole('button', { name: /new source/i })); await user.type(screen.getByLabelText(/^name/i), 'New Source'); fireEvent.change(screen.getByLabelText(/total amount/i), { target: { value: '10000' } }); await user.click(screen.getByRole('button', { name: /create source/i })); @@ -1360,11 +1360,11 @@ describe('BudgetSourcesPage', () => { renderPage(); await waitFor(() => { - expect(screen.getByRole('button', { name: /add source/i })).toBeInTheDocument(); + expect(screen.getByRole('button', { name: /new source/i })).toBeInTheDocument(); }); // Create a source to get a success message - await user.click(screen.getByRole('button', { name: /add source/i })); + await user.click(screen.getByRole('button', { name: /new source/i })); await user.type(screen.getByLabelText(/^name/i), 'First Source'); fireEvent.change(screen.getByLabelText(/total amount/i), { target: { value: '5000' } }); await user.click(screen.getByRole('button', { name: /create source/i })); @@ -1376,7 +1376,7 @@ describe('BudgetSourcesPage', () => { }); // Re-open the create form — success message remains - await user.click(screen.getByRole('button', { name: /add source/i })); + await user.click(screen.getByRole('button', { name: /new source/i })); expect( screen.queryByText(/budget source "first source" created successfully/i), @@ -1611,10 +1611,10 @@ describe('BudgetSourcesPage', () => { renderPage(); await waitFor(() => { - expect(screen.getByRole('button', { name: /add source/i })).toBeInTheDocument(); + expect(screen.getByRole('button', { name: /new source/i })).toBeInTheDocument(); }); - await user.click(screen.getByRole('button', { name: /add source/i })); + await user.click(screen.getByRole('button', { name: /new source/i })); const typeSelect = screen.getByLabelText(/^type/i); const options = Array.from(typeSelect.querySelectorAll('option')) as HTMLOptionElement[]; diff --git a/client/src/pages/BudgetSourcesPage/BudgetSourcesPage.tsx b/client/src/pages/BudgetSourcesPage/BudgetSourcesPage.tsx index 670fc9315..378723d1e 100644 --- a/client/src/pages/BudgetSourcesPage/BudgetSourcesPage.tsx +++ b/client/src/pages/BudgetSourcesPage/BudgetSourcesPage.tsx @@ -18,6 +18,7 @@ import { BudgetSubNav } from '../../components/BudgetSubNav/BudgetSubNav.js'; import { BudgetBar } from '../../components/BudgetBar/BudgetBar.js'; import type { BudgetBarSegment } from '../../components/BudgetBar/BudgetBar.js'; import styles from './BudgetSourcesPage.module.css'; +import sharedStyles from '../../styles/shared.module.css'; // ---- Display helpers ---- @@ -491,7 +492,7 @@ export function BudgetSourcesPage() {

{t('sources.error')}

{error}

-
@@ -506,17 +507,9 @@ export function BudgetSourcesPage() { {/* Page header */}

{t('sources.title')}

-
- - {/* Budget sub-navigation */} - - - {/* Section header */} -
-

{t('sources.sectionTitle')}

+ {/* Budget sub-navigation */} + + + {/* Section header */} +
+

{t('sources.sectionTitle')}

+
+ {successMessage && (
{successMessage} diff --git a/client/src/pages/HouseholdItemsPage/HouseholdItemsPage.module.css b/client/src/pages/HouseholdItemsPage/HouseholdItemsPage.module.css index bc1eb1572..50710e58c 100644 --- a/client/src/pages/HouseholdItemsPage/HouseholdItemsPage.module.css +++ b/client/src/pages/HouseholdItemsPage/HouseholdItemsPage.module.css @@ -1,21 +1,17 @@ .container { - max-width: 1400px; - margin: 0 auto; - padding: var(--spacing-8); + composes: pageContainer from '../../styles/shared.module.css'; +} + +.content { + composes: pageContent from '../../styles/shared.module.css'; } .header { - display: flex; - justify-content: space-between; - align-items: center; - margin-bottom: var(--spacing-8); + composes: pageHeader from '../../styles/shared.module.css'; } .pageTitle { - font-size: var(--font-size-4xl); - font-weight: var(--font-weight-bold); - color: var(--color-text-primary); - margin: 0; + composes: pageTitle from '../../styles/shared.module.css'; } .errorBanner { diff --git a/client/src/pages/HouseholdItemsPage/HouseholdItemsPage.test.tsx b/client/src/pages/HouseholdItemsPage/HouseholdItemsPage.test.tsx new file mode 100644 index 000000000..3d1691ac5 --- /dev/null +++ b/client/src/pages/HouseholdItemsPage/HouseholdItemsPage.test.tsx @@ -0,0 +1,174 @@ +/** + * @jest-environment jsdom + */ +import { jest, describe, it, expect, beforeEach } from '@jest/globals'; +import { screen, waitFor, render } from '@testing-library/react'; +import { MemoryRouter } from 'react-router-dom'; +import type * as HouseholdItemsApiTypes from '../../lib/householdItemsApi.js'; +import type * as VendorsApiTypes from '../../lib/vendorsApi.js'; +import type * as HouseholdItemCategoriesApiTypes from '../../lib/householdItemCategoriesApi.js'; + +// Mock API modules BEFORE importing the component +const mockListHouseholdItems = jest.fn(); +const mockDeleteHouseholdItem = jest.fn(); +const mockFetchVendors = jest.fn(); +const mockFetchHouseholdItemCategories = + jest.fn(); + +jest.unstable_mockModule('../../lib/householdItemsApi.js', () => ({ + listHouseholdItems: mockListHouseholdItems, + deleteHouseholdItem: mockDeleteHouseholdItem, + fetchHouseholdItem: jest.fn(), + createHouseholdItem: jest.fn(), + updateHouseholdItem: jest.fn(), +})); + +jest.unstable_mockModule('../../lib/vendorsApi.js', () => ({ + fetchVendors: mockFetchVendors, + fetchVendor: jest.fn(), + createVendor: jest.fn(), + updateVendor: jest.fn(), + deleteVendor: jest.fn(), +})); + +jest.unstable_mockModule('../../lib/householdItemCategoriesApi.js', () => ({ + fetchHouseholdItemCategories: mockFetchHouseholdItemCategories, + fetchHouseholdItemCategory: jest.fn(), + createHouseholdItemCategory: jest.fn(), + updateHouseholdItemCategory: jest.fn(), + deleteHouseholdItemCategory: jest.fn(), +})); + +// ─── Mock: useAreas hook ────────────────────────────────────────────────────── + +jest.unstable_mockModule('../../hooks/useAreas.js', () => ({ + useAreas: () => ({ + areas: [], + isLoading: false, + error: null, + }), +})); + +// ─── Mock: formatters — provides useFormatters() hook ──────────────────────── + +jest.unstable_mockModule('../../lib/formatters.js', () => { + const fmtDate = (d: string | null | undefined, fallback = '—') => { + if (!d) return fallback; + const [year, month, day] = d.slice(0, 10).split('-').map(Number); + if (!year || !month || !day) return fallback; + return new Date(year, month - 1, day).toLocaleDateString('en-US', { + year: 'numeric', + month: 'short', + day: 'numeric', + }); + }; + const fmtCurrency = (n: number) => + new Intl.NumberFormat('en-US', { + style: 'currency', + currency: 'EUR', + minimumFractionDigits: 2, + maximumFractionDigits: 2, + }).format(n); + return { + formatCurrency: fmtCurrency, + formatDate: fmtDate, + formatTime: (ts: string | null | undefined, fallback = '—') => ts ?? fallback, + formatDateTime: (ts: string | null | undefined, fallback = '—') => ts ?? fallback, + formatPercent: (n: number) => `${n.toFixed(2)}%`, + computeActualDuration: () => null, + useFormatters: () => ({ + formatCurrency: fmtCurrency, + formatDate: fmtDate, + formatTime: (ts: string | null | undefined, fallback = '—') => ts ?? fallback, + formatDateTime: (ts: string | null | undefined, fallback = '—') => ts ?? fallback, + formatPercent: (n: number) => `${n.toFixed(2)}%`, + }), + }; +}); + +// ─── Mock: useTableState — returns stable defaults ──────────────────────────── + +jest.unstable_mockModule('../../hooks/useTableState.js', () => ({ + useTableState: () => ({ + tableState: { + search: '', + sortBy: 'name', + sortDir: 'asc', + page: 1, + pageSize: 25, + filters: new Map(), + }, + searchInput: '', + setSearch: jest.fn(), + toApiParams: () => ({ page: 1, pageSize: 25 }), + setFilter: jest.fn(), + setSortBy: jest.fn(), + setPage: jest.fn(), + setPageSize: jest.fn(), + }), +})); + +describe('HouseholdItemsPage — layout consistency (Issue #1142)', () => { + let HouseholdItemsPage: React.ComponentType; + + const emptyHouseholdItemsResponse = { + items: [], + pagination: { totalItems: 0, totalPages: 1, page: 1, pageSize: 25 }, + }; + + beforeEach(async () => { + if (!HouseholdItemsPage) { + const module = await import('./HouseholdItemsPage.js'); + HouseholdItemsPage = module.HouseholdItemsPage; + } + + mockListHouseholdItems.mockReset(); + mockDeleteHouseholdItem.mockReset(); + mockFetchVendors.mockReset(); + mockFetchHouseholdItemCategories.mockReset(); + + mockListHouseholdItems.mockResolvedValue(emptyHouseholdItemsResponse); + mockFetchVendors.mockResolvedValue({ vendors: [], pagination: { totalItems: 0, totalPages: 1, page: 1, pageSize: 100 } }); + mockFetchHouseholdItemCategories.mockResolvedValue({ categories: [] }); + }); + + function renderPage() { + return render( + + + , + ); + } + + // ─── Page layout ──────────────────────────────────────────────────────────── + + describe('page layout', () => { + it('renders an

page title', async () => { + renderPage(); + + await waitFor(() => { + expect(screen.getByRole('heading', { level: 1 })).toBeInTheDocument(); + }); + }); + + it('renders "New Household Item" primary action button', async () => { + renderPage(); + + await waitFor(() => { + expect( + screen.getByRole('button', { name: /new household item/i }), + ).toBeInTheDocument(); + }); + }); + + it('"New Household Item" button is accessible by role, has testid, and is not disabled', async () => { + renderPage(); + + await waitFor(() => { + const btn = screen.getByTestId('new-household-item-button'); + expect(btn).toBeVisible(); + expect(btn).not.toBeDisabled(); + }); + }); + }); +}); diff --git a/client/src/pages/HouseholdItemsPage/HouseholdItemsPage.tsx b/client/src/pages/HouseholdItemsPage/HouseholdItemsPage.tsx index 1b2187d4c..b0709bb5a 100644 --- a/client/src/pages/HouseholdItemsPage/HouseholdItemsPage.tsx +++ b/client/src/pages/HouseholdItemsPage/HouseholdItemsPage.tsx @@ -416,21 +416,22 @@ export function HouseholdItemsPage() { return (
-
-

{t('page.title')}

- -
+
+
+

{t('page.title')}

+ +
- + - + pageKey="householdItems" columns={columns} items={householdItems} @@ -495,6 +496,7 @@ export function HouseholdItemsPage() { )} )} +
); } diff --git a/client/src/pages/InvoicesPage/InvoicesPage.module.css b/client/src/pages/InvoicesPage/InvoicesPage.module.css index 0e4b28882..6ac7d7e1a 100644 --- a/client/src/pages/InvoicesPage/InvoicesPage.module.css +++ b/client/src/pages/InvoicesPage/InvoicesPage.module.css @@ -1,24 +1,19 @@ .container { - padding: var(--spacing-8); - max-width: 1200px; - margin: 0 auto; + composes: pageContainer from '../../styles/shared.module.css'; +} + +.content { + composes: pageContent from '../../styles/shared.module.css'; } /* ---- Page header ---- */ .header { - display: flex; - align-items: center; - justify-content: space-between; - gap: var(--spacing-4); - margin-bottom: var(--spacing-6); + composes: pageHeader from '../../styles/shared.module.css'; } .pageTitle { - font-size: var(--font-size-4xl); - font-weight: var(--font-weight-bold); - color: var(--color-text-primary); - margin: 0; + composes: pageTitle from '../../styles/shared.module.css'; } /* ---- Section title (below sub-nav) ---- */ diff --git a/client/src/pages/InvoicesPage/InvoicesPage.test.tsx b/client/src/pages/InvoicesPage/InvoicesPage.test.tsx new file mode 100644 index 000000000..4d9e03929 --- /dev/null +++ b/client/src/pages/InvoicesPage/InvoicesPage.test.tsx @@ -0,0 +1,158 @@ +/** + * @jest-environment jsdom + */ +import { jest, describe, it, expect, beforeEach } from '@jest/globals'; +import { screen, waitFor, render } from '@testing-library/react'; +import { MemoryRouter } from 'react-router-dom'; +import type * as InvoicesApiTypes from '../../lib/invoicesApi.js'; +import type * as VendorsApiTypes from '../../lib/vendorsApi.js'; + +// Mock API modules BEFORE importing the component +const mockFetchAllInvoices = jest.fn(); +const mockCreateInvoice = jest.fn(); +const mockFetchVendors = jest.fn(); + +jest.unstable_mockModule('../../lib/invoicesApi.js', () => ({ + fetchAllInvoices: mockFetchAllInvoices, + createInvoice: mockCreateInvoice, + fetchInvoice: jest.fn(), + updateInvoice: jest.fn(), + deleteInvoice: jest.fn(), +})); + +jest.unstable_mockModule('../../lib/vendorsApi.js', () => ({ + fetchVendors: mockFetchVendors, + fetchVendor: jest.fn(), + createVendor: jest.fn(), + updateVendor: jest.fn(), + deleteVendor: jest.fn(), +})); + +// ─── Mock: formatters — provides useFormatters() hook ──────────────────────── + +jest.unstable_mockModule('../../lib/formatters.js', () => { + const fmtCurrency = (n: number) => + new Intl.NumberFormat('en-US', { + style: 'currency', + currency: 'EUR', + minimumFractionDigits: 2, + maximumFractionDigits: 2, + }).format(n); + const fmtDate = (d: string | null | undefined, fallback = '—') => { + if (!d) return fallback; + const [year, month, day] = d.slice(0, 10).split('-').map(Number); + if (!year || !month || !day) return fallback; + return new Date(year, month - 1, day).toLocaleDateString('en-US', { + year: 'numeric', + month: 'short', + day: 'numeric', + }); + }; + const fmtTime = (ts: string | null | undefined, fallback = '—') => ts ?? fallback; + const fmtDateTime = (ts: string | null | undefined, fallback = '—') => ts ?? fallback; + return { + formatCurrency: fmtCurrency, + formatDate: fmtDate, + formatTime: fmtTime, + formatDateTime: fmtDateTime, + formatPercent: (n: number) => `${n.toFixed(2)}%`, + computeActualDuration: () => null, + useFormatters: () => ({ + formatCurrency: fmtCurrency, + formatDate: fmtDate, + formatTime: fmtTime, + formatDateTime: fmtDateTime, + formatPercent: (n: number) => `${n.toFixed(2)}%`, + }), + }; +}); + +// ─── Mock: useTableState — returns stable defaults ──────────────────────────── + +jest.unstable_mockModule('../../hooks/useTableState.js', () => ({ + useTableState: () => ({ + tableState: { + search: '', + sortBy: 'date', + sortDir: 'desc', + page: 1, + pageSize: 25, + filters: new Map(), + }, + searchInput: '', + setSearch: jest.fn(), + toApiParams: () => ({ page: 1, pageSize: 25 }), + setFilter: jest.fn(), + setSortBy: jest.fn(), + setPage: jest.fn(), + setPageSize: jest.fn(), + }), +})); + +describe('InvoicesPage — layout consistency (Issue #1142)', () => { + let InvoicesPage: React.ComponentType; + + const emptyInvoiceResponse = { + invoices: [], + pagination: { totalItems: 0, totalPages: 1, page: 1, pageSize: 25 }, + summary: { + pending: { count: 0, totalAmount: 0 }, + paid: { count: 0, totalAmount: 0 }, + claimed: { count: 0, totalAmount: 0 }, + quotation: { count: 0, totalAmount: 0 }, + }, + }; + + beforeEach(async () => { + if (!InvoicesPage) { + const module = await import('./InvoicesPage.js'); + InvoicesPage = module.InvoicesPage; + } + + mockFetchAllInvoices.mockReset(); + mockCreateInvoice.mockReset(); + mockFetchVendors.mockReset(); + + // Default: empty results + mockFetchAllInvoices.mockResolvedValue(emptyInvoiceResponse); + mockFetchVendors.mockResolvedValue({ vendors: [], pagination: { totalItems: 0, totalPages: 1, page: 1, pageSize: 100 } }); + }); + + function renderPage() { + return render( + + + , + ); + } + + // ─── Page layout ──────────────────────────────────────────────────────────── + + describe('page layout', () => { + it('renders an

page title', async () => { + renderPage(); + + await waitFor(() => { + expect(screen.getByRole('heading', { level: 1 })).toBeInTheDocument(); + }); + }); + + it('renders "New Invoice" primary action button', async () => { + renderPage(); + + await waitFor(() => { + expect(screen.getByRole('button', { name: /new invoice/i })).toBeInTheDocument(); + }); + }); + + it('"New Invoice" button is accessible by role and text', async () => { + renderPage(); + + await waitFor(() => { + const btn = screen.getByRole('button', { name: /new invoice/i }); + expect(btn).toBeVisible(); + expect(btn).not.toBeDisabled(); + }); + }); + }); +}); diff --git a/client/src/pages/InvoicesPage/InvoicesPage.tsx b/client/src/pages/InvoicesPage/InvoicesPage.tsx index 00d72b9a2..0477a2ffa 100644 --- a/client/src/pages/InvoicesPage/InvoicesPage.tsx +++ b/client/src/pages/InvoicesPage/InvoicesPage.tsx @@ -169,7 +169,7 @@ export function InvoicesPage() { params.set('pageSize', String(newState.pageSize)); // Delete all known filter param keys first - const knownFilterKeys = ['status', 'vendorId', 'amount', 'date', 'dueDate']; + const knownFilterKeys = ['status', 'vendorId', 'amount']; for (const key of knownFilterKeys) { params.delete(key); } @@ -324,9 +324,6 @@ export function InvoicesPage() { sortable: true, sortKey: 'due_date', defaultVisible: true, - filterable: true, - filterType: 'date' as const, - filterParamKey: 'dueDate', render: (inv) => (inv.dueDate ? formatDate(inv.dueDate) : '—'), }, { @@ -432,23 +429,24 @@ export function InvoicesPage() { return (
-
-

{t('invoices.title')}

- -
+
+
+

{t('invoices.title')}

+ +
- + -

{t('invoices.sectionTitle')}

+

{t('invoices.sectionTitle')}

- + pageKey="invoices" columns={columns} items={invoices} @@ -637,6 +635,7 @@ export function InvoicesPage() { )} +
); } diff --git a/client/src/pages/MilestonesPage/MilestonesPage.module.css b/client/src/pages/MilestonesPage/MilestonesPage.module.css index 81ff2093b..cd81b89ea 100644 --- a/client/src/pages/MilestonesPage/MilestonesPage.module.css +++ b/client/src/pages/MilestonesPage/MilestonesPage.module.css @@ -1,21 +1,17 @@ .container { - max-width: 1400px; - margin: 0 auto; - padding: var(--spacing-8); + composes: pageContainer from '../../styles/shared.module.css'; +} + +.content { + composes: pageContent from '../../styles/shared.module.css'; } .header { - display: flex; - justify-content: space-between; - align-items: center; - margin-bottom: var(--spacing-8); + composes: pageHeader from '../../styles/shared.module.css'; } .pageTitle { - font-size: var(--font-size-4xl); - font-weight: var(--font-weight-bold); - color: var(--color-text-primary); - margin: 0; + composes: pageTitle from '../../styles/shared.module.css'; } .actionsMenu { diff --git a/client/src/pages/MilestonesPage/MilestonesPage.test.tsx b/client/src/pages/MilestonesPage/MilestonesPage.test.tsx new file mode 100644 index 000000000..ca389f834 --- /dev/null +++ b/client/src/pages/MilestonesPage/MilestonesPage.test.tsx @@ -0,0 +1,126 @@ +/** + * @jest-environment jsdom + */ +import { jest, describe, it, expect, beforeEach } from '@jest/globals'; +import { screen, waitFor, render } from '@testing-library/react'; +import { MemoryRouter } from 'react-router-dom'; + +// Mock API modules BEFORE importing the component +const mockListMilestones = jest.fn<() => Promise>(); +const mockDeleteMilestone = jest.fn<() => Promise>(); + +jest.unstable_mockModule('../../lib/milestonesApi.js', () => ({ + listMilestones: mockListMilestones, + deleteMilestone: mockDeleteMilestone, + getMilestone: jest.fn(), + createMilestone: jest.fn(), + updateMilestone: jest.fn(), + linkWorkItem: jest.fn(), + unlinkWorkItem: jest.fn(), + addDependentWorkItem: jest.fn(), + removeDependentWorkItem: jest.fn(), + fetchMilestoneLinkedHouseholdItems: jest.fn(), +})); + +// ─── Mock: useKeyboardShortcuts hook ───────────────────────────────────────── + +jest.unstable_mockModule('../../hooks/useKeyboardShortcuts.js', () => ({ + useKeyboardShortcuts: () => undefined, +})); + +// ─── Mock: KeyboardShortcutsHelp component ──────────────────────────────────── + +jest.unstable_mockModule('../../components/KeyboardShortcutsHelp/KeyboardShortcutsHelp.js', () => ({ + KeyboardShortcutsHelp: () => null, +})); + +// ─── Mock: formatters — provides useFormatters() hook ──────────────────────── + +jest.unstable_mockModule('../../lib/formatters.js', () => { + const fmtDate = (d: string | null | undefined, fallback = '—') => { + if (!d) return fallback; + const [year, month, day] = d.slice(0, 10).split('-').map(Number); + if (!year || !month || !day) return fallback; + return new Date(year, month - 1, day).toLocaleDateString('en-US', { + year: 'numeric', + month: 'short', + day: 'numeric', + }); + }; + const fmtCurrency = (n: number) => + new Intl.NumberFormat('en-US', { + style: 'currency', + currency: 'EUR', + minimumFractionDigits: 2, + maximumFractionDigits: 2, + }).format(n); + return { + formatCurrency: fmtCurrency, + formatDate: fmtDate, + formatTime: (ts: string | null | undefined, fallback = '—') => ts ?? fallback, + formatDateTime: (ts: string | null | undefined, fallback = '—') => ts ?? fallback, + formatPercent: (n: number) => `${n.toFixed(2)}%`, + computeActualDuration: () => null, + useFormatters: () => ({ + formatCurrency: fmtCurrency, + formatDate: fmtDate, + formatTime: (ts: string | null | undefined, fallback = '—') => ts ?? fallback, + formatDateTime: (ts: string | null | undefined, fallback = '—') => ts ?? fallback, + formatPercent: (n: number) => `${n.toFixed(2)}%`, + }), + }; +}); + +describe('MilestonesPage — layout consistency (Issue #1142)', () => { + let MilestonesPage: React.ComponentType; + + beforeEach(async () => { + if (!MilestonesPage) { + const module = await import('./MilestonesPage.js'); + MilestonesPage = module.MilestonesPage; + } + + mockListMilestones.mockReset(); + mockDeleteMilestone.mockReset(); + + mockListMilestones.mockResolvedValue([]); + }); + + function renderPage() { + return render( + + + , + ); + } + + // ─── Page layout ──────────────────────────────────────────────────────────── + + describe('page layout', () => { + it('renders an

page title', async () => { + renderPage(); + + await waitFor(() => { + expect(screen.getByRole('heading', { level: 1 })).toBeInTheDocument(); + }); + }); + + it('renders "New Milestone" primary action button', async () => { + renderPage(); + + await waitFor(() => { + expect(screen.getByRole('button', { name: /new milestone/i })).toBeInTheDocument(); + }); + }); + + it('"New Milestone" button is accessible by role, has testid, and is not disabled', async () => { + renderPage(); + + await waitFor(() => { + const btn = screen.getByTestId('new-milestone-button'); + expect(btn).toBeVisible(); + expect(btn).not.toBeDisabled(); + }); + }); + }); +}); diff --git a/client/src/pages/MilestonesPage/MilestonesPage.tsx b/client/src/pages/MilestonesPage/MilestonesPage.tsx index 2f5162e8d..6f72df73d 100644 --- a/client/src/pages/MilestonesPage/MilestonesPage.tsx +++ b/client/src/pages/MilestonesPage/MilestonesPage.tsx @@ -356,20 +356,21 @@ export function MilestonesPage() { return (
-
-

{t('milestones.page.title')}

- -
- +
+
+

{t('milestones.page.title')}

+ +
+ - + pageKey="milestones" columns={columns} items={filtered} @@ -430,6 +431,7 @@ export function MilestonesPage() { {showShortcutsHelp && ( setShowShortcutsHelp(false)} /> )} +
); } diff --git a/client/src/pages/SubsidyProgramsPage/SubsidyProgramsPage.module.css b/client/src/pages/SubsidyProgramsPage/SubsidyProgramsPage.module.css index e3ed4116d..7b987b3e2 100644 --- a/client/src/pages/SubsidyProgramsPage/SubsidyProgramsPage.module.css +++ b/client/src/pages/SubsidyProgramsPage/SubsidyProgramsPage.module.css @@ -1,29 +1,19 @@ .container { - padding: var(--spacing-8); - max-width: 1200px; - margin: 0 auto; + composes: pageContainer from '../../styles/shared.module.css'; } .content { - display: flex; - flex-direction: column; - gap: var(--spacing-6); + composes: pageContent from '../../styles/shared.module.css'; } /* ---- Page header ---- */ .pageHeader { - display: flex; - align-items: center; - justify-content: space-between; - gap: var(--spacing-4); + composes: pageHeader from '../../styles/shared.module.css'; } .pageTitle { - font-size: var(--font-size-4xl); - font-weight: var(--font-weight-bold); - color: var(--color-text-primary); - margin: 0; + composes: pageTitle from '../../styles/shared.module.css'; } /* ---- Section header (below sub-nav) ---- */ @@ -561,13 +551,6 @@ * ============================================================ */ @media (max-width: 767px) { - .container { - padding: var(--spacing-4); - } - - .pageTitle { - font-size: var(--font-size-2xl); - } .card { padding: var(--spacing-4); diff --git a/client/src/pages/SubsidyProgramsPage/SubsidyProgramsPage.test.tsx b/client/src/pages/SubsidyProgramsPage/SubsidyProgramsPage.test.tsx index a5d443351..d84485c4e 100644 --- a/client/src/pages/SubsidyProgramsPage/SubsidyProgramsPage.test.tsx +++ b/client/src/pages/SubsidyProgramsPage/SubsidyProgramsPage.test.tsx @@ -271,13 +271,13 @@ describe('SubsidyProgramsPage', () => { }); }); - it('renders "Add Program" button', async () => { + it('renders "New Subsidy Program" button', async () => { mockFetchSubsidyPrograms.mockResolvedValueOnce(emptyProgramsResponse); renderPage(); await waitFor(() => { - expect(screen.getByRole('button', { name: /add program/i })).toBeInTheDocument(); + expect(screen.getByRole('button', { name: /new subsidy program/i })).toBeInTheDocument(); }); }); @@ -545,34 +545,34 @@ describe('SubsidyProgramsPage', () => { // ─── Create form ─────────────────────────────────────────────────────────── describe('create form', () => { - it('shows create form after clicking "Add Program" button', async () => { + it('shows create form after clicking "New Subsidy Program" button', async () => { mockFetchSubsidyPrograms.mockResolvedValueOnce(emptyProgramsResponse); const user = userEvent.setup(); renderPage(); await waitFor(() => { - expect(screen.getByRole('button', { name: /add program/i })).toBeInTheDocument(); + expect(screen.getByRole('button', { name: /new subsidy program/i })).toBeInTheDocument(); }); - await user.click(screen.getByRole('button', { name: /add program/i })); + await user.click(screen.getByRole('button', { name: /new subsidy program/i })); expect(screen.getByRole('heading', { name: /new subsidy program/i })).toBeInTheDocument(); }); - it('disables "Add Program" button when create form is open', async () => { + it('disables "New Subsidy Program" button when create form is open', async () => { mockFetchSubsidyPrograms.mockResolvedValueOnce(emptyProgramsResponse); const user = userEvent.setup(); renderPage(); await waitFor(() => { - expect(screen.getByRole('button', { name: /add program/i })).not.toBeDisabled(); + expect(screen.getByRole('button', { name: /new subsidy program/i })).not.toBeDisabled(); }); - await user.click(screen.getByRole('button', { name: /add program/i })); + await user.click(screen.getByRole('button', { name: /new subsidy program/i })); - expect(screen.getByRole('button', { name: /add program/i })).toBeDisabled(); + expect(screen.getByRole('button', { name: /new subsidy program/i })).toBeDisabled(); }); it('hides create form after clicking Cancel', async () => { @@ -582,10 +582,10 @@ describe('SubsidyProgramsPage', () => { renderPage(); await waitFor(() => { - expect(screen.getByRole('button', { name: /add program/i })).toBeInTheDocument(); + expect(screen.getByRole('button', { name: /new subsidy program/i })).toBeInTheDocument(); }); - await user.click(screen.getByRole('button', { name: /add program/i })); + await user.click(screen.getByRole('button', { name: /new subsidy program/i })); expect(screen.getByRole('heading', { name: /new subsidy program/i })).toBeInTheDocument(); await user.click(screen.getByRole('button', { name: /cancel/i })); @@ -601,10 +601,10 @@ describe('SubsidyProgramsPage', () => { renderPage(); await waitFor(() => { - expect(screen.getByRole('button', { name: /add program/i })).toBeInTheDocument(); + expect(screen.getByRole('button', { name: /new subsidy program/i })).toBeInTheDocument(); }); - await user.click(screen.getByRole('button', { name: /add program/i })); + await user.click(screen.getByRole('button', { name: /new subsidy program/i })); // Set a reduction value but leave name empty, then try to submit const reductionValueInput = screen.getByLabelText(/value \(%\)/i); @@ -627,10 +627,10 @@ describe('SubsidyProgramsPage', () => { renderPage(); await waitFor(() => { - expect(screen.getByRole('button', { name: /add program/i })).toBeInTheDocument(); + expect(screen.getByRole('button', { name: /new subsidy program/i })).toBeInTheDocument(); }); - await user.click(screen.getByRole('button', { name: /add program/i })); + await user.click(screen.getByRole('button', { name: /new subsidy program/i })); // Fill name, set invalid reduction value await user.type(screen.getByLabelText(/name/i), 'Test Program'); @@ -655,10 +655,10 @@ describe('SubsidyProgramsPage', () => { renderPage(); await waitFor(() => { - expect(screen.getByRole('button', { name: /add program/i })).toBeInTheDocument(); + expect(screen.getByRole('button', { name: /new subsidy program/i })).toBeInTheDocument(); }); - await user.click(screen.getByRole('button', { name: /add program/i })); + await user.click(screen.getByRole('button', { name: /new subsidy program/i })); await user.type(screen.getByLabelText(/name/i), 'Pct Program'); // reductionType defaults to 'percentage' @@ -681,10 +681,10 @@ describe('SubsidyProgramsPage', () => { renderPage(); await waitFor(() => { - expect(screen.getByRole('button', { name: /add program/i })).toBeInTheDocument(); + expect(screen.getByRole('button', { name: /new subsidy program/i })).toBeInTheDocument(); }); - await user.click(screen.getByRole('button', { name: /add program/i })); + await user.click(screen.getByRole('button', { name: /new subsidy program/i })); await user.type(screen.getByLabelText(/name/i), 'Energy Rebate'); const reductionValueInput = screen.getByLabelText(/value \(%\)/i); @@ -712,10 +712,10 @@ describe('SubsidyProgramsPage', () => { renderPage(); await waitFor(() => { - expect(screen.getByRole('button', { name: /add program/i })).toBeInTheDocument(); + expect(screen.getByRole('button', { name: /new subsidy program/i })).toBeInTheDocument(); }); - await user.click(screen.getByRole('button', { name: /add program/i })); + await user.click(screen.getByRole('button', { name: /new subsidy program/i })); await user.type(screen.getByLabelText(/name/i), 'Energy Rebate'); const reductionValueInput = screen.getByLabelText(/value \(%\)/i); @@ -736,10 +736,10 @@ describe('SubsidyProgramsPage', () => { renderPage(); await waitFor(() => { - expect(screen.getByRole('button', { name: /add program/i })).toBeInTheDocument(); + expect(screen.getByRole('button', { name: /new subsidy program/i })).toBeInTheDocument(); }); - await user.click(screen.getByRole('button', { name: /add program/i })); + await user.click(screen.getByRole('button', { name: /new subsidy program/i })); await user.type(screen.getByLabelText(/name/i), 'Energy Rebate'); const reductionValueInput = screen.getByLabelText(/value \(%\)/i); @@ -764,10 +764,10 @@ describe('SubsidyProgramsPage', () => { renderPage(); await waitFor(() => { - expect(screen.getByRole('button', { name: /add program/i })).toBeInTheDocument(); + expect(screen.getByRole('button', { name: /new subsidy program/i })).toBeInTheDocument(); }); - await user.click(screen.getByRole('button', { name: /add program/i })); + await user.click(screen.getByRole('button', { name: /new subsidy program/i })); await user.type(screen.getByLabelText(/name/i), 'Dup Program'); const reductionValueInput = screen.getByLabelText(/value \(%\)/i); fireEvent.change(reductionValueInput, { target: { value: '10' } }); @@ -786,10 +786,10 @@ describe('SubsidyProgramsPage', () => { renderPage(); await waitFor(() => { - expect(screen.getByRole('button', { name: /add program/i })).toBeInTheDocument(); + expect(screen.getByRole('button', { name: /new subsidy program/i })).toBeInTheDocument(); }); - await user.click(screen.getByRole('button', { name: /add program/i })); + await user.click(screen.getByRole('button', { name: /new subsidy program/i })); await user.type(screen.getByLabelText(/name/i), 'Program Name'); const reductionValueInput = screen.getByLabelText(/value \(%\)/i); fireEvent.change(reductionValueInput, { target: { value: '10' } }); @@ -807,10 +807,10 @@ describe('SubsidyProgramsPage', () => { renderPage(); await waitFor(() => { - expect(screen.getByRole('button', { name: /add program/i })).toBeInTheDocument(); + expect(screen.getByRole('button', { name: /new subsidy program/i })).toBeInTheDocument(); }); - await user.click(screen.getByRole('button', { name: /add program/i })); + await user.click(screen.getByRole('button', { name: /new subsidy program/i })); // Name is empty by default const submitButton = screen.getByRole('button', { name: /create program/i }); @@ -824,10 +824,10 @@ describe('SubsidyProgramsPage', () => { renderPage(); await waitFor(() => { - expect(screen.getByRole('button', { name: /add program/i })).toBeInTheDocument(); + expect(screen.getByRole('button', { name: /new subsidy program/i })).toBeInTheDocument(); }); - await user.click(screen.getByRole('button', { name: /add program/i })); + await user.click(screen.getByRole('button', { name: /new subsidy program/i })); await user.type(screen.getByLabelText(/name/i), 'Some Program'); // reductionValue is empty @@ -842,10 +842,10 @@ describe('SubsidyProgramsPage', () => { renderPage(); await waitFor(() => { - expect(screen.getByRole('button', { name: /add program/i })).toBeInTheDocument(); + expect(screen.getByRole('button', { name: /new subsidy program/i })).toBeInTheDocument(); }); - await user.click(screen.getByRole('button', { name: /add program/i })); + await user.click(screen.getByRole('button', { name: /new subsidy program/i })); await user.type(screen.getByLabelText(/name/i), 'Some Program'); const reductionValueInput = screen.getByLabelText(/value \(%\)/i); fireEvent.change(reductionValueInput, { target: { value: '10' } }); @@ -862,10 +862,10 @@ describe('SubsidyProgramsPage', () => { renderPage(); await waitFor(() => { - expect(screen.getByRole('button', { name: /add program/i })).toBeInTheDocument(); + expect(screen.getByRole('button', { name: /new subsidy program/i })).toBeInTheDocument(); }); - await user.click(screen.getByRole('button', { name: /add program/i })); + await user.click(screen.getByRole('button', { name: /new subsidy program/i })); await waitFor(() => { expect(screen.getByLabelText('Materials')).toBeInTheDocument(); @@ -881,10 +881,10 @@ describe('SubsidyProgramsPage', () => { renderPage(); await waitFor(() => { - expect(screen.getByRole('button', { name: /add program/i })).toBeInTheDocument(); + expect(screen.getByRole('button', { name: /new subsidy program/i })).toBeInTheDocument(); }); - await user.click(screen.getByRole('button', { name: /add program/i })); + await user.click(screen.getByRole('button', { name: /new subsidy program/i })); await waitFor(() => { expect(screen.queryByText(/applicable budget categories/i)).not.toBeInTheDocument(); @@ -899,10 +899,10 @@ describe('SubsidyProgramsPage', () => { renderPage(); await waitFor(() => { - expect(screen.getByRole('button', { name: /add program/i })).toBeInTheDocument(); + expect(screen.getByRole('button', { name: /new subsidy program/i })).toBeInTheDocument(); }); - await user.click(screen.getByRole('button', { name: /add program/i })); + await user.click(screen.getByRole('button', { name: /new subsidy program/i })); await waitFor(() => { expect(screen.getByLabelText('Materials')).toBeInTheDocument(); @@ -928,10 +928,10 @@ describe('SubsidyProgramsPage', () => { renderPage(); await waitFor(() => { - expect(screen.getByRole('button', { name: /add program/i })).toBeInTheDocument(); + expect(screen.getByRole('button', { name: /new subsidy program/i })).toBeInTheDocument(); }); - await user.click(screen.getByRole('button', { name: /add program/i })); + await user.click(screen.getByRole('button', { name: /new subsidy program/i })); await waitFor(() => { expect(screen.getByLabelText('Materials')).toBeInTheDocument(); @@ -1447,10 +1447,10 @@ describe('SubsidyProgramsPage', () => { renderPage(); await waitFor(() => { - expect(screen.getByRole('button', { name: /add program/i })).toBeInTheDocument(); + expect(screen.getByRole('button', { name: /new subsidy program/i })).toBeInTheDocument(); }); - await user.click(screen.getByRole('button', { name: /add program/i })); + await user.click(screen.getByRole('button', { name: /new subsidy program/i })); expect(screen.getByLabelText(/maximum amount/i)).toBeInTheDocument(); }); @@ -1462,10 +1462,10 @@ describe('SubsidyProgramsPage', () => { renderPage(); await waitFor(() => { - expect(screen.getByRole('button', { name: /add program/i })).toBeInTheDocument(); + expect(screen.getByRole('button', { name: /new subsidy program/i })).toBeInTheDocument(); }); - await user.click(screen.getByRole('button', { name: /add program/i })); + await user.click(screen.getByRole('button', { name: /new subsidy program/i })); const maxAmountInput = screen.getByLabelText(/maximum amount/i); expect(maxAmountInput).toHaveAttribute('placeholder', 'No limit'); @@ -1479,10 +1479,10 @@ describe('SubsidyProgramsPage', () => { renderPage(); await waitFor(() => { - expect(screen.getByRole('button', { name: /add program/i })).toBeInTheDocument(); + expect(screen.getByRole('button', { name: /new subsidy program/i })).toBeInTheDocument(); }); - await user.click(screen.getByRole('button', { name: /add program/i })); + await user.click(screen.getByRole('button', { name: /new subsidy program/i })); await user.type(screen.getByLabelText(/name/i), 'Capped Subsidy'); const reductionValueInput = screen.getByLabelText(/value \(%\)/i); fireEvent.change(reductionValueInput, { target: { value: '15' } }); @@ -1504,10 +1504,10 @@ describe('SubsidyProgramsPage', () => { renderPage(); await waitFor(() => { - expect(screen.getByRole('button', { name: /add program/i })).toBeInTheDocument(); + expect(screen.getByRole('button', { name: /new subsidy program/i })).toBeInTheDocument(); }); - await user.click(screen.getByRole('button', { name: /add program/i })); + await user.click(screen.getByRole('button', { name: /new subsidy program/i })); await user.type(screen.getByLabelText(/name/i), 'Energy Rebate'); const reductionValueInput = screen.getByLabelText(/value \(%\)/i); fireEvent.change(reductionValueInput, { target: { value: '15' } }); @@ -1685,10 +1685,10 @@ describe('SubsidyProgramsPage', () => { renderPage(); await waitFor(() => { - expect(screen.getByRole('button', { name: /add program/i })).toBeInTheDocument(); + expect(screen.getByRole('button', { name: /new subsidy program/i })).toBeInTheDocument(); }); - await user.click(screen.getByRole('button', { name: /add program/i })); + await user.click(screen.getByRole('button', { name: /new subsidy program/i })); await user.type(screen.getByLabelText(/name/i), 'Energy Rebate'); const reductionValueInput = screen.getByLabelText(/value \(%\)/i); fireEvent.change(reductionValueInput, { target: { value: '15' } }); diff --git a/client/src/pages/SubsidyProgramsPage/SubsidyProgramsPage.tsx b/client/src/pages/SubsidyProgramsPage/SubsidyProgramsPage.tsx index d7ebd9ce3..3bbf59758 100644 --- a/client/src/pages/SubsidyProgramsPage/SubsidyProgramsPage.tsx +++ b/client/src/pages/SubsidyProgramsPage/SubsidyProgramsPage.tsx @@ -19,6 +19,7 @@ import { ApiClientError } from '../../lib/apiClient.js'; import { useFormatters } from '../../lib/formatters.js'; import { BudgetSubNav } from '../../components/BudgetSubNav/BudgetSubNav.js'; import styles from './SubsidyProgramsPage.module.css'; +import sharedStyles from '../../styles/shared.module.css'; // ---- Display helpers ---- @@ -363,10 +364,10 @@ export function SubsidyProgramsPage() {
-

Budget

+

{t('subsidies.pageTitle')}

-
Loading subsidy programs...
+
{t('subsidies.loading')}
); @@ -377,14 +378,14 @@ export function SubsidyProgramsPage() {
-

Budget

+

{t('subsidies.pageTitle')}

-

Error

+

{t('subsidies.error')}

{error}

-
@@ -397,18 +398,10 @@ export function SubsidyProgramsPage() {
{/* Page header */}
-

Budget

-
- - {/* Budget sub-navigation */} - - - {/* Section header */} -
-

{t('subsidies.title')}

+

{t('subsidies.pageTitle')}

+ {/* Budget sub-navigation */} + + + {/* Section header */} +
+

{t('subsidies.title')}

+
+ {successMessage && (
{successMessage} @@ -435,10 +436,9 @@ export function SubsidyProgramsPage() { {/* Create form */} {showCreateForm && (
-

New Subsidy Program

+

{t('subsidies.newSubsidyProgram')}

- Subsidy programs represent government or institutional programs that reduce - construction costs through percentage or fixed-amount reductions. + {t('subsidies.subsidyDescription')}

{createError && ( @@ -451,7 +451,7 @@ export function SubsidyProgramsPage() { {/* Row 1: Name */}
setNewName(e.target.value)} className={styles.input} - placeholder="e.g., Energy Efficiency Rebate Program" + placeholder={t('subsidies.form.placeholders.name')} maxLength={200} disabled={isCreating} autoFocus diff --git a/client/src/pages/VendorsPage/VendorsPage.module.css b/client/src/pages/VendorsPage/VendorsPage.module.css index 1db212100..adc91fcd0 100644 --- a/client/src/pages/VendorsPage/VendorsPage.module.css +++ b/client/src/pages/VendorsPage/VendorsPage.module.css @@ -1,21 +1,17 @@ .container { - max-width: 1400px; - margin: 0 auto; - padding: var(--spacing-8); + composes: pageContainer from '../../styles/shared.module.css'; +} + +.content { + composes: pageContent from '../../styles/shared.module.css'; } .header { - display: flex; - justify-content: space-between; - align-items: center; - margin-bottom: var(--spacing-8); + composes: pageHeader from '../../styles/shared.module.css'; } .pageTitle { - font-size: var(--font-size-4xl); - font-weight: var(--font-weight-bold); - color: var(--color-text-primary); - margin: 0; + composes: pageTitle from '../../styles/shared.module.css'; } .sectionTitle { diff --git a/client/src/pages/VendorsPage/VendorsPage.test.tsx b/client/src/pages/VendorsPage/VendorsPage.test.tsx new file mode 100644 index 000000000..ff0f78aab --- /dev/null +++ b/client/src/pages/VendorsPage/VendorsPage.test.tsx @@ -0,0 +1,163 @@ +/** + * @jest-environment jsdom + */ +import { jest, describe, it, expect, beforeEach } from '@jest/globals'; +import { screen, waitFor, render } from '@testing-library/react'; +import { MemoryRouter } from 'react-router-dom'; +import type * as VendorsApiTypes from '../../lib/vendorsApi.js'; + +// Mock API modules BEFORE importing the component +const mockFetchVendors = jest.fn(); +const mockCreateVendor = jest.fn(); +const mockDeleteVendor = jest.fn(); + +jest.unstable_mockModule('../../lib/vendorsApi.js', () => ({ + fetchVendors: mockFetchVendors, + fetchVendor: jest.fn(), + createVendor: mockCreateVendor, + updateVendor: jest.fn(), + deleteVendor: mockDeleteVendor, +})); + +// ─── Mock: useTrades hook ───────────────────────────────────────────────────── + +jest.unstable_mockModule('../../hooks/useTrades.js', () => ({ + useTrades: () => ({ + trades: [], + isLoading: false, + error: null, + }), +})); + +// ─── Mock: TradePicker component ───────────────────────────────────────────── + +jest.unstable_mockModule('../../components/TradePicker/TradePicker.js', () => ({ + TradePicker: ({ value, onChange }: { value: string | null; onChange: (v: string | null) => void }) => ( + + ), +})); + +// ─── Mock: formatters — provides useFormatters() hook ──────────────────────── + +jest.unstable_mockModule('../../lib/formatters.js', () => { + const fmtDate = (d: string | null | undefined, fallback = '—') => { + if (!d) return fallback; + const [year, month, day] = d.slice(0, 10).split('-').map(Number); + if (!year || !month || !day) return fallback; + return new Date(year, month - 1, day).toLocaleDateString('en-US', { + year: 'numeric', + month: 'short', + day: 'numeric', + }); + }; + const fmtCurrency = (n: number) => + new Intl.NumberFormat('en-US', { + style: 'currency', + currency: 'EUR', + minimumFractionDigits: 2, + maximumFractionDigits: 2, + }).format(n); + return { + formatCurrency: fmtCurrency, + formatDate: fmtDate, + formatTime: (ts: string | null | undefined, fallback = '—') => ts ?? fallback, + formatDateTime: (ts: string | null | undefined, fallback = '—') => ts ?? fallback, + formatPercent: (n: number) => `${n.toFixed(2)}%`, + computeActualDuration: () => null, + useFormatters: () => ({ + formatCurrency: fmtCurrency, + formatDate: fmtDate, + formatTime: (ts: string | null | undefined, fallback = '—') => ts ?? fallback, + formatDateTime: (ts: string | null | undefined, fallback = '—') => ts ?? fallback, + formatPercent: (n: number) => `${n.toFixed(2)}%`, + }), + }; +}); + +// ─── Mock: useTableState — returns stable defaults ──────────────────────────── + +jest.unstable_mockModule('../../hooks/useTableState.js', () => ({ + useTableState: () => ({ + tableState: { + search: '', + sortBy: 'name', + sortDir: 'asc', + page: 1, + pageSize: 25, + filters: new Map(), + }, + searchInput: '', + setSearch: jest.fn(), + toApiParams: () => ({ page: 1, pageSize: 25 }), + setFilter: jest.fn(), + setSortBy: jest.fn(), + setPage: jest.fn(), + setPageSize: jest.fn(), + }), +})); + +describe('VendorsPage — layout consistency (Issue #1142)', () => { + let VendorsPage: React.ComponentType; + + const emptyVendorResponse = { + vendors: [], + pagination: { totalItems: 0, totalPages: 1, page: 1, pageSize: 25 }, + }; + + beforeEach(async () => { + if (!VendorsPage) { + const module = await import('./VendorsPage.js'); + VendorsPage = module.VendorsPage; + } + + mockFetchVendors.mockReset(); + mockCreateVendor.mockReset(); + mockDeleteVendor.mockReset(); + + mockFetchVendors.mockResolvedValue(emptyVendorResponse); + }); + + function renderPage() { + return render( + + + , + ); + } + + // ─── Page layout ──────────────────────────────────────────────────────────── + + describe('page layout', () => { + it('renders an

page title', async () => { + renderPage(); + + await waitFor(() => { + expect(screen.getByRole('heading', { level: 1 })).toBeInTheDocument(); + }); + }); + + it('renders "New Vendor" primary action button', async () => { + renderPage(); + + await waitFor(() => { + expect(screen.getByRole('button', { name: /new vendor/i })).toBeInTheDocument(); + }); + }); + + it('"New Vendor" button is accessible by role and text', async () => { + renderPage(); + + await waitFor(() => { + const btn = screen.getByRole('button', { name: /new vendor/i }); + expect(btn).toBeVisible(); + expect(btn).not.toBeDisabled(); + }); + }); + }); +}); diff --git a/client/src/pages/VendorsPage/VendorsPage.tsx b/client/src/pages/VendorsPage/VendorsPage.tsx index 50d414b57..e16ce9afc 100644 --- a/client/src/pages/VendorsPage/VendorsPage.tsx +++ b/client/src/pages/VendorsPage/VendorsPage.tsx @@ -357,23 +357,24 @@ export function VendorsPage() { return (
-
-

{t('vendors.title')}

- -
+
+
+

{t('vendors.title')}

+ +
- + -

{t('vendors.sectionTitle')}

+

{t('vendors.sectionTitle')}

- + pageKey="vendors" columns={columns} items={vendors} @@ -568,6 +569,7 @@ export function VendorsPage() { )} )} +
); } diff --git a/client/src/pages/WorkItemsPage/WorkItemsPage.module.css b/client/src/pages/WorkItemsPage/WorkItemsPage.module.css index bc1eb1572..50710e58c 100644 --- a/client/src/pages/WorkItemsPage/WorkItemsPage.module.css +++ b/client/src/pages/WorkItemsPage/WorkItemsPage.module.css @@ -1,21 +1,17 @@ .container { - max-width: 1400px; - margin: 0 auto; - padding: var(--spacing-8); + composes: pageContainer from '../../styles/shared.module.css'; +} + +.content { + composes: pageContent from '../../styles/shared.module.css'; } .header { - display: flex; - justify-content: space-between; - align-items: center; - margin-bottom: var(--spacing-8); + composes: pageHeader from '../../styles/shared.module.css'; } .pageTitle { - font-size: var(--font-size-4xl); - font-weight: var(--font-weight-bold); - color: var(--color-text-primary); - margin: 0; + composes: pageTitle from '../../styles/shared.module.css'; } .errorBanner { diff --git a/client/src/pages/WorkItemsPage/WorkItemsPage.test.tsx b/client/src/pages/WorkItemsPage/WorkItemsPage.test.tsx new file mode 100644 index 000000000..42b152873 --- /dev/null +++ b/client/src/pages/WorkItemsPage/WorkItemsPage.test.tsx @@ -0,0 +1,181 @@ +/** + * @jest-environment jsdom + */ +import { jest, describe, it, expect, beforeEach } from '@jest/globals'; +import { screen, waitFor, render } from '@testing-library/react'; +import { MemoryRouter } from 'react-router-dom'; +import type * as WorkItemsApiTypes from '../../lib/workItemsApi.js'; +import type { listUsers as listUsersFn } from '../../lib/usersApi.js'; +import type * as VendorsApiTypes from '../../lib/vendorsApi.js'; + +// Mock API modules BEFORE importing the component +const mockListWorkItems = jest.fn(); +const mockDeleteWorkItem = jest.fn(); +const mockListUsers = jest.fn(); +const mockFetchVendors = jest.fn(); + +jest.unstable_mockModule('../../lib/workItemsApi.js', () => ({ + listWorkItems: mockListWorkItems, + deleteWorkItem: mockDeleteWorkItem, + fetchWorkItem: jest.fn(), + createWorkItem: jest.fn(), + updateWorkItem: jest.fn(), +})); + +jest.unstable_mockModule('../../lib/usersApi.js', () => ({ + listUsers: mockListUsers, + adminUpdateUser: jest.fn(), + deactivateUser: jest.fn(), +})); + +jest.unstable_mockModule('../../lib/vendorsApi.js', () => ({ + fetchVendors: mockFetchVendors, + fetchVendor: jest.fn(), + createVendor: jest.fn(), + updateVendor: jest.fn(), + deleteVendor: jest.fn(), +})); + +// ─── Mock: useAreas hook ────────────────────────────────────────────────────── + +jest.unstable_mockModule('../../hooks/useAreas.js', () => ({ + useAreas: () => ({ + areas: [], + isLoading: false, + error: null, + }), +})); + +// ─── Mock: useKeyboardShortcuts hook ───────────────────────────────────────── + +jest.unstable_mockModule('../../hooks/useKeyboardShortcuts.js', () => ({ + useKeyboardShortcuts: () => undefined, +})); + +// ─── Mock: KeyboardShortcutsHelp component ──────────────────────────────────── + +jest.unstable_mockModule('../../components/KeyboardShortcutsHelp/KeyboardShortcutsHelp.js', () => ({ + KeyboardShortcutsHelp: () => null, +})); + +// ─── Mock: formatters — provides useFormatters() hook ──────────────────────── + +jest.unstable_mockModule('../../lib/formatters.js', () => { + const fmtDate = (d: string | null | undefined, fallback = '—') => { + if (!d) return fallback; + const [year, month, day] = d.slice(0, 10).split('-').map(Number); + if (!year || !month || !day) return fallback; + return new Date(year, month - 1, day).toLocaleDateString('en-US', { + year: 'numeric', + month: 'short', + day: 'numeric', + }); + }; + const fmtCurrency = (n: number) => + new Intl.NumberFormat('en-US', { + style: 'currency', + currency: 'EUR', + minimumFractionDigits: 2, + maximumFractionDigits: 2, + }).format(n); + return { + formatCurrency: fmtCurrency, + formatDate: fmtDate, + formatTime: (ts: string | null | undefined, fallback = '—') => ts ?? fallback, + formatDateTime: (ts: string | null | undefined, fallback = '—') => ts ?? fallback, + formatPercent: (n: number) => `${n.toFixed(2)}%`, + computeActualDuration: () => null, + useFormatters: () => ({ + formatCurrency: fmtCurrency, + formatDate: fmtDate, + formatTime: (ts: string | null | undefined, fallback = '—') => ts ?? fallback, + formatDateTime: (ts: string | null | undefined, fallback = '—') => ts ?? fallback, + formatPercent: (n: number) => `${n.toFixed(2)}%`, + }), + }; +}); + +// ─── Mock: useTableState — returns stable defaults ──────────────────────────── + +jest.unstable_mockModule('../../hooks/useTableState.js', () => ({ + useTableState: () => ({ + tableState: { + search: '', + sortBy: 'title', + sortDir: 'asc', + page: 1, + pageSize: 25, + filters: new Map(), + }, + searchInput: '', + setSearch: jest.fn(), + toApiParams: () => ({ page: 1, pageSize: 25 }), + setFilter: jest.fn(), + setSortBy: jest.fn(), + setPage: jest.fn(), + setPageSize: jest.fn(), + }), +})); + +describe('WorkItemsPage — layout consistency (Issue #1142)', () => { + let WorkItemsPage: React.ComponentType; + + const emptyWorkItemsResponse = { + items: [], + pagination: { totalItems: 0, totalPages: 1, page: 1, pageSize: 25 }, + }; + + beforeEach(async () => { + if (!WorkItemsPage) { + const module = await import('./WorkItemsPage.js'); + WorkItemsPage = module.WorkItemsPage; + } + + mockListWorkItems.mockReset(); + mockDeleteWorkItem.mockReset(); + mockListUsers.mockReset(); + mockFetchVendors.mockReset(); + + mockListWorkItems.mockResolvedValue(emptyWorkItemsResponse); + mockListUsers.mockResolvedValue({ users: [] }); + mockFetchVendors.mockResolvedValue({ vendors: [], pagination: { totalItems: 0, totalPages: 1, page: 1, pageSize: 100 } }); + }); + + function renderPage() { + return render( + + + , + ); + } + + // ─── Page layout ──────────────────────────────────────────────────────────── + + describe('page layout', () => { + it('renders an

page title', async () => { + renderPage(); + + await waitFor(() => { + expect(screen.getByRole('heading', { level: 1 })).toBeInTheDocument(); + }); + }); + + it('renders "New Work Item" primary action button', async () => { + renderPage(); + + await waitFor(() => { + expect(screen.getByRole('button', { name: /new work item/i })).toBeInTheDocument(); + }); + }); + + it('"New Work Item" button is accessible by role and text', async () => { + renderPage(); + + await waitFor(() => { + const btn = screen.getByRole('button', { name: /new work item/i }); + expect(btn).toBeVisible(); + expect(btn).not.toBeDisabled(); + }); + }); + }); +}); diff --git a/client/src/pages/WorkItemsPage/WorkItemsPage.tsx b/client/src/pages/WorkItemsPage/WorkItemsPage.tsx index 0b8be5ebf..af7610343 100644 --- a/client/src/pages/WorkItemsPage/WorkItemsPage.tsx +++ b/client/src/pages/WorkItemsPage/WorkItemsPage.tsx @@ -421,21 +421,22 @@ export function WorkItemsPage() { return (
-
-

{t('list.pageTitle')}

- -
+
+
+

{t('list.pageTitle')}

+ +
- + - + pageKey="workItems" columns={columns} items={workItems} @@ -505,6 +506,7 @@ export function WorkItemsPage() { {showShortcutsHelp && ( setShowShortcutsHelp(false)} /> )} +
); } diff --git a/client/src/styles/shared.module.css b/client/src/styles/shared.module.css index 7372d4787..e070a54a2 100644 --- a/client/src/styles/shared.module.css +++ b/client/src/styles/shared.module.css @@ -442,3 +442,70 @@ padding: var(--spacing-3); font-size: var(--font-size-sm); } + +/* ============================================================ + * PAGE LAYOUT + * ============================================================ */ + +.pageContainer { + max-width: 1200px; + margin: 0 auto; + padding: var(--spacing-8); +} + +.pageContent { + display: flex; + flex-direction: column; + gap: var(--spacing-6); +} + +.pageHeader { + display: flex; + justify-content: space-between; + align-items: center; + gap: var(--spacing-4); +} + +.pageTitle { + font-size: var(--font-size-4xl); + font-weight: var(--font-weight-bold); + color: var(--color-text-primary); + margin: 0; +} + +@media (min-width: 768px) and (max-width: 1023px) { + .pageContainer { + padding: var(--spacing-6); + } +} + +@media (max-width: 767px) { + .pageContainer { + padding: var(--spacing-4); + } + + .pageHeader { + flex-direction: column; + align-items: stretch; + } + + .pageHeader button, + .pageHeader a { + min-height: 44px; + width: 100%; + } + + .pageTitle { + font-size: var(--font-size-2xl); + } +} + +@media (prefers-reduced-motion: reduce) { + .pageContainer, + .pageContent, + .pageHeader, + .pageTitle { + transition: none; + animation: none; + } +} diff --git a/e2e/pages/BudgetSourcesPage.ts b/e2e/pages/BudgetSourcesPage.ts index 558bc2bf2..2a8305165 100644 --- a/e2e/pages/BudgetSourcesPage.ts +++ b/e2e/pages/BudgetSourcesPage.ts @@ -4,8 +4,9 @@ * The page renders: * - An h1 "Budget" page title * - BudgetSubNav - * - An h2 "Sources" section header with an "Add Source" button - * - An inline create form (h2 "New Budget Source") toggled by "Add Source" + * - A page header h1 "Budget" with a "New Source" primary action button + * Note: button text was standardised from "Add Source" to "New Source" in issue #1142. + * - An inline create form (h2 "New Budget Source") toggled by "New Source" * - A sources list (class `.sourcesList`) with inline edit forms per row * - A delete confirmation modal (role="dialog", aria-labelledby="delete-modal-title") * - Success/error banners (role="alert") @@ -70,7 +71,8 @@ export class BudgetSourcesPage { this.heading = page.getByRole('heading', { level: 1, name: 'Budget', exact: true }); this.sectionTitle = page.getByRole('heading', { level: 2, name: 'Sources', exact: true }); - this.addSourceButton = page.getByRole('button', { name: 'Add Source', exact: true }); + // Button text standardised to "New Source" in issue #1142 (was "Add Source") + this.addSourceButton = page.getByRole('button', { name: 'New Source', exact: true }); // Create form — identified by its h2 heading "New Budget Source" this.createFormHeading = page.getByRole('heading', { @@ -129,7 +131,7 @@ export class BudgetSourcesPage { } /** - * Open the create form by clicking "Add Source". + * Open the create form by clicking "New Source". */ async openCreateForm(): Promise { await this.addSourceButton.click(); diff --git a/e2e/pages/SubsidyProgramsPage.ts b/e2e/pages/SubsidyProgramsPage.ts index 584326ef8..185d856bd 100644 --- a/e2e/pages/SubsidyProgramsPage.ts +++ b/e2e/pages/SubsidyProgramsPage.ts @@ -4,8 +4,9 @@ * The page renders: * - An h1 "Budget" page title * - BudgetSubNav - * - An h2 "Subsidy Programs" section header with an "Add Program" button - * - An inline create form (h2 "New Subsidy Program") toggled by "Add Program" + * - An h2 "Subsidy Programs" section header with a "New Subsidy Program" button + * Note: button text was standardised from "Add Program" to "New Subsidy Program" in issue #1142. + * - An inline create form (h2 "New Subsidy Program") toggled by "New Subsidy Program" * - `#programName` (text, required) * - `#reductionType` (select: "percentage" | "fixed") * - `#reductionValue` (number, required — label changes by type) @@ -90,7 +91,8 @@ export class SubsidyProgramsPage { name: 'Subsidy Programs', exact: true, }); - this.addProgramButton = page.getByRole('button', { name: 'Add Program', exact: true }); + // Button text standardised to "New Subsidy Program" in issue #1142 (was "Add Program") + this.addProgramButton = page.getByRole('button', { name: 'New Subsidy Program', exact: true }); // Create form — identified by its h2 heading "New Subsidy Program" this.createFormHeading = page.getByRole('heading', { diff --git a/e2e/pages/VendorsPage.ts b/e2e/pages/VendorsPage.ts index 177847dfd..99147bd5e 100644 --- a/e2e/pages/VendorsPage.ts +++ b/e2e/pages/VendorsPage.ts @@ -2,7 +2,8 @@ * Page Object Model for the Vendors list page (/budget/vendors) * * The page renders: - * - A page header with an "Add Vendor" button + * - A page header with a "New Vendor" button (data-testid="new-vendor-button") + * Note: button text was standardised from "Add Vendor" to "New Vendor" in issue #1142. * - A search input and sort controls * - A data table (desktop) / card list (mobile) of vendors * - Pagination controls when totalPages > 1 @@ -80,7 +81,8 @@ export class VendorsPage { // Page header this.heading = page.getByRole('heading', { level: 1, name: 'Budget', exact: true }); - this.addVendorButton = page.getByRole('button', { name: 'Add Vendor', exact: true }); + // Button text standardised to "New Vendor" in issue #1142 (was "Add Vendor") + this.addVendorButton = page.getByTestId('new-vendor-button'); // Search / sort this.searchInput = page.getByLabel('Search vendors'); diff --git a/e2e/tests/layout/page-layout-consistency.spec.ts b/e2e/tests/layout/page-layout-consistency.spec.ts new file mode 100644 index 000000000..c24f802c5 --- /dev/null +++ b/e2e/tests/layout/page-layout-consistency.spec.ts @@ -0,0 +1,260 @@ +/** + * E2E tests for consistent list page layout (Issue #1142) + * + * Verifies that all 8 list pages share a consistent header layout after + * the standardization introduced in issue #1142: + * - 1200px max-width content wrapper + * - h1 page title visible + * - "New ..." primary action button (or dropdown) visible with correct text + * + * Pages under test: + * /budget/overview — Budget h1, "New" dropdown trigger + * /budget/sources — Budget h1, "New Source" button + * /budget/subsidies — Budget h1, "New Subsidy Program" button + * /budget/invoices — Budget h1, "New Invoice" button + * /budget/vendors — Budget h1, "New Vendor" button + * /project/work-items — Project h1, "New Work Item" button + * /project/household-items — Project h1, "New Household Item" button + * /project/milestones — Project h1, "New Milestone" button + * + * Scenarios: + * 1. Desktop — all 8 pages have consistent header layout (h1 + primary button visible) + * 2. Budget Overview dropdown text ("New" trigger, "New Invoice"/"New Vendor" items) + * 3. Button text correctness — Budget sub-pages + * 4. Button text correctness — Project sub-pages + * 5. Mobile — header renders with title and button visible on narrow viewport + */ + +import { test, expect } from '../../fixtures/auth.js'; + +// ───────────────────────────────────────────────────────────────────────────── +// Scenario 1: Desktop — all 8 pages load with h1 title and primary action button +// ───────────────────────────────────────────────────────────────────────────── + +test.describe('Page layout consistency — all 8 list pages', () => { + const listPages = [ + // Budget section + { name: 'Budget Overview', route: '/budget/overview', h1: 'Budget' }, + { name: 'Budget Sources', route: '/budget/sources', h1: 'Budget' }, + { name: 'Subsidy Programs', route: '/budget/subsidies', h1: 'Budget' }, + { name: 'Invoices', route: '/budget/invoices', h1: 'Budget' }, + { name: 'Vendors', route: '/budget/vendors', h1: 'Budget' }, + // Project section + { name: 'Work Items', route: '/project/work-items', h1: 'Project' }, + { name: 'Household Items', route: '/project/household-items', h1: 'Project' }, + { name: 'Milestones', route: '/project/milestones', h1: 'Project' }, + ] as const; + + for (const { name, route, h1 } of listPages) { + test(`${name} — h1 title visible`, async ({ authenticatedPage }) => { + await authenticatedPage.goto(route); + const heading = authenticatedPage.getByRole('heading', { + level: 1, + name: h1, + exact: true, + }); + await expect(heading).toBeVisible(); + }); + + test(`${name} — primary action button or dropdown visible`, async ({ authenticatedPage }) => { + await authenticatedPage.goto(route); + + // Budget Overview has a dropdown trigger ("New"); all other pages have + // a named "New ..." button. We look for any primary-action button in the + // page header area — a button whose text starts with "New" covers both cases. + const newButton = authenticatedPage + .locator('button') + .filter({ hasText: /^New/ }) + .first(); + await expect(newButton).toBeVisible(); + }); + } +}); + +// ───────────────────────────────────────────────────────────────────────────── +// Scenario 2: Budget Overview dropdown +// ───────────────────────────────────────────────────────────────────────────── + +test.describe('Budget Overview — "New" dropdown', () => { + test('Trigger button reads "New"', async ({ authenticatedPage }) => { + await authenticatedPage.goto('/budget/overview'); + await authenticatedPage.getByRole('heading', { level: 1, name: 'Budget', exact: true }).waitFor({ state: 'visible' }); + + // The trigger button has data-testid="budget-overview-add-button" and text "New" + const trigger = authenticatedPage.getByTestId('budget-overview-add-button'); + await expect(trigger).toBeVisible(); + await expect(trigger).toHaveText('New'); + }); + + test('Dropdown shows "New Invoice" and "New Vendor" items', async ({ authenticatedPage }) => { + await authenticatedPage.goto('/budget/overview'); + await authenticatedPage.getByRole('heading', { level: 1, name: 'Budget', exact: true }).waitFor({ state: 'visible' }); + + // Open the dropdown + const trigger = authenticatedPage.getByTestId('budget-overview-add-button'); + await trigger.click(); + + // Dropdown menu items should appear + const newInvoiceItem = authenticatedPage.getByTestId('budget-overview-add-invoice'); + const newVendorItem = authenticatedPage.getByTestId('budget-overview-add-vendor'); + + await expect(newInvoiceItem).toBeVisible(); + await expect(newVendorItem).toBeVisible(); + await expect(newInvoiceItem).toHaveText('New Invoice'); + await expect(newVendorItem).toHaveText('New Vendor'); + }); +}); + +// ───────────────────────────────────────────────────────────────────────────── +// Scenario 3: Button text correctness — Budget sub-pages +// ───────────────────────────────────────────────────────────────────────────── + +test.describe('Budget sub-pages — "New ..." button text', () => { + test('BudgetSources (/budget/sources) — button reads "New Source"', async ({ + authenticatedPage, + }) => { + await authenticatedPage.goto('/budget/sources'); + await authenticatedPage.getByRole('heading', { level: 1, name: 'Budget', exact: true }).waitFor({ state: 'visible' }); + + // The "New Source" button opens the inline create form + const button = authenticatedPage.getByRole('button', { name: 'New Source', exact: true }); + await expect(button).toBeVisible(); + }); + + test('SubsidyPrograms (/budget/subsidies) — button reads "New Subsidy Program"', async ({ + authenticatedPage, + }) => { + await authenticatedPage.goto('/budget/subsidies'); + await authenticatedPage.getByRole('heading', { level: 1, name: 'Budget', exact: true }).waitFor({ state: 'visible' }); + + const button = authenticatedPage.getByRole('button', { + name: 'New Subsidy Program', + exact: true, + }); + await expect(button).toBeVisible(); + }); + + test('Invoices (/budget/invoices) — button reads "New Invoice"', async ({ + authenticatedPage, + }) => { + await authenticatedPage.goto('/budget/invoices'); + await authenticatedPage.getByRole('heading', { level: 1, name: 'Budget', exact: true }).waitFor({ state: 'visible' }); + + // data-testid="new-invoice-button" with text "New Invoice" + const button = authenticatedPage.getByTestId('new-invoice-button'); + await expect(button).toBeVisible(); + await expect(button).toHaveText('New Invoice'); + }); + + test('Vendors (/budget/vendors) — button reads "New Vendor"', async ({ + authenticatedPage, + }) => { + await authenticatedPage.goto('/budget/vendors'); + await authenticatedPage.getByRole('heading', { level: 1, name: 'Budget', exact: true }).waitFor({ state: 'visible' }); + + // The vendor page add button renders "New Vendor" (t('vendors.addVendor')) + const button = authenticatedPage.getByRole('button', { name: 'New Vendor', exact: true }); + await expect(button).toBeVisible(); + }); +}); + +// ───────────────────────────────────────────────────────────────────────────── +// Scenario 4: Button text correctness — Project sub-pages +// ───────────────────────────────────────────────────────────────────────────── + +test.describe('Project sub-pages — "New ..." button text', () => { + test('WorkItems (/project/work-items) — button reads "New Work Item"', async ({ + authenticatedPage, + }) => { + await authenticatedPage.goto('/project/work-items'); + await authenticatedPage.getByRole('heading', { level: 1, name: 'Project', exact: true }).waitFor({ state: 'visible' }); + + const button = authenticatedPage.getByRole('button', { name: 'New Work Item', exact: true }); + await expect(button).toBeVisible(); + }); + + test('HouseholdItems (/project/household-items) — button reads "New Household Item"', async ({ + authenticatedPage, + }) => { + await authenticatedPage.goto('/project/household-items'); + await authenticatedPage.getByRole('heading', { level: 1, name: 'Project', exact: true }).waitFor({ state: 'visible' }); + + const button = authenticatedPage.getByRole('button', { + name: 'New Household Item', + exact: true, + }); + await expect(button).toBeVisible(); + }); + + test('Milestones (/project/milestones) — button reads "New Milestone"', async ({ + authenticatedPage, + }) => { + await authenticatedPage.goto('/project/milestones'); + await authenticatedPage.getByRole('heading', { level: 1, name: 'Project', exact: true }).waitFor({ state: 'visible' }); + + // data-testid="new-milestone-button" with text "New Milestone" + const button = authenticatedPage.getByTestId('new-milestone-button'); + await expect(button).toBeVisible(); + await expect(button).toHaveText('New Milestone'); + }); +}); + +// ───────────────────────────────────────────────────────────────────────────── +// Scenario 5: Mobile — header stacks vertically, title and button remain visible +// ───────────────────────────────────────────────────────────────────────────── + +test.describe('Mobile — header stacks vertically', () => { + test('Vendors page — title and button both visible on narrow viewport', async ({ + authenticatedPage, + }) => { + const viewport = authenticatedPage.viewportSize(); + + // This test is only meaningful on narrow (mobile/tablet) viewports. + // On desktop viewports it acts as an extra smoke check. + await authenticatedPage.goto('/budget/vendors'); + + const heading = authenticatedPage.getByRole('heading', { + level: 1, + name: 'Budget', + exact: true, + }); + await expect(heading).toBeVisible(); + + const button = authenticatedPage.getByRole('button', { name: 'New Vendor', exact: true }); + await expect(button).toBeVisible(); + + // On narrow viewports the header should not cause horizontal overflow + if (viewport && viewport.width <= 768) { + const hasHorizontalScroll = await authenticatedPage.evaluate(() => { + return document.documentElement.scrollWidth > window.innerWidth; + }); + expect(hasHorizontalScroll).toBe(false); + } + }); + + test('WorkItems page — title and button both visible on narrow viewport', async ({ + authenticatedPage, + }) => { + const viewport = authenticatedPage.viewportSize(); + + await authenticatedPage.goto('/project/work-items'); + + const heading = authenticatedPage.getByRole('heading', { + level: 1, + name: 'Project', + exact: true, + }); + await expect(heading).toBeVisible(); + + const button = authenticatedPage.getByRole('button', { name: 'New Work Item', exact: true }); + await expect(button).toBeVisible(); + + // On narrow viewports verify no horizontal overflow + if (viewport && viewport.width <= 768) { + const hasHorizontalScroll = await authenticatedPage.evaluate(() => { + return document.documentElement.scrollWidth > window.innerWidth; + }); + expect(hasHorizontalScroll).toBe(false); + } + }); +});