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}
-
void loadSources()}>
+ void loadSources()}>
{t('sources.retry')}
@@ -506,17 +507,9 @@ export function BudgetSourcesPage() {
{/* Page header */}
{t('sources.title')}
-
-
- {/* Budget sub-navigation */}
-
-
- {/* Section header */}
-
-
{t('sources.sectionTitle')}
{
setShowCreateForm(true);
setCreateError('');
@@ -527,6 +520,14 @@ export function BudgetSourcesPage() {
+ {/* 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')}
- navigate('/project/household-items/new')}
- data-testid="new-household-item-button"
- >
- {t('newButton')}
-
-
+
+
+
{t('page.title')}
+ navigate('/project/household-items/new')}
+ data-testid="new-household-item-button"
+ >
+ {t('newButton')}
+
+
-
+
-
+
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.addInvoice')}
-
-
+
+
+
{t('invoices.title')}
+
+ {t('invoices.addInvoice')}
+
+
-
+
-
{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')}
- navigate('/project/milestones/new')}
- data-testid="new-milestone-button"
- >
- {t('milestones.newButton')}
-
-
-
+
+
+
{t('milestones.page.title')}
+ navigate('/project/milestones/new')}
+ data-testid="new-milestone-button"
+ >
+ {t('milestones.newButton')}
+
+
+
-
+
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}
-
void loadData()}>
- Retry
+ void loadData()}>
+ {t('subsidies.retry')}
@@ -397,18 +398,10 @@ export function SubsidyProgramsPage() {
{/* Page header */}
-
Budget
-
-
- {/* Budget sub-navigation */}
-
-
- {/* Section header */}
-
-
{t('subsidies.title')}
+ {t('subsidies.pageTitle')}
{
setShowCreateForm(true);
setCreateError('');
@@ -416,10 +409,18 @@ export function SubsidyProgramsPage() {
}}
disabled={showCreateForm}
>
- Add Program
+ {t('subsidies.addProgram')}
+ {/* 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 */}
- Name *
+ {t('subsidies.form.name')} {t('subsidies.form.required')}
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 }) => (
+ onChange(e.target.value || null)}
+ >
+ No trade
+
+ ),
+}));
+
+// ─── 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.addVendor')}
-
-
+
+
+
{t('vendors.title')}
+
+ {t('vendors.addVendor')}
+
+
-
+
-
{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')}
- navigate('/project/work-items/new')}
- data-testid="new-work-item-button"
- >
- {t('list.newWorkItem')}
-
-
+
+
+
{t('list.pageTitle')}
+ navigate('/project/work-items/new')}
+ data-testid="new-work-item-button"
+ >
+ {t('list.newWorkItem')}
+
+
-
+
-
+
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);
+ }
+ });
+});