From 95e738a6a315fc4e7faaa1d8b82357ff91075657 Mon Sep 17 00:00:00 2001 From: Frank Steiler Date: Thu, 19 Feb 2026 22:06:20 +0100 Subject: [PATCH 01/69] docs: polish README for v1.8.0 stable release (#141) Add version, CI, and Docker badges. Consolidate the features section by grouping work item properties (tags, notes, subtasks, dependencies) under a single Work Items heading and separating list view capabilities. Rename Application Shell and Design System sections to user-friendly Appearance and Infrastructure headings. Replace the redundant Planned Features bullet list with a concise Coming Soon paragraph. Normalize bold item casing to sentence case for consistency. Co-authored-by: Claude frontend-developer (Opus 4.6) --- README.md | 90 ++++++++++++++++++++++--------------------------------- 1 file changed, 35 insertions(+), 55 deletions(-) diff --git a/README.md b/README.md index ba060c38f..1dd4ca7d3 100644 --- a/README.md +++ b/README.md @@ -3,80 +3,60 @@ > [!NOTE] > I'm using this project to test out 'vibe coding' - I use this as a playground to better understand how to use an agentic development workflow. My plan is to write as little code as possible, but rely on a set of agents to build this application. I currently have a time-limited need for this (relatievely) simple application - which is why I'm not necessarily concerned about long-term maintainability. -A self-hosted home building project management tool for homeowners. Track work items, budgets, timelines, and household item purchases from a single Docker container backed by SQLite -- no external database required. +[![GitHub Release](https://img.shields.io/github/v/release/steilerDev/cornerstone?label=release)](https://github.com/steilerDev/cornerstone/releases/latest) +[![CI](https://img.shields.io/github/actions/workflow/status/steilerDev/cornerstone/ci.yml?branch=main&label=CI)](https://github.com/steilerDev/cornerstone/actions/workflows/ci.yml) +[![Docker Image](https://img.shields.io/docker/v/steilerdev/cornerstone?label=Docker&sort=semver)](https://hub.docker.com/r/steilerdev/cornerstone) -## Features - -### Work Items Management - -- **Full CRUD Operations** -- Create, view, edit, and delete work items with titles, descriptions, statuses, dates, durations, and scheduling constraints. -- **Status Tracking** -- Track each item as Not Started, In Progress, Completed, or Blocked. -- **Scheduling** -- Set start and end dates, durations, and "start after" / "start before" constraints for vendor or weather dependencies. -- **User Assignment** -- Assign work items to any registered user on your instance. -- **Filtering and Search** -- Filter by status, assigned user, or tag. Full-text search with debounced input for fast results. -- **Sorting and Pagination** -- Sort by title, status, start date, end date, created date, or updated date. Paginated results for large projects. -- **Responsive Views** -- Table layout on desktop, card layout on mobile and tablet. URL state sync keeps your filters bookmarkable. - -### Tags +A self-hosted home building project management tool for homeowners. Track work items, manage dependencies, organize with tags, and collaborate with your household -- all from a single Docker container backed by SQLite. No external database or cloud service required. -- **Custom Tags** -- Create colored tags to categorize and organize your work items (e.g., "Electrical", "Plumbing", "Exterior"). -- **Tag Management Page** -- Dedicated page for creating, editing, and deleting tags, accessible from the sidebar. -- **Color-Coded Pills** -- Tags appear as colored pills throughout the interface for quick visual identification. - -### Notes +## Features -- **Work Item Notes** -- Add notes to any work item to track progress, record decisions, or leave information for other users. -- **Author Attribution** -- Each note shows who wrote it and when, with timestamps. -- **Edit and Delete** -- Note authors and admins can edit or delete notes. +### Work Items -### Subtasks +Create, view, edit, and delete work items to track every task in your build project. Each work item supports: -- **Checklist Items** -- Break down work items into smaller subtasks with a checklist interface. -- **Toggle Completion** -- Mark subtasks as complete or incomplete with a single click. -- **Reorder** -- Rearrange subtasks using up/down buttons (no drag-and-drop -- designed for accessibility). +- **Status tracking** -- Not Started, In Progress, Completed, or Blocked +- **Scheduling** -- Start and end dates, durations, and "start after" / "start before" constraints for vendor or weather dependencies +- **User assignment** -- Assign items to any registered user on your instance +- **Tags** -- Color-coded tags (e.g., "Electrical", "Plumbing", "Exterior") for organizing and filtering work items. Manage tags from a dedicated page in the sidebar. +- **Notes** -- Add timestamped notes to any work item to track progress or record decisions. Authors and admins can edit or delete notes. +- **Subtasks** -- Break work items into checklist items. Toggle completion with a single click and reorder with up/down buttons. +- **Dependencies** -- Define predecessor/successor relationships between work items using a sentence-builder interface. Supports Finish-to-Start, Start-to-Start, Finish-to-Finish, and Start-to-Finish types. Circular dependencies are automatically detected and prevented. -### Dependencies +### List View -- **Predecessor and Successor Links** -- Define relationships between work items to track what must happen before or after each task. -- **Four Dependency Types** -- Finish-to-Start, Start-to-Start, Finish-to-Finish, and Start-to-Finish relationships. -- **Circular Dependency Detection** -- The system automatically prevents circular dependencies using depth-first cycle detection. +- **Filtering** -- Filter by status, assigned user, or tag. Full-text search with debounced input. +- **Sorting and pagination** -- Sort by title, status, start date, end date, created date, or updated date. Paginated results for large projects. +- **Responsive layout** -- Table layout on desktop, card layout on mobile and tablet. URL state sync keeps your filters bookmarkable. ### Keyboard Shortcuts -- **List Page** -- Press `n` to create a new work item, `/` to focus search, arrow keys to navigate, `?` for help. -- **Detail Page** -- Press `e` to edit, `Delete` to delete, `Escape` to cancel. +- **List page** -- `n` to create a new work item, `/` to focus search, arrow keys to navigate, `?` for help +- **Detail page** -- `e` to edit, `Delete` to delete, `Escape` to cancel ### Authentication and User Management -- **First-Run Setup** -- On first launch, a setup wizard walks you through creating the initial admin account. No command-line setup needed. -- **Local Authentication** -- Email and password login with bcrypt password hashing and secure session cookies. -- **OIDC Single Sign-On** -- Connect to your existing identity provider (Authentik, Keycloak, and other OpenID Connect providers) for seamless login. New users are automatically provisioned on their first OIDC login. -- **User Profiles** -- Users can view and edit their display name and change their password (local accounts). -- **Admin User Management** -- Admins can list, search, edit roles, and deactivate user accounts. -- **Role-Based Access Control** -- Admin and Member roles control access to management features. - -### Application Shell - -- **Responsive Layout** -- Full sidebar navigation on desktop, collapsible menu on mobile and tablet. -- **Health Checks** -- Built-in `/api/health/ready` and `/api/health/live` endpoints for Docker and orchestrator health monitoring. +- **First-run setup** -- On first launch, a setup wizard walks you through creating the initial admin account. No command-line setup needed. +- **Local authentication** -- Email and password login with bcrypt password hashing and secure session cookies. +- **OIDC single sign-on** -- Connect to your existing identity provider (Authentik, Keycloak, and other OpenID Connect providers) for seamless login. New users are automatically provisioned on their first OIDC login. +- **User profiles** -- Users can view and edit their display name and change their password (local accounts). +- **Admin user management** -- Admins can list, search, edit roles, and deactivate user accounts. +- **Role-based access control** -- Admin and Member roles control access to management features. -### Design System +### Appearance -- **Design Token System** -- All visual values (colors, spacing, typography, shadows, radii, transitions) are defined as CSS custom properties in a 3-layer architecture: a raw color palette (Layer 1), purpose-driven semantic aliases (Layer 2), and dark mode overrides (Layer 3). No hardcoded color values exist in component CSS. -- **Dark Mode** -- Choose Light, Dark, or System (follows your OS preference). Your selection is persisted to localStorage and applied immediately with no flash on page load. -- **Brand Identity** -- Custom Cornerstone logo and favicon. +- **Dark mode** -- Choose Light, Dark, or System (follows your OS preference). Your selection is remembered and applied immediately with no flash on page load. +- **Responsive design** -- Full sidebar navigation on desktop, collapsible menu on mobile and tablet. +- **Design token system** -- Consistent visual language built on CSS custom properties with semantic color tokens, ensuring a polished look across light and dark themes. -### Planned Features +### Infrastructure -The following features are on the roadmap but not yet available: +- **Health checks** -- Built-in `/api/health/ready` and `/api/health/live` endpoints for Docker and orchestrator health monitoring. +- **Automated releases** -- CI/CD pipeline with semantic versioning and multi-architecture Docker images (amd64/arm64). -- Gantt chart with dependency visualization and scheduling -- Budget tracking with multiple financing sources -- Household item and furniture tracking -- Paperless-ngx document integration -- Dashboard and reporting +### Coming Soon -See the [Roadmap](#roadmap) section for details. +Cornerstone is under active development. Planned features include Gantt chart visualization, budget tracking, household item management, Paperless-ngx integration, and a project dashboard. See the [Roadmap](#roadmap) for details. ## Quick Start From b76f66a336eca36af59d86b1a1ea474514219433 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Thu, 19 Feb 2026 22:11:51 +0100 Subject: [PATCH 02/69] build(deps): Bump actions/download-artifact from 4 to 7 (#83) Bumps [actions/download-artifact](https://github.com/actions/download-artifact) from 4 to 7. - [Release notes](https://github.com/actions/download-artifact/releases) - [Commits](https://github.com/actions/download-artifact/compare/v4...v7) --- updated-dependencies: - dependency-name: actions/download-artifact dependency-version: '7' dependency-type: direct:production update-type: version-update:semver-major ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/ci.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index db331dadf..3de57ba13 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -84,7 +84,7 @@ jobs: uses: actions/checkout@v6 - name: Download image artifact - uses: actions/download-artifact@v4 + uses: actions/download-artifact@v7 with: name: cornerstone-docker-image From 7066214f84c692a3375cdeb3d2b1c3a23060f2a3 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Thu, 19 Feb 2026 22:12:03 +0100 Subject: [PATCH 03/69] build(deps): Bump actions/upload-artifact from 4 to 6 (#84) Bumps [actions/upload-artifact](https://github.com/actions/upload-artifact) from 4 to 6. - [Release notes](https://github.com/actions/upload-artifact/releases) - [Commits](https://github.com/actions/upload-artifact/compare/v4...v6) --- updated-dependencies: - dependency-name: actions/upload-artifact dependency-version: '6' dependency-type: direct:production update-type: version-update:semver-major ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/ci.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 3de57ba13..6f120f72e 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -66,7 +66,7 @@ jobs: run: docker save cornerstone:e2e -o cornerstone-e2e.tar - name: Upload image artifact - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@v6 with: name: cornerstone-docker-image path: cornerstone-e2e.tar @@ -109,7 +109,7 @@ jobs: - name: Upload test results if: ${{ always() }} - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@v6 with: name: e2e-test-results path: | From 5fcf02bf1b19db1c3927f46baefa6feb6f9f747c Mon Sep 17 00:00:00 2001 From: Frank Steiler Date: Fri, 20 Feb 2026 08:24:59 +0100 Subject: [PATCH 04/69] feat(budget): implement budget categories CRUD endpoints (Story #142) (#150) * feat(budget): implement budget categories CRUD endpoints (Story #142) Implements the foundation for EPIC-05 (Budget Management) with: - SQL migration (0003_create_budget_tables.sql) creating all 8 budget tables: budget_categories, vendors, invoices, budget_sources, subsidy_programs, and junction tables work_item_vendors, work_item_subsidies, subsidy_program_categories. Includes 10 seeded default budget categories (Materials, Labor, Permits, etc.). - Drizzle ORM schema additions for all 8 new tables with correct types (real for monetary fields), indexes, and FK relationships. - Shared types in @cornerstone/shared: BudgetCategory entity, CreateBudgetCategoryRequest, UpdateBudgetCategoryRequest, BudgetCategoryListResponse, BudgetCategoryResponse. - CATEGORY_IN_USE error code added to shared ErrorCode union and CategoryInUseError class added to AppError. - budgetCategoryService with getAll, getById, create, update, and delete methods. Create/update enforce case-insensitive name uniqueness. Delete checks for subsidy program references (409 if in-use) with details payload. - budgetCategories route handler implementing all 5 endpoints: GET/POST /api/budget-categories and GET/PATCH/DELETE /api/budget-categories/:id with JSON schema validation. - Route registered in app.ts at prefix /api/budget-categories. Fixes #142 Co-Authored-By: Claude backend-developer (Sonnet 4.5) * feat(budget): implement budget categories management UI (Story #142) - Add budgetCategoriesApi.ts with typed client functions (fetch, create, update, delete) - Add BudgetCategoriesPage with inline create/edit forms, color swatch, sort order, delete confirmation modal with 409 in-use error handling, loading/error/empty states - Update App.tsx: replace BudgetPage placeholder with nested /budget routes; /budget redirects to /budget/categories; BudgetCategoriesPage at /budget/categories - Update Sidebar: rename "Budget" link to "Budget Categories", update href to /budget/categories (active state matches sub-paths automatically) - Update Sidebar.test.tsx and App.test.tsx to reflect navigation change (trivial test fixes required due to route/label change) Fixes #142 Co-Authored-By: Claude frontend-developer (Sonnet 4.5) * test(budget): add unit, integration, and E2E tests for budget categories - 62 service unit tests for budgetCategoryService (CRUD + validation) - 39 route integration tests for /api/budget-categories - 21 schema tests for all 8 new budget tables - 18 API client tests for budgetCategoriesApi - 41 component tests for BudgetCategoriesPage - 38 Playwright E2E tests with BudgetCategoriesPage POM Fixes #142 Co-Authored-By: Claude qa-integration-tester (Opus 4.6) Co-Authored-By: Claude e2e-test-engineer (Opus 4.6) --------- Co-authored-by: Claude frontend-developer (Opus 4.6) --- client/src/App.test.tsx | 28 +- client/src/App.tsx | 11 +- .../src/components/Sidebar/Sidebar.test.tsx | 19 +- client/src/components/Sidebar/Sidebar.tsx | 4 +- client/src/lib/budgetCategoriesApi.test.ts | 400 +++++++ client/src/lib/budgetCategoriesApi.ts | 39 + .../BudgetCategoriesPage.module.css | 651 ++++++++++ .../BudgetCategoriesPage.test.tsx | 896 ++++++++++++++ .../BudgetCategoriesPage.tsx | 619 ++++++++++ e2e/fixtures/testData.ts | 2 + e2e/pages/BudgetCategoriesPage.ts | 403 +++++++ e2e/tests/budget/budget-categories.spec.ts | 1050 +++++++++++++++++ server/src/app.ts | 4 + .../migrations/0003_create_budget_tables.sql | 134 +++ server/src/db/schema.test.ts | 597 ++++++++++ server/src/db/schema.ts | 169 +++ server/src/errors/AppError.ts | 10 + server/src/routes/budgetCategories.test.ts | 797 +++++++++++++ server/src/routes/budgetCategories.ts | 145 +++ .../services/budgetCategoryService.test.ts | 806 +++++++++++++ server/src/services/budgetCategoryService.ts | 256 ++++ shared/src/index.ts | 9 + shared/src/types/budgetCategory.ts | 51 + shared/src/types/errors.ts | 3 +- 24 files changed, 7085 insertions(+), 18 deletions(-) create mode 100644 client/src/lib/budgetCategoriesApi.test.ts create mode 100644 client/src/lib/budgetCategoriesApi.ts create mode 100644 client/src/pages/BudgetCategoriesPage/BudgetCategoriesPage.module.css create mode 100644 client/src/pages/BudgetCategoriesPage/BudgetCategoriesPage.test.tsx create mode 100644 client/src/pages/BudgetCategoriesPage/BudgetCategoriesPage.tsx create mode 100644 e2e/pages/BudgetCategoriesPage.ts create mode 100644 e2e/tests/budget/budget-categories.spec.ts create mode 100644 server/src/db/migrations/0003_create_budget_tables.sql create mode 100644 server/src/routes/budgetCategories.test.ts create mode 100644 server/src/routes/budgetCategories.ts create mode 100644 server/src/services/budgetCategoryService.test.ts create mode 100644 server/src/services/budgetCategoryService.ts create mode 100644 shared/src/types/budgetCategory.ts diff --git a/client/src/App.test.tsx b/client/src/App.test.tsx index 72af9217b..577be4012 100644 --- a/client/src/App.test.tsx +++ b/client/src/App.test.tsx @@ -4,12 +4,18 @@ import { jest, describe, it, expect, beforeEach } from '@jest/globals'; import { render, screen, waitFor } from '@testing-library/react'; import type * as AuthApiTypes from './lib/authApi.js'; +import type * as BudgetCategoriesApiTypes from './lib/budgetCategoriesApi.js'; import type * as AppTypes from './App.js'; const mockGetAuthMe = jest.fn(); const mockLogin = jest.fn(); const mockLogout = jest.fn(); +const mockFetchBudgetCategories = jest.fn(); +const mockCreateBudgetCategory = jest.fn(); +const mockUpdateBudgetCategory = jest.fn(); +const mockDeleteBudgetCategory = jest.fn(); + // Must mock BEFORE importing the component jest.unstable_mockModule('./lib/authApi.js', () => ({ getAuthMe: mockGetAuthMe, @@ -17,6 +23,13 @@ jest.unstable_mockModule('./lib/authApi.js', () => ({ logout: mockLogout, })); +jest.unstable_mockModule('./lib/budgetCategoriesApi.js', () => ({ + fetchBudgetCategories: mockFetchBudgetCategories, + createBudgetCategory: mockCreateBudgetCategory, + updateBudgetCategory: mockUpdateBudgetCategory, + deleteBudgetCategory: mockDeleteBudgetCategory, +})); + describe('App', () => { // Dynamic imports let App: typeof AppTypes.App; @@ -32,6 +45,13 @@ describe('App', () => { mockGetAuthMe.mockReset(); mockLogin.mockReset(); mockLogout.mockReset(); + mockFetchBudgetCategories.mockReset(); + mockCreateBudgetCategory.mockReset(); + mockUpdateBudgetCategory.mockReset(); + mockDeleteBudgetCategory.mockReset(); + + // Default: budget categories returns empty list + mockFetchBudgetCategories.mockResolvedValue({ categories: [] }); // Default: authenticated user (no setup required) mockGetAuthMe.mockResolvedValue({ @@ -93,12 +113,12 @@ describe('App', () => { expect(heading).toBeInTheDocument(); }); - it('navigates to Budget page when /budget path is accessed', async () => { - window.history.pushState({}, 'Budget', '/budget'); + it('navigates to Budget Categories page when /budget/categories path is accessed', async () => { + window.history.pushState({}, 'Budget Categories', '/budget/categories'); render(); - // Wait for lazy-loaded Budget component to resolve - const heading = await screen.findByRole('heading', { name: /budget/i }); + // Wait for lazy-loaded BudgetCategories component to resolve + const heading = await screen.findByRole('heading', { name: /budget categories/i, level: 1 }); expect(heading).toBeInTheDocument(); }); diff --git a/client/src/App.tsx b/client/src/App.tsx index 8c8c8ad53..7e99eeb61 100644 --- a/client/src/App.tsx +++ b/client/src/App.tsx @@ -1,5 +1,5 @@ import { lazy, Suspense } from 'react'; -import { BrowserRouter, Routes, Route } from 'react-router-dom'; +import { BrowserRouter, Routes, Route, Navigate } from 'react-router-dom'; import { AppShell } from './components/AppShell/AppShell'; import { AuthProvider } from './contexts/AuthContext'; import { ThemeProvider } from './contexts/ThemeContext'; @@ -11,7 +11,9 @@ const DashboardPage = lazy(() => import('./pages/DashboardPage/DashboardPage')); const WorkItemsPage = lazy(() => import('./pages/WorkItemsPage/WorkItemsPage')); const WorkItemCreatePage = lazy(() => import('./pages/WorkItemCreatePage/WorkItemCreatePage')); const WorkItemDetailPage = lazy(() => import('./pages/WorkItemDetailPage/WorkItemDetailPage')); -const BudgetPage = lazy(() => import('./pages/BudgetPage/BudgetPage')); +const BudgetCategoriesPage = lazy( + () => import('./pages/BudgetCategoriesPage/BudgetCategoriesPage'), +); const TimelinePage = lazy(() => import('./pages/TimelinePage/TimelinePage')); const HouseholdItemsPage = lazy(() => import('./pages/HouseholdItemsPage/HouseholdItemsPage')); const DocumentsPage = lazy(() => import('./pages/DocumentsPage/DocumentsPage')); @@ -51,7 +53,10 @@ export function App() { } /> } /> } /> - } /> + + } /> + } /> + } /> } /> } /> diff --git a/client/src/components/Sidebar/Sidebar.test.tsx b/client/src/components/Sidebar/Sidebar.test.tsx index aa165523e..8b6af2df6 100644 --- a/client/src/components/Sidebar/Sidebar.test.tsx +++ b/client/src/components/Sidebar/Sidebar.test.tsx @@ -87,7 +87,10 @@ describe('Sidebar', () => { 'href', '/work-items', ); - expect(screen.getByRole('link', { name: /budget/i })).toHaveAttribute('href', '/budget'); + expect(screen.getByRole('link', { name: /budget categories/i })).toHaveAttribute( + 'href', + '/budget/categories', + ); expect(screen.getByRole('link', { name: /timeline/i })).toHaveAttribute('href', '/timeline'); expect(screen.getByRole('link', { name: /household items/i })).toHaveAttribute( 'href', @@ -127,12 +130,12 @@ describe('Sidebar', () => { expect(screen.getByRole('link', { name: /dashboard/i })).not.toHaveClass('active'); }); - it('budget link is active at /budget', () => { + it('budget categories link is active at /budget/categories', () => { renderWithRouter(, { - initialEntries: ['/budget'], + initialEntries: ['/budget/categories'], }); - const budgetLink = screen.getByRole('link', { name: /budget/i }); + const budgetLink = screen.getByRole('link', { name: /budget categories/i }); expect(budgetLink).toHaveClass('active'); }); @@ -165,14 +168,14 @@ describe('Sidebar', () => { it('only one link is active at a time', () => { renderWithRouter(, { - initialEntries: ['/budget'], + initialEntries: ['/budget/categories'], }); const activeLinks = screen .getAllByRole('link') .filter((link) => link.classList.contains('active')); expect(activeLinks).toHaveLength(1); - expect(activeLinks[0]).toHaveTextContent(/budget/i); + expect(activeLinks[0]).toHaveTextContent(/budget categories/i); }); it('renders a close button with correct aria-label', () => { @@ -226,11 +229,11 @@ describe('Sidebar', () => { expect(mockOnClose).toHaveBeenCalledTimes(1); }); - it('clicking a nav link calls onClose (budget)', async () => { + it('clicking a nav link calls onClose (budget categories)', async () => { const user = userEvent.setup(); renderWithRouter(); - const budgetLink = screen.getByRole('link', { name: /budget/i }); + const budgetLink = screen.getByRole('link', { name: /budget categories/i }); await user.click(budgetLink); expect(mockOnClose).toHaveBeenCalledTimes(1); diff --git a/client/src/components/Sidebar/Sidebar.tsx b/client/src/components/Sidebar/Sidebar.tsx index 46679f2c6..2e8aed737 100644 --- a/client/src/components/Sidebar/Sidebar.tsx +++ b/client/src/components/Sidebar/Sidebar.tsx @@ -46,11 +46,11 @@ export function Sidebar({ isOpen, onClose }: SidebarProps) { Work Items `${styles.navLink} ${isActive ? styles.active : ''}`} onClick={onClose} > - Budget + Budget Categories { + let mockFetch: jest.MockedFunction; + + beforeEach(() => { + mockFetch = jest.fn(); + globalThis.fetch = mockFetch; + }); + + afterEach(() => { + jest.restoreAllMocks(); + }); + + // ─── fetchBudgetCategories ───────────────────────────────────────────────── + + describe('fetchBudgetCategories', () => { + it('sends GET request to /api/budget-categories', async () => { + const mockResponse: BudgetCategoryListResponse = { + categories: [], + }; + + mockFetch.mockResolvedValueOnce({ + ok: true, + json: async () => mockResponse, + } as Response); + + await fetchBudgetCategories(); + + expect(mockFetch).toHaveBeenCalledWith('/api/budget-categories', expect.any(Object)); + }); + + it('returns parsed response with empty categories array', async () => { + const mockResponse: BudgetCategoryListResponse = { + categories: [], + }; + + mockFetch.mockResolvedValueOnce({ + ok: true, + json: async () => mockResponse, + } as Response); + + const result = await fetchBudgetCategories(); + + expect(result).toEqual(mockResponse); + expect(result.categories).toEqual([]); + }); + + it('returns parsed response with categories list', async () => { + const mockResponse: BudgetCategoryListResponse = { + categories: [ + { + id: 'cat-1', + name: 'Materials', + description: 'Building materials', + color: '#FF5733', + sortOrder: 1, + createdAt: '2026-01-01T00:00:00.000Z', + updatedAt: '2026-01-01T00:00:00.000Z', + }, + { + id: 'cat-2', + name: 'Labor', + description: null, + color: '#3B82F6', + sortOrder: 2, + createdAt: '2026-01-02T00:00:00.000Z', + updatedAt: '2026-01-02T00:00:00.000Z', + }, + ], + }; + + mockFetch.mockResolvedValueOnce({ + ok: true, + json: async () => mockResponse, + } as Response); + + const result = await fetchBudgetCategories(); + + expect(result.categories).toHaveLength(2); + expect(result.categories[0].name).toBe('Materials'); + expect(result.categories[1].name).toBe('Labor'); + }); + + it('throws ApiClientError when server returns error', async () => { + mockFetch.mockResolvedValueOnce({ + ok: false, + status: 401, + json: async () => ({ error: { code: 'UNAUTHORIZED', message: 'Unauthorized' } }), + } as Response); + + await expect(fetchBudgetCategories()).rejects.toThrow(); + }); + }); + + // ─── createBudgetCategory ────────────────────────────────────────────────── + + describe('createBudgetCategory', () => { + it('sends POST request to /api/budget-categories with body', async () => { + const mockResponse: BudgetCategory = { + id: 'cat-new', + name: 'Materials', + description: null, + color: null, + sortOrder: 0, + createdAt: '2026-01-01T00:00:00.000Z', + updatedAt: '2026-01-01T00:00:00.000Z', + }; + + mockFetch.mockResolvedValueOnce({ + ok: true, + status: 201, + json: async () => mockResponse, + } as Response); + + const requestData = { name: 'Materials' }; + await createBudgetCategory(requestData); + + expect(mockFetch).toHaveBeenCalledWith( + '/api/budget-categories', + expect.objectContaining({ + method: 'POST', + body: JSON.stringify(requestData), + }), + ); + }); + + it('returns the created budget category', async () => { + const mockResponse: BudgetCategory = { + id: 'cat-new', + name: 'Labor', + description: 'Construction labor', + color: '#3B82F6', + sortOrder: 5, + createdAt: '2026-01-01T00:00:00.000Z', + updatedAt: '2026-01-01T00:00:00.000Z', + }; + + mockFetch.mockResolvedValueOnce({ + ok: true, + status: 201, + json: async () => mockResponse, + } as Response); + + const result = await createBudgetCategory({ + name: 'Labor', + description: 'Construction labor', + color: '#3B82F6', + sortOrder: 5, + }); + + expect(result).toEqual(mockResponse); + expect(result.id).toBe('cat-new'); + expect(result.name).toBe('Labor'); + }); + + it('sends all optional fields when provided', async () => { + const mockResponse: BudgetCategory = { + id: 'cat-full', + name: 'Permits', + description: 'Permit costs', + color: '#10B981', + sortOrder: 3, + createdAt: '2026-01-01T00:00:00.000Z', + updatedAt: '2026-01-01T00:00:00.000Z', + }; + + mockFetch.mockResolvedValueOnce({ + ok: true, + status: 201, + json: async () => mockResponse, + } as Response); + + const requestData = { + name: 'Permits', + description: 'Permit costs', + color: '#10B981', + sortOrder: 3, + }; + + await createBudgetCategory(requestData); + + expect(mockFetch).toHaveBeenCalledWith( + '/api/budget-categories', + expect.objectContaining({ + method: 'POST', + body: JSON.stringify(requestData), + }), + ); + }); + + it('throws ApiClientError for 409 CONFLICT (duplicate name)', async () => { + mockFetch.mockResolvedValueOnce({ + ok: false, + status: 409, + json: async () => ({ + error: { + code: 'CONFLICT', + message: 'A budget category with this name already exists', + }, + }), + } as Response); + + await expect(createBudgetCategory({ name: 'Materials' })).rejects.toThrow(); + }); + + it('throws ApiClientError for 400 VALIDATION_ERROR', async () => { + mockFetch.mockResolvedValueOnce({ + ok: false, + status: 400, + json: async () => ({ + error: { code: 'VALIDATION_ERROR', message: 'name is required' }, + }), + } as Response); + + await expect(createBudgetCategory({ name: '' })).rejects.toThrow(); + }); + }); + + // ─── updateBudgetCategory ────────────────────────────────────────────────── + + describe('updateBudgetCategory', () => { + it('sends PATCH request to /api/budget-categories/:id with body', async () => { + const mockResponse: BudgetCategory = { + id: 'cat-1', + name: 'Updated Materials', + description: null, + color: '#FF0000', + sortOrder: 0, + createdAt: '2026-01-01T00:00:00.000Z', + updatedAt: '2026-01-02T00:00:00.000Z', + }; + + mockFetch.mockResolvedValueOnce({ + ok: true, + json: async () => mockResponse, + } as Response); + + const updateData = { name: 'Updated Materials' }; + await updateBudgetCategory('cat-1', updateData); + + expect(mockFetch).toHaveBeenCalledWith( + '/api/budget-categories/cat-1', + expect.objectContaining({ + method: 'PATCH', + body: JSON.stringify(updateData), + }), + ); + }); + + it('returns the updated budget category', async () => { + const mockResponse: BudgetCategory = { + id: 'cat-1', + name: 'New Name', + description: 'New description', + color: '#00FF00', + sortOrder: 10, + createdAt: '2026-01-01T00:00:00.000Z', + updatedAt: '2026-01-02T00:00:00.000Z', + }; + + mockFetch.mockResolvedValueOnce({ + ok: true, + json: async () => mockResponse, + } as Response); + + const result = await updateBudgetCategory('cat-1', { + name: 'New Name', + description: 'New description', + color: '#00FF00', + sortOrder: 10, + }); + + expect(result).toEqual(mockResponse); + expect(result.name).toBe('New Name'); + }); + + it('handles partial update (only color)', async () => { + const mockResponse: BudgetCategory = { + id: 'cat-1', + name: 'Materials', + description: null, + color: '#AABBCC', + sortOrder: 0, + createdAt: '2026-01-01T00:00:00.000Z', + updatedAt: '2026-01-02T00:00:00.000Z', + }; + + mockFetch.mockResolvedValueOnce({ + ok: true, + json: async () => mockResponse, + } as Response); + + const updateData = { color: '#AABBCC' }; + await updateBudgetCategory('cat-1', updateData); + + expect(mockFetch).toHaveBeenCalledWith( + '/api/budget-categories/cat-1', + expect.objectContaining({ + method: 'PATCH', + body: JSON.stringify(updateData), + }), + ); + }); + + it('throws ApiClientError for 404 NOT_FOUND', async () => { + mockFetch.mockResolvedValueOnce({ + ok: false, + status: 404, + json: async () => ({ + error: { code: 'NOT_FOUND', message: 'Budget category not found' }, + }), + } as Response); + + await expect(updateBudgetCategory('nonexistent', { name: 'Updated' })).rejects.toThrow(); + }); + + it('throws ApiClientError for 409 CONFLICT on name update', async () => { + mockFetch.mockResolvedValueOnce({ + ok: false, + status: 409, + json: async () => ({ + error: { + code: 'CONFLICT', + message: 'A budget category with this name already exists', + }, + }), + } as Response); + + await expect(updateBudgetCategory('cat-1', { name: 'Existing Name' })).rejects.toThrow(); + }); + }); + + // ─── deleteBudgetCategory ────────────────────────────────────────────────── + + describe('deleteBudgetCategory', () => { + it('sends DELETE request to /api/budget-categories/:id', async () => { + mockFetch.mockResolvedValueOnce({ + ok: true, + status: 204, + // 204 returns undefined via the apiClient's special-case handling + } as Response); + + await deleteBudgetCategory('cat-1'); + + expect(mockFetch).toHaveBeenCalledWith( + '/api/budget-categories/cat-1', + expect.objectContaining({ + method: 'DELETE', + }), + ); + }); + + it('returns void on successful deletion', async () => { + mockFetch.mockResolvedValueOnce({ + ok: true, + status: 204, + } as Response); + + const result = await deleteBudgetCategory('cat-1'); + + expect(result).toBeUndefined(); + }); + + it('throws ApiClientError for 404 NOT_FOUND', async () => { + mockFetch.mockResolvedValueOnce({ + ok: false, + status: 404, + json: async () => ({ + error: { code: 'NOT_FOUND', message: 'Budget category not found' }, + }), + } as Response); + + await expect(deleteBudgetCategory('nonexistent')).rejects.toThrow(); + }); + + it('throws ApiClientError for 409 CATEGORY_IN_USE', async () => { + mockFetch.mockResolvedValueOnce({ + ok: false, + status: 409, + json: async () => ({ + error: { + code: 'CATEGORY_IN_USE', + message: 'Budget category is in use and cannot be deleted', + details: { subsidyProgramCount: 1, workItemCount: 0 }, + }, + }), + } as Response); + + await expect(deleteBudgetCategory('cat-in-use')).rejects.toThrow(); + }); + }); +}); diff --git a/client/src/lib/budgetCategoriesApi.ts b/client/src/lib/budgetCategoriesApi.ts new file mode 100644 index 000000000..2eec0c087 --- /dev/null +++ b/client/src/lib/budgetCategoriesApi.ts @@ -0,0 +1,39 @@ +import { get, post, patch, del } from './apiClient.js'; +import type { + BudgetCategory, + BudgetCategoryListResponse, + CreateBudgetCategoryRequest, + UpdateBudgetCategoryRequest, +} from '@cornerstone/shared'; + +/** + * Fetches all budget categories, sorted by sort order. + */ +export function fetchBudgetCategories(): Promise { + return get('/budget-categories'); +} + +/** + * Creates a new budget category. + */ +export function createBudgetCategory(data: CreateBudgetCategoryRequest): Promise { + return post('/budget-categories', data); +} + +/** + * Updates an existing budget category. + */ +export function updateBudgetCategory( + id: string, + data: UpdateBudgetCategoryRequest, +): Promise { + return patch(`/budget-categories/${id}`, data); +} + +/** + * Deletes a budget category. + * @throws {ApiClientError} with statusCode 409 if the category is in use. + */ +export function deleteBudgetCategory(id: string): Promise { + return del(`/budget-categories/${id}`); +} diff --git a/client/src/pages/BudgetCategoriesPage/BudgetCategoriesPage.module.css b/client/src/pages/BudgetCategoriesPage/BudgetCategoriesPage.module.css new file mode 100644 index 000000000..fc02a0e27 --- /dev/null +++ b/client/src/pages/BudgetCategoriesPage/BudgetCategoriesPage.module.css @@ -0,0 +1,651 @@ +.container { + padding: var(--spacing-8); + max-width: 1200px; + margin: 0 auto; +} + +.content { + display: flex; + flex-direction: column; + gap: var(--spacing-6); +} + +/* ---- Page header ---- */ + +.pageHeader { + display: flex; + align-items: center; + justify-content: space-between; + gap: var(--spacing-4); +} + +.pageTitle { + font-size: var(--font-size-4xl); + font-weight: var(--font-weight-bold); + color: var(--color-text-primary); + margin: 0; +} + +/* ---- Banners ---- */ + +.successBanner { + background-color: var(--color-success-bg); + border: 1px solid var(--color-success-border); + border-radius: var(--radius-md); + color: var(--color-success-text-on-light); + padding: var(--spacing-3); + font-size: var(--font-size-sm); +} + +.errorBanner { + background-color: var(--color-danger-bg); + border: 1px solid var(--color-danger-border); + border-radius: var(--radius-md); + color: var(--color-danger-active); + padding: var(--spacing-3); + font-size: var(--font-size-sm); + margin-bottom: var(--spacing-4); +} + +/* ---- Loading / full-page error ---- */ + +.loading { + display: flex; + align-items: center; + justify-content: center; + min-height: 400px; + font-size: var(--font-size-base); + color: var(--color-text-muted); +} + +.errorCard { + background: var(--color-bg-primary); + border-radius: var(--radius-lg); + box-shadow: var(--shadow-sm); + padding: var(--spacing-8); + text-align: center; +} + +.errorTitle { + font-size: var(--font-size-2xl); + font-weight: var(--font-weight-bold); + color: var(--color-danger); + margin: 0 0 var(--spacing-4) 0; +} + +/* ---- Card ---- */ + +.card { + background: var(--color-bg-primary); + border-radius: var(--radius-lg); + box-shadow: var(--shadow-sm); + padding: var(--spacing-6); +} + +.cardTitle { + font-size: var(--font-size-xl); + font-weight: var(--font-weight-semibold); + color: var(--color-text-primary); + margin: 0 0 var(--spacing-2) 0; +} + +.cardDescription { + font-size: var(--font-size-sm); + color: var(--color-text-muted); + margin: 0 0 var(--spacing-6) 0; +} + +/* ---- Form ---- */ + +.form { + display: flex; + flex-direction: column; + gap: var(--spacing-4); +} + +.formRow { + display: flex; + gap: var(--spacing-4); + align-items: flex-end; +} + +.editFormRow { + display: flex; + gap: var(--spacing-4); + align-items: flex-end; +} + +.field { + display: flex; + flex-direction: column; + gap: var(--spacing-1); +} + +/* Name field stretches to fill available space */ +.fieldGrow { + flex: 1; + display: flex; + flex-direction: column; + gap: var(--spacing-1); +} + +/* Color field is fixed width */ +.fieldFixed { + flex-shrink: 0; + display: flex; + flex-direction: column; + gap: var(--spacing-1); +} + +/* Sort order field is narrower */ +.fieldNarrow { + width: 7rem; + flex-shrink: 0; + display: flex; + flex-direction: column; + gap: var(--spacing-1); +} + +.label { + font-size: var(--font-size-sm); + font-weight: var(--font-weight-medium); + color: var(--color-text-primary); +} + +.required { + color: var(--color-danger); + margin-left: var(--spacing-0-5); +} + +.input { + padding: var(--spacing-2) var(--spacing-3); + border: 1px solid var(--color-border-strong); + border-radius: var(--radius-md); + font-size: var(--font-size-sm); + color: var(--color-text-primary); + background-color: var(--color-bg-primary); + transition: var(--transition-input); + width: 100%; + box-sizing: border-box; +} + +.input:focus-visible { + outline: none; + border-color: var(--color-primary); + box-shadow: var(--shadow-focus-subtle); +} + +.input:disabled { + background-color: var(--color-bg-secondary); + color: var(--color-text-disabled); + cursor: not-allowed; +} + +/* ---- Color picker ---- */ + +.colorWrapper { + display: flex; + align-items: center; + gap: var(--spacing-2); +} + +.colorInput { + width: 3rem; + height: 2.25rem; + padding: var(--spacing-1); + border: 1px solid var(--color-border-strong); + border-radius: var(--radius-md); + background-color: var(--color-bg-primary); + cursor: pointer; + flex-shrink: 0; +} + +.colorInput:disabled { + cursor: not-allowed; + opacity: 0.6; +} + +/* 12 px color swatch shown next to the picker for a preview */ +.colorSwatch { + display: inline-block; + width: 1.25rem; + height: 1.25rem; + border-radius: var(--radius-circle); + border: 1px solid var(--color-border-strong); + flex-shrink: 0; +} + +/* ---- Form actions row ---- */ + +.formActions { + display: flex; + gap: var(--spacing-3); + align-items: center; +} + +/* ---- Buttons ---- */ + +.button { + padding: var(--spacing-2-5) var(--spacing-4); + background-color: var(--color-primary); + color: var(--color-primary-text); + font-size: var(--font-size-sm); + font-weight: var(--font-weight-medium); + border: none; + border-radius: var(--radius-md); + cursor: pointer; + transition: var(--transition-button); + white-space: nowrap; +} + +.button:hover:not(:disabled) { + background-color: var(--color-primary-hover); +} + +.button:focus-visible { + outline: none; + box-shadow: var(--shadow-focus); +} + +.button:disabled { + background-color: var(--color-text-placeholder); + cursor: not-allowed; +} + +.saveButton { + padding: var(--spacing-1-5) var(--spacing-3); + background-color: var(--color-primary); + color: var(--color-primary-text); + font-size: var(--font-size-sm); + font-weight: var(--font-weight-medium); + border: none; + border-radius: var(--radius-md); + cursor: pointer; + transition: var(--transition-button); +} + +.saveButton:hover:not(:disabled) { + background-color: var(--color-primary-hover); +} + +.saveButton:focus-visible { + outline: none; + box-shadow: var(--shadow-focus); +} + +.saveButton:disabled { + background-color: var(--color-text-placeholder); + cursor: not-allowed; +} + +.cancelButton { + padding: var(--spacing-1-5) var(--spacing-3); + background-color: var(--color-bg-tertiary); + color: var(--color-text-secondary); + font-size: var(--font-size-sm); + font-weight: var(--font-weight-medium); + border: 1px solid var(--color-border-strong); + border-radius: var(--radius-md); + cursor: pointer; + transition: var(--transition-button-border); +} + +.cancelButton:hover:not(:disabled) { + background-color: var(--color-bg-tertiary); + border-color: var(--color-border-strong); +} + +.cancelButton:focus-visible { + outline: none; + box-shadow: var(--shadow-focus); +} + +.cancelButton:disabled { + opacity: 0.5; + cursor: not-allowed; +} + +.editButton { + padding: var(--spacing-1-5) var(--spacing-3); + background-color: var(--color-bg-tertiary); + color: var(--color-text-secondary); + font-size: var(--font-size-sm); + font-weight: var(--font-weight-medium); + border: 1px solid var(--color-border-strong); + border-radius: var(--radius-md); + cursor: pointer; + transition: var(--transition-button-border); +} + +.editButton:hover:not(:disabled) { + background-color: var(--color-bg-tertiary); +} + +.editButton:focus-visible { + outline: none; + box-shadow: var(--shadow-focus); +} + +.editButton:disabled { + opacity: 0.5; + cursor: not-allowed; +} + +.deleteButton { + padding: var(--spacing-1-5) var(--spacing-3); + background-color: var(--color-danger-bg); + color: var(--color-danger); + font-size: var(--font-size-sm); + font-weight: var(--font-weight-medium); + border: 1px solid var(--color-danger-border); + border-radius: var(--radius-md); + cursor: pointer; + transition: background-color var(--transition-normal); +} + +.deleteButton:hover:not(:disabled) { + background-color: var(--color-danger-bg-strong); +} + +.deleteButton:focus-visible { + outline: none; + box-shadow: var(--shadow-focus-danger); +} + +.deleteButton:disabled { + opacity: 0.5; + cursor: not-allowed; +} + +/* ---- Categories list ---- */ + +.categoriesList { + display: flex; + flex-direction: column; + gap: var(--spacing-3); +} + +.categoryRow { + display: flex; + justify-content: space-between; + align-items: center; + padding: var(--spacing-3); + border: 1px solid var(--color-border); + border-radius: var(--radius-md); + transition: background-color var(--transition-normal); +} + +.categoryRow:hover { + background-color: var(--color-bg-secondary); +} + +.categoryInfo { + display: flex; + align-items: center; + gap: var(--spacing-3); + flex: 1; + min-width: 0; +} + +/* 12px color circle swatch in the list */ +.categorySwatch { + display: inline-block; + width: 0.75rem; + height: 0.75rem; + border-radius: var(--radius-circle); + border: 1px solid var(--color-border-strong); + flex-shrink: 0; +} + +.categoryDetails { + display: flex; + flex-direction: column; + gap: var(--spacing-0-5); + flex: 1; + min-width: 0; +} + +.categoryName { + font-size: var(--font-size-sm); + font-weight: var(--font-weight-medium); + color: var(--color-text-primary); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.categoryDescription { + font-size: var(--font-size-xs); + color: var(--color-text-muted); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.categorySortOrder { + font-size: var(--font-size-xs); + color: var(--color-text-placeholder); + flex-shrink: 0; + font-variant-numeric: tabular-nums; +} + +.categoryActions { + display: flex; + gap: var(--spacing-2); + flex-shrink: 0; + margin-left: var(--spacing-4); +} + +/* ---- Edit form inside list row ---- */ + +.editForm { + flex: 1; + display: flex; + flex-direction: column; + gap: var(--spacing-3); +} + +.editActions { + display: flex; + gap: var(--spacing-2); +} + +/* ---- Empty state ---- */ + +.emptyState { + padding: var(--spacing-8); + text-align: center; + font-size: var(--font-size-sm); + color: var(--color-text-muted); +} + +/* ---- Modal ---- */ + +.modal { + position: fixed; + top: 0; + left: 0; + right: 0; + bottom: 0; + z-index: var(--z-modal); + display: flex; + align-items: center; + justify-content: center; +} + +.modalBackdrop { + position: absolute; + top: 0; + left: 0; + right: 0; + bottom: 0; + background-color: var(--color-overlay); +} + +.modalContent { + position: relative; + background: var(--color-bg-primary); + border-radius: var(--radius-lg); + box-shadow: var(--shadow-2xl); + padding: var(--spacing-6); + max-width: 28rem; + width: calc(100% - var(--spacing-8)); + margin: var(--spacing-4); +} + +.modalTitle { + font-size: var(--font-size-xl); + font-weight: var(--font-weight-semibold); + color: var(--color-text-primary); + margin: 0 0 var(--spacing-4) 0; +} + +.modalText { + font-size: var(--font-size-sm); + color: var(--color-text-secondary); + margin: 0 0 var(--spacing-3) 0; +} + +.modalWarning { + font-size: var(--font-size-sm); + color: var(--color-danger); + margin: 0 0 var(--spacing-6) 0; +} + +.modalActions { + display: flex; + gap: var(--spacing-3); + justify-content: flex-end; +} + +.confirmDeleteButton { + padding: var(--spacing-2-5) var(--spacing-4); + background-color: var(--color-danger); + color: var(--color-danger-text); + font-size: var(--font-size-sm); + font-weight: var(--font-weight-medium); + border: none; + border-radius: var(--radius-md); + cursor: pointer; + transition: background-color var(--transition-normal); +} + +.confirmDeleteButton:hover:not(:disabled) { + background-color: var(--color-danger-hover); +} + +.confirmDeleteButton:focus-visible { + outline: none; + box-shadow: var(--shadow-focus-danger); +} + +.confirmDeleteButton:disabled { + background-color: var(--color-text-placeholder); + cursor: not-allowed; +} + +/* ============================================================ + * RESPONSIVE — Mobile (max 767px) + * ============================================================ */ + +@media (max-width: 767px) { + .container { + padding: var(--spacing-4); + } + + .pageTitle { + font-size: var(--font-size-2xl); + } + + .card { + padding: var(--spacing-4); + } + + /* Stack page header vertically */ + .pageHeader { + flex-direction: column; + align-items: stretch; + } + + .button { + width: 100%; + text-align: center; + } + + /* Stack form fields vertically on mobile */ + .formRow, + .editFormRow { + flex-direction: column; + align-items: stretch; + } + + .fieldNarrow { + width: 100%; + } + + .formActions { + flex-direction: column; + } + + .formActions .button, + .formActions .cancelButton { + width: 100%; + text-align: center; + } + + /* Category row becomes a column on mobile */ + .categoryRow { + flex-direction: column; + align-items: stretch; + gap: var(--spacing-3); + } + + .categoryActions { + margin-left: 0; + justify-content: stretch; + } + + .editButton, + .deleteButton { + flex: 1; + } + + .editActions { + flex-direction: column; + } + + .saveButton, + .editActions .cancelButton { + width: 100%; + } + + /* Modal actions */ + .modalActions { + flex-direction: column-reverse; + } + + .confirmDeleteButton, + .modalActions .cancelButton { + width: 100%; + } +} + +/* ============================================================ + * RESPONSIVE — Tablet (768px – 1024px) + * ============================================================ */ + +@media (min-width: 768px) and (max-width: 1024px) { + .container { + padding: var(--spacing-6); + } + + /* Touch-friendly minimum heights */ + .button, + .saveButton, + .cancelButton, + .editButton, + .deleteButton, + .confirmDeleteButton { + min-height: 44px; + } +} diff --git a/client/src/pages/BudgetCategoriesPage/BudgetCategoriesPage.test.tsx b/client/src/pages/BudgetCategoriesPage/BudgetCategoriesPage.test.tsx new file mode 100644 index 000000000..ee99c7008 --- /dev/null +++ b/client/src/pages/BudgetCategoriesPage/BudgetCategoriesPage.test.tsx @@ -0,0 +1,896 @@ +/** + * @jest-environment jsdom + */ +import { jest, describe, it, expect, beforeEach } from '@jest/globals'; +import { screen, waitFor, render, fireEvent } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { MemoryRouter } from 'react-router-dom'; +import type * as BudgetCategoriesApiTypes from '../../lib/budgetCategoriesApi.js'; +import { ApiClientError } from '../../lib/apiClient.js'; +import type { BudgetCategory, BudgetCategoryListResponse } from '@cornerstone/shared'; + +// Mock the API module BEFORE importing the component +const mockFetchBudgetCategories = jest.fn(); +const mockCreateBudgetCategory = jest.fn(); +const mockUpdateBudgetCategory = jest.fn(); +const mockDeleteBudgetCategory = jest.fn(); + +jest.unstable_mockModule('../../lib/budgetCategoriesApi.js', () => ({ + fetchBudgetCategories: mockFetchBudgetCategories, + createBudgetCategory: mockCreateBudgetCategory, + updateBudgetCategory: mockUpdateBudgetCategory, + deleteBudgetCategory: mockDeleteBudgetCategory, +})); + +describe('BudgetCategoriesPage', () => { + let BudgetCategoriesPage: React.ComponentType; + + // Sample data + const sampleCategory1: BudgetCategory = { + id: 'cat-1', + name: 'Materials', + description: 'Building materials', + color: '#FF5733', + sortOrder: 1, + createdAt: '2026-01-01T00:00:00.000Z', + updatedAt: '2026-01-01T00:00:00.000Z', + }; + + const sampleCategory2: BudgetCategory = { + id: 'cat-2', + name: 'Labor', + description: null, + color: '#3B82F6', + sortOrder: 2, + createdAt: '2026-01-02T00:00:00.000Z', + updatedAt: '2026-01-02T00:00:00.000Z', + }; + + const emptyResponse: BudgetCategoryListResponse = { + categories: [], + }; + + const listResponse: BudgetCategoryListResponse = { + categories: [sampleCategory1, sampleCategory2], + }; + + beforeEach(async () => { + if (!BudgetCategoriesPage) { + const module = await import('./BudgetCategoriesPage.js'); + BudgetCategoriesPage = module.default; + } + + // Reset all mocks + mockFetchBudgetCategories.mockReset(); + mockCreateBudgetCategory.mockReset(); + mockUpdateBudgetCategory.mockReset(); + mockDeleteBudgetCategory.mockReset(); + }); + + function renderPage() { + return render( + + + , + ); + } + + // ─── Loading state ────────────────────────────────────────────────────────── + + describe('loading state', () => { + it('shows loading indicator while fetching categories', () => { + // Never resolves — stays in loading state + mockFetchBudgetCategories.mockReturnValueOnce(new Promise(() => {})); + + renderPage(); + + expect(screen.getByText(/loading budget categories/i)).toBeInTheDocument(); + }); + + it('hides loading indicator after data loads', async () => { + mockFetchBudgetCategories.mockResolvedValueOnce(emptyResponse); + + renderPage(); + + await waitFor(() => { + expect(screen.queryByText(/loading budget categories/i)).not.toBeInTheDocument(); + }); + }); + }); + + // ─── Page structure ───────────────────────────────────────────────────────── + + describe('page structure', () => { + it('renders the page heading "Budget Categories"', async () => { + mockFetchBudgetCategories.mockResolvedValueOnce(emptyResponse); + + renderPage(); + + await waitFor(() => { + expect( + screen.getByRole('heading', { name: /budget categories/i, level: 1 }), + ).toBeInTheDocument(); + }); + }); + + it('renders "Add Category" button', async () => { + mockFetchBudgetCategories.mockResolvedValueOnce(emptyResponse); + + renderPage(); + + await waitFor(() => { + expect(screen.getByRole('button', { name: /add category/i })).toBeInTheDocument(); + }); + }); + }); + + // ─── Empty state ──────────────────────────────────────────────────────────── + + describe('empty state', () => { + it('shows empty state message when no categories exist', async () => { + mockFetchBudgetCategories.mockResolvedValueOnce(emptyResponse); + + renderPage(); + + await waitFor(() => { + expect(screen.getByText(/no budget categories yet/i)).toBeInTheDocument(); + }); + }); + + it('shows count of 0 in section heading for empty state', async () => { + mockFetchBudgetCategories.mockResolvedValueOnce(emptyResponse); + + renderPage(); + + await waitFor(() => { + expect(screen.getByRole('heading', { name: /categories \(0\)/i })).toBeInTheDocument(); + }); + }); + }); + + // ─── Categories list display ───────────────────────────────────────────────── + + describe('categories list display', () => { + it('displays category names in the list', async () => { + mockFetchBudgetCategories.mockResolvedValueOnce(listResponse); + + renderPage(); + + await waitFor(() => { + expect(screen.getByText('Materials')).toBeInTheDocument(); + expect(screen.getByText('Labor')).toBeInTheDocument(); + }); + }); + + it('displays category description when present', async () => { + mockFetchBudgetCategories.mockResolvedValueOnce(listResponse); + + renderPage(); + + await waitFor(() => { + expect(screen.getByText('Building materials')).toBeInTheDocument(); + }); + }); + + it('shows correct count in section heading', async () => { + mockFetchBudgetCategories.mockResolvedValueOnce(listResponse); + + renderPage(); + + await waitFor(() => { + expect(screen.getByRole('heading', { name: /categories \(2\)/i })).toBeInTheDocument(); + }); + }); + + it('renders Edit button for each category', async () => { + mockFetchBudgetCategories.mockResolvedValueOnce(listResponse); + + renderPage(); + + await waitFor(() => { + expect(screen.getByRole('button', { name: /edit materials/i })).toBeInTheDocument(); + expect(screen.getByRole('button', { name: /edit labor/i })).toBeInTheDocument(); + }); + }); + + it('renders Delete button for each category', async () => { + mockFetchBudgetCategories.mockResolvedValueOnce(listResponse); + + renderPage(); + + await waitFor(() => { + expect(screen.getByRole('button', { name: /delete materials/i })).toBeInTheDocument(); + expect(screen.getByRole('button', { name: /delete labor/i })).toBeInTheDocument(); + }); + }); + }); + + // ─── Error state ──────────────────────────────────────────────────────────── + + describe('error state', () => { + it('shows error state when API call fails and no categories loaded', async () => { + mockFetchBudgetCategories.mockRejectedValueOnce( + new ApiClientError(500, { code: 'INTERNAL_ERROR', message: 'Server error' }), + ); + + renderPage(); + + await waitFor(() => { + expect(screen.getByRole('alert')).toBeInTheDocument(); + expect(screen.getByText('Server error')).toBeInTheDocument(); + }); + }); + + it('shows generic error message for non-ApiClientError failures', async () => { + mockFetchBudgetCategories.mockRejectedValueOnce(new Error('Network error')); + + renderPage(); + + await waitFor(() => { + expect(screen.getByText(/failed to load budget categories/i)).toBeInTheDocument(); + }); + }); + + it('shows a Retry button on load error', async () => { + mockFetchBudgetCategories.mockRejectedValueOnce(new Error('Network error')); + + renderPage(); + + await waitFor(() => { + expect(screen.getByRole('button', { name: /retry/i })).toBeInTheDocument(); + }); + }); + + it('retries loading when Retry button is clicked', async () => { + mockFetchBudgetCategories + .mockRejectedValueOnce(new Error('First failure')) + .mockResolvedValueOnce(listResponse); + + const user = userEvent.setup(); + renderPage(); + + await waitFor(() => { + expect(screen.getByRole('button', { name: /retry/i })).toBeInTheDocument(); + }); + + await user.click(screen.getByRole('button', { name: /retry/i })); + + await waitFor(() => { + expect(screen.getByText('Materials')).toBeInTheDocument(); + }); + }); + }); + + // ─── Create form ──────────────────────────────────────────────────────────── + + describe('create form', () => { + it('shows create form when "Add Category" is clicked', async () => { + mockFetchBudgetCategories.mockResolvedValueOnce(emptyResponse); + + const user = userEvent.setup(); + renderPage(); + + await waitFor(() => { + expect(screen.getByRole('button', { name: /add category/i })).toBeInTheDocument(); + }); + + await user.click(screen.getByRole('button', { name: /add category/i })); + + expect(screen.getByRole('heading', { name: /new budget category/i })).toBeInTheDocument(); + expect(screen.getByLabelText(/name/i)).toBeInTheDocument(); + }); + + it('"Add Category" button is disabled while form is shown', async () => { + mockFetchBudgetCategories.mockResolvedValueOnce(emptyResponse); + + const user = userEvent.setup(); + renderPage(); + + await waitFor(() => { + expect(screen.getByRole('button', { name: /add category/i })).toBeInTheDocument(); + }); + + await user.click(screen.getByRole('button', { name: /add category/i })); + + expect(screen.getByRole('button', { name: /add category/i })).toBeDisabled(); + }); + + it('hides create form when Cancel is clicked', async () => { + mockFetchBudgetCategories.mockResolvedValueOnce(emptyResponse); + + const user = userEvent.setup(); + renderPage(); + + await waitFor(() => { + expect(screen.getByRole('button', { name: /add category/i })).toBeInTheDocument(); + }); + + await user.click(screen.getByRole('button', { name: /add category/i })); + await user.click(screen.getByRole('button', { name: /cancel/i })); + + expect( + screen.queryByRole('heading', { name: /new budget category/i }), + ).not.toBeInTheDocument(); + }); + + it('"Create Category" submit button is disabled when name is empty (prevents empty submission)', async () => { + // The component disables the Create Category button when name is empty, + // preventing form submission rather than showing a validation error on click. + mockFetchBudgetCategories.mockResolvedValueOnce(emptyResponse); + + const user = userEvent.setup(); + renderPage(); + + await waitFor(() => { + expect(screen.getByRole('button', { name: /add category/i })).toBeInTheDocument(); + }); + + await user.click(screen.getByRole('button', { name: /add category/i })); + + // Name is empty — Create Category button is disabled + const createButton = screen.getByRole('button', { name: /create category/i }); + expect(createButton).toBeDisabled(); + }); + + it('shows validation error when submitting with whitespace-only name', async () => { + // The form can be submitted if the user types spaces (button becomes enabled), + // but the component catches it and sets createError. + mockFetchBudgetCategories.mockResolvedValueOnce(emptyResponse); + + const user = userEvent.setup(); + renderPage(); + + await waitFor(() => { + expect(screen.getByRole('button', { name: /add category/i })).toBeInTheDocument(); + }); + + await user.click(screen.getByRole('button', { name: /add category/i })); + + // Type spaces to make the button enabled (non-empty string, but trimmed is empty) + const nameInput = screen.getByLabelText(/^name/i); + await user.type(nameInput, ' '); + + // fireEvent.submit triggers submit directly, bypassing the disabled check + const form = nameInput.closest('form')!; + fireEvent.submit(form); + + await waitFor(() => { + expect(screen.getByText(/category name is required/i)).toBeInTheDocument(); + }); + }); + + it('successfully creates a category and shows success message', async () => { + mockFetchBudgetCategories.mockResolvedValueOnce(emptyResponse); + + const newCategory: BudgetCategory = { + id: 'cat-new', + name: 'Permits', + description: null, + color: '#3b82f6', + sortOrder: 0, + createdAt: '2026-01-01T00:00:00.000Z', + updatedAt: '2026-01-01T00:00:00.000Z', + }; + mockCreateBudgetCategory.mockResolvedValueOnce(newCategory); + + const user = userEvent.setup(); + renderPage(); + + await waitFor(() => { + expect(screen.getByRole('button', { name: /add category/i })).toBeInTheDocument(); + }); + + await user.click(screen.getByRole('button', { name: /add category/i })); + + const nameInput = screen.getByLabelText(/^name/i); + await user.type(nameInput, 'Permits'); + + await user.click(screen.getByRole('button', { name: /create category/i })); + + await waitFor(() => { + expect(mockCreateBudgetCategory).toHaveBeenCalledTimes(1); + expect(mockCreateBudgetCategory).toHaveBeenCalledWith( + expect.objectContaining({ name: 'Permits' }), + ); + }); + + await waitFor(() => { + expect(screen.getByRole('alert')).toBeInTheDocument(); + expect(screen.getByText(/category "permits" created successfully/i)).toBeInTheDocument(); + }); + }); + + it('hides create form after successful creation', async () => { + mockFetchBudgetCategories.mockResolvedValueOnce(emptyResponse); + + const newCategory: BudgetCategory = { + id: 'cat-new', + name: 'Permits', + description: null, + color: '#3b82f6', + sortOrder: 0, + createdAt: '2026-01-01T00:00:00.000Z', + updatedAt: '2026-01-01T00:00:00.000Z', + }; + mockCreateBudgetCategory.mockResolvedValueOnce(newCategory); + + const user = userEvent.setup(); + renderPage(); + + await waitFor(() => { + expect(screen.getByRole('button', { name: /add category/i })).toBeInTheDocument(); + }); + + await user.click(screen.getByRole('button', { name: /add category/i })); + + const nameInput = screen.getByLabelText(/^name/i); + await user.type(nameInput, 'Permits'); + await user.click(screen.getByRole('button', { name: /create category/i })); + + await waitFor(() => { + expect( + screen.queryByRole('heading', { name: /new budget category/i }), + ).not.toBeInTheDocument(); + }); + }); + + it('shows create API error message on failure (409 conflict)', async () => { + mockFetchBudgetCategories.mockResolvedValueOnce(emptyResponse); + mockCreateBudgetCategory.mockRejectedValueOnce( + new ApiClientError(409, { + code: 'CONFLICT', + message: 'A budget category with this name already exists', + }), + ); + + const user = userEvent.setup(); + renderPage(); + + await waitFor(() => { + expect(screen.getByRole('button', { name: /add category/i })).toBeInTheDocument(); + }); + + await user.click(screen.getByRole('button', { name: /add category/i })); + + const nameInput = screen.getByLabelText(/^name/i); + await user.type(nameInput, 'Materials'); + + await user.click(screen.getByRole('button', { name: /create category/i })); + + await waitFor(() => { + expect( + screen.getByText(/a budget category with this name already exists/i), + ).toBeInTheDocument(); + }); + }); + + it('"Create Category" button is disabled when name is empty', async () => { + mockFetchBudgetCategories.mockResolvedValueOnce(emptyResponse); + + const user = userEvent.setup(); + renderPage(); + + await waitFor(() => { + expect(screen.getByRole('button', { name: /add category/i })).toBeInTheDocument(); + }); + + await user.click(screen.getByRole('button', { name: /add category/i })); + + // Name is empty by default — button should be disabled + const createButton = screen.getByRole('button', { name: /create category/i }); + expect(createButton).toBeDisabled(); + }); + }); + + // ─── Edit form ────────────────────────────────────────────────────────────── + + describe('edit form (inline)', () => { + it('shows inline edit form when Edit button is clicked', async () => { + mockFetchBudgetCategories.mockResolvedValueOnce(listResponse); + + const user = userEvent.setup(); + renderPage(); + + await waitFor(() => { + expect(screen.getByRole('button', { name: /edit materials/i })).toBeInTheDocument(); + }); + + await user.click(screen.getByRole('button', { name: /edit materials/i })); + + expect(screen.getByRole('form', { name: /edit materials/i })).toBeInTheDocument(); + }); + + it('pre-fills edit form with current category values', async () => { + mockFetchBudgetCategories.mockResolvedValueOnce(listResponse); + + const user = userEvent.setup(); + renderPage(); + + await waitFor(() => { + expect(screen.getByRole('button', { name: /edit materials/i })).toBeInTheDocument(); + }); + + await user.click(screen.getByRole('button', { name: /edit materials/i })); + + // Name input should be pre-filled + const nameInput = screen.getByDisplayValue('Materials'); + expect(nameInput).toBeInTheDocument(); + }); + + it('hides edit form when Cancel is clicked', async () => { + mockFetchBudgetCategories.mockResolvedValueOnce(listResponse); + + const user = userEvent.setup(); + renderPage(); + + await waitFor(() => { + expect(screen.getByRole('button', { name: /edit materials/i })).toBeInTheDocument(); + }); + + await user.click(screen.getByRole('button', { name: /edit materials/i })); + await user.click(screen.getByRole('button', { name: /cancel/i })); + + expect(screen.queryByRole('form', { name: /edit materials/i })).not.toBeInTheDocument(); + }); + + it('successfully saves an update and shows success message', async () => { + mockFetchBudgetCategories.mockResolvedValueOnce(listResponse); + + const updatedCategory: BudgetCategory = { + ...sampleCategory1, + name: 'Updated Materials', + updatedAt: '2026-01-03T00:00:00.000Z', + }; + mockUpdateBudgetCategory.mockResolvedValueOnce(updatedCategory); + + const user = userEvent.setup(); + renderPage(); + + await waitFor(() => { + expect(screen.getByRole('button', { name: /edit materials/i })).toBeInTheDocument(); + }); + + await user.click(screen.getByRole('button', { name: /edit materials/i })); + + // Clear and retype the name + const nameInput = screen.getByDisplayValue('Materials'); + await user.clear(nameInput); + await user.type(nameInput, 'Updated Materials'); + + await user.click(screen.getByRole('button', { name: /^save$/i })); + + await waitFor(() => { + expect(mockUpdateBudgetCategory).toHaveBeenCalledWith( + 'cat-1', + expect.objectContaining({ name: 'Updated Materials' }), + ); + }); + + await waitFor(() => { + expect(screen.getByRole('alert')).toBeInTheDocument(); + expect( + screen.getByText(/category "updated materials" updated successfully/i), + ).toBeInTheDocument(); + }); + }); + + it('shows update error when save fails (409 conflict)', async () => { + mockFetchBudgetCategories.mockResolvedValueOnce(listResponse); + mockUpdateBudgetCategory.mockRejectedValueOnce( + new ApiClientError(409, { + code: 'CONFLICT', + message: 'A budget category with this name already exists', + }), + ); + + const user = userEvent.setup(); + renderPage(); + + await waitFor(() => { + expect(screen.getByRole('button', { name: /edit materials/i })).toBeInTheDocument(); + }); + + await user.click(screen.getByRole('button', { name: /edit materials/i })); + + const nameInput = screen.getByDisplayValue('Materials'); + await user.clear(nameInput); + await user.type(nameInput, 'Labor'); // Conflicts with cat-2 + + await user.click(screen.getByRole('button', { name: /^save$/i })); + + await waitFor(() => { + expect( + screen.getByText(/a budget category with this name already exists/i), + ).toBeInTheDocument(); + }); + }); + + it('shows validation error when saving with empty name', async () => { + mockFetchBudgetCategories.mockResolvedValueOnce(listResponse); + + const user = userEvent.setup(); + renderPage(); + + await waitFor(() => { + expect(screen.getByRole('button', { name: /edit materials/i })).toBeInTheDocument(); + }); + + await user.click(screen.getByRole('button', { name: /edit materials/i })); + + const nameInput = screen.getByDisplayValue('Materials'); + await user.clear(nameInput); + + // Submit with empty name + const saveButton = screen.getByRole('button', { name: /^save$/i }); + // The save button is disabled when name is empty + expect(saveButton).toBeDisabled(); + }); + + it('disables other edit/delete buttons while one category is being edited', async () => { + mockFetchBudgetCategories.mockResolvedValueOnce(listResponse); + + const user = userEvent.setup(); + renderPage(); + + await waitFor(() => { + expect(screen.getByRole('button', { name: /edit materials/i })).toBeInTheDocument(); + }); + + await user.click(screen.getByRole('button', { name: /edit materials/i })); + + // Edit button for Labor should be disabled + const editLaborButton = screen.getByRole('button', { name: /edit labor/i }); + expect(editLaborButton).toBeDisabled(); + }); + }); + + // ─── Delete confirmation modal ────────────────────────────────────────────── + + describe('delete confirmation modal', () => { + it('shows delete confirmation modal when Delete button is clicked', async () => { + mockFetchBudgetCategories.mockResolvedValueOnce(listResponse); + + const user = userEvent.setup(); + renderPage(); + + await waitFor(() => { + expect(screen.getByRole('button', { name: /delete materials/i })).toBeInTheDocument(); + }); + + await user.click(screen.getByRole('button', { name: /delete materials/i })); + + expect(screen.getByRole('dialog')).toBeInTheDocument(); + expect(screen.getByRole('heading', { name: /delete category/i })).toBeInTheDocument(); + }); + + it('shows the category name in the confirmation modal body text', async () => { + mockFetchBudgetCategories.mockResolvedValueOnce(listResponse); + + const user = userEvent.setup(); + renderPage(); + + await waitFor(() => { + expect(screen.getByRole('button', { name: /delete materials/i })).toBeInTheDocument(); + }); + + await user.click(screen.getByRole('button', { name: /delete materials/i })); + + // The category name appears in the modal dialog + const dialog = screen.getByRole('dialog'); + expect(dialog).toHaveTextContent('Materials'); + }); + + it('closes the modal when Cancel is clicked', async () => { + mockFetchBudgetCategories.mockResolvedValueOnce(listResponse); + + const user = userEvent.setup(); + renderPage(); + + await waitFor(() => { + expect(screen.getByRole('button', { name: /delete materials/i })).toBeInTheDocument(); + }); + + await user.click(screen.getByRole('button', { name: /delete materials/i })); + await user.click(screen.getByRole('button', { name: /cancel/i })); + + await waitFor(() => { + expect(screen.queryByRole('dialog')).not.toBeInTheDocument(); + }); + }); + + it('successfully deletes a category and shows success message', async () => { + mockFetchBudgetCategories.mockResolvedValueOnce(listResponse); + mockDeleteBudgetCategory.mockResolvedValueOnce(undefined); + + const user = userEvent.setup(); + renderPage(); + + await waitFor(() => { + expect(screen.getByRole('button', { name: /delete materials/i })).toBeInTheDocument(); + }); + + await user.click(screen.getByRole('button', { name: /delete materials/i })); + await user.click(screen.getByRole('button', { name: /delete category/i })); + + await waitFor(() => { + expect(mockDeleteBudgetCategory).toHaveBeenCalledWith('cat-1'); + }); + + await waitFor(() => { + expect(screen.getByRole('alert')).toBeInTheDocument(); + expect(screen.getByText(/category "materials" deleted successfully/i)).toBeInTheDocument(); + }); + }); + + it('removes the deleted category from the list', async () => { + mockFetchBudgetCategories.mockResolvedValueOnce(listResponse); + mockDeleteBudgetCategory.mockResolvedValueOnce(undefined); + + const user = userEvent.setup(); + renderPage(); + + await waitFor(() => { + expect(screen.getByRole('button', { name: /delete materials/i })).toBeInTheDocument(); + }); + + await user.click(screen.getByRole('button', { name: /delete materials/i })); + await user.click(screen.getByRole('button', { name: /delete category/i })); + + await waitFor(() => { + expect(screen.queryByText('Materials')).not.toBeInTheDocument(); + }); + + // Labor should still be there + expect(screen.getByText('Labor')).toBeInTheDocument(); + }); + + it('shows CATEGORY_IN_USE error when deletion fails with 409', async () => { + mockFetchBudgetCategories.mockResolvedValueOnce(listResponse); + mockDeleteBudgetCategory.mockRejectedValueOnce( + new ApiClientError(409, { + code: 'CATEGORY_IN_USE', + message: 'Budget category is in use', + }), + ); + + const user = userEvent.setup(); + renderPage(); + + await waitFor(() => { + expect(screen.getByRole('button', { name: /delete materials/i })).toBeInTheDocument(); + }); + + await user.click(screen.getByRole('button', { name: /delete materials/i })); + await user.click(screen.getByRole('button', { name: /delete category/i })); + + await waitFor(() => { + expect( + screen.getByText(/this category cannot be deleted because it is currently in use/i), + ).toBeInTheDocument(); + }); + }); + + it('hides "Delete Category" confirm button when category-in-use error is shown', async () => { + mockFetchBudgetCategories.mockResolvedValueOnce(listResponse); + mockDeleteBudgetCategory.mockRejectedValueOnce( + new ApiClientError(409, { + code: 'CATEGORY_IN_USE', + message: 'Budget category is in use', + }), + ); + + const user = userEvent.setup(); + renderPage(); + + await waitFor(() => { + expect(screen.getByRole('button', { name: /delete materials/i })).toBeInTheDocument(); + }); + + await user.click(screen.getByRole('button', { name: /delete materials/i })); + await user.click(screen.getByRole('button', { name: /delete category/i })); + + await waitFor(() => { + expect( + screen.getByText(/this category cannot be deleted because it is currently in use/i), + ).toBeInTheDocument(); + }); + + // The confirm delete button should no longer be visible + expect(screen.queryByRole('button', { name: /delete category/i })).not.toBeInTheDocument(); + }); + + it('shows generic error for non-409 delete failures', async () => { + mockFetchBudgetCategories.mockResolvedValueOnce(listResponse); + mockDeleteBudgetCategory.mockRejectedValueOnce(new Error('Network error')); + + const user = userEvent.setup(); + renderPage(); + + await waitFor(() => { + expect(screen.getByRole('button', { name: /delete materials/i })).toBeInTheDocument(); + }); + + await user.click(screen.getByRole('button', { name: /delete materials/i })); + await user.click(screen.getByRole('button', { name: /delete category/i })); + + await waitFor(() => { + expect(screen.getByText(/failed to delete category/i)).toBeInTheDocument(); + }); + }); + }); + + // ─── Success message behavior ─────────────────────────────────────────────── + + describe('success message behavior', () => { + it('shows success alert after creating a category', async () => { + mockFetchBudgetCategories.mockResolvedValueOnce(emptyResponse); + mockCreateBudgetCategory.mockResolvedValueOnce({ + id: 'cat-new', + name: 'Permits', + description: null, + color: '#3b82f6', + sortOrder: 0, + createdAt: '2026-01-01T00:00:00.000Z', + updatedAt: '2026-01-01T00:00:00.000Z', + }); + + const user = userEvent.setup(); + renderPage(); + + await waitFor(() => { + expect(screen.getByRole('button', { name: /add category/i })).toBeInTheDocument(); + }); + + await user.click(screen.getByRole('button', { name: /add category/i })); + await user.type(screen.getByLabelText(/^name/i), 'Permits'); + await user.click(screen.getByRole('button', { name: /create category/i })); + + await waitFor(() => { + const alerts = screen.getAllByRole('alert'); + const successAlert = alerts.find((el) => el.textContent?.includes('created successfully')); + expect(successAlert).toBeInTheDocument(); + }); + }); + + it('success message persists when opening the create form again', async () => { + // The component does NOT clear the success message when opening the create form. + // The success message stays visible alongside the form. + mockFetchBudgetCategories.mockResolvedValueOnce(emptyResponse); + mockCreateBudgetCategory.mockResolvedValueOnce({ + id: 'cat-new', + name: 'Custom HVAC', + description: null, + color: '#3b82f6', + sortOrder: 0, + createdAt: '2026-01-01T00:00:00.000Z', + updatedAt: '2026-01-01T00:00:00.000Z', + }); + + const user = userEvent.setup(); + renderPage(); + + await waitFor(() => { + expect(screen.getByRole('button', { name: /add category/i })).toBeInTheDocument(); + }); + + // Create a category to get a success message + await user.click(screen.getByRole('button', { name: /add category/i })); + await user.type(screen.getByLabelText(/^name/i), 'Custom HVAC'); + await user.click(screen.getByRole('button', { name: /create category/i })); + + await waitFor(() => { + const alerts = screen.getAllByRole('alert'); + const successAlert = alerts.find((el) => el.textContent?.includes('created successfully')); + expect(successAlert).toBeInTheDocument(); + }); + + // Re-open create form — success message remains visible (not cleared) + await user.click(screen.getByRole('button', { name: /add category/i })); + + // Success message should still be there (component doesn't clear it on form open) + expect( + screen.queryByText(/category "custom hvac" created successfully/i), + ).toBeInTheDocument(); + }); + }); +}); diff --git a/client/src/pages/BudgetCategoriesPage/BudgetCategoriesPage.tsx b/client/src/pages/BudgetCategoriesPage/BudgetCategoriesPage.tsx new file mode 100644 index 000000000..52d30deea --- /dev/null +++ b/client/src/pages/BudgetCategoriesPage/BudgetCategoriesPage.tsx @@ -0,0 +1,619 @@ +import { useState, useEffect, type FormEvent } from 'react'; +import type { BudgetCategory } from '@cornerstone/shared'; +import { + fetchBudgetCategories, + createBudgetCategory, + updateBudgetCategory, + deleteBudgetCategory, +} from '../../lib/budgetCategoriesApi.js'; +import { ApiClientError } from '../../lib/apiClient.js'; +import styles from './BudgetCategoriesPage.module.css'; + +const DEFAULT_COLOR = '#3b82f6'; + +type EditingCategory = { + id: string; + name: string; + description: string; + color: string; + sortOrder: number; +}; + +export function BudgetCategoriesPage() { + const [categories, setCategories] = useState([]); + const [isLoading, setIsLoading] = useState(true); + const [error, setError] = useState(''); + const [successMessage, setSuccessMessage] = useState(''); + + // Create form state + const [showCreateForm, setShowCreateForm] = useState(false); + const [newName, setNewName] = useState(''); + const [newDescription, setNewDescription] = useState(''); + const [newColor, setNewColor] = useState(DEFAULT_COLOR); + const [newSortOrder, setNewSortOrder] = useState(''); + const [isCreating, setIsCreating] = useState(false); + const [createError, setCreateError] = useState(''); + + // Edit state + const [editingCategory, setEditingCategory] = useState(null); + const [isUpdating, setIsUpdating] = useState(false); + const [updateError, setUpdateError] = useState(''); + + // Delete confirmation state + const [deletingCategoryId, setDeletingCategoryId] = useState(null); + const [isDeleting, setIsDeleting] = useState(false); + const [deleteError, setDeleteError] = useState(''); + + useEffect(() => { + void loadCategories(); + }, []); + + const loadCategories = async () => { + setIsLoading(true); + setError(''); + + try { + const response = await fetchBudgetCategories(); + setCategories(response.categories); + } catch (err) { + if (err instanceof ApiClientError) { + setError(err.error.message); + } else { + setError('Failed to load budget categories. Please try again.'); + } + } finally { + setIsLoading(false); + } + }; + + const handleCreateCategory = async (event: FormEvent) => { + event.preventDefault(); + setCreateError(''); + setSuccessMessage(''); + + const trimmedName = newName.trim(); + if (!trimmedName) { + setCreateError('Category name is required'); + return; + } + + if (trimmedName.length > 100) { + setCreateError('Category name must be 100 characters or less'); + return; + } + + const sortOrderValue = newSortOrder.trim() !== '' ? parseInt(newSortOrder, 10) : undefined; + + setIsCreating(true); + + try { + const created = await createBudgetCategory({ + name: trimmedName, + description: newDescription.trim() || null, + color: newColor, + sortOrder: sortOrderValue, + }); + setCategories( + [...categories, created].sort( + (a, b) => a.sortOrder - b.sortOrder || a.name.localeCompare(b.name), + ), + ); + setNewName(''); + setNewDescription(''); + setNewColor(DEFAULT_COLOR); + setNewSortOrder(''); + setShowCreateForm(false); + setSuccessMessage(`Category "${created.name}" created successfully`); + } catch (err) { + if (err instanceof ApiClientError) { + setCreateError(err.error.message); + } else { + setCreateError('Failed to create category. Please try again.'); + } + } finally { + setIsCreating(false); + } + }; + + const startEdit = (category: BudgetCategory) => { + setEditingCategory({ + id: category.id, + name: category.name, + description: category.description ?? '', + color: category.color ?? DEFAULT_COLOR, + sortOrder: category.sortOrder, + }); + setUpdateError(''); + setSuccessMessage(''); + }; + + const cancelEdit = () => { + setEditingCategory(null); + setUpdateError(''); + }; + + const handleUpdateCategory = async (event: FormEvent) => { + event.preventDefault(); + if (!editingCategory) return; + + setUpdateError(''); + setSuccessMessage(''); + + const trimmedName = editingCategory.name.trim(); + if (!trimmedName) { + setUpdateError('Category name is required'); + return; + } + + if (trimmedName.length > 100) { + setUpdateError('Category name must be 100 characters or less'); + return; + } + + setIsUpdating(true); + + try { + const updated = await updateBudgetCategory(editingCategory.id, { + name: trimmedName, + description: editingCategory.description.trim() || null, + color: editingCategory.color, + sortOrder: editingCategory.sortOrder, + }); + setCategories( + categories + .map((cat) => (cat.id === updated.id ? updated : cat)) + .sort((a, b) => a.sortOrder - b.sortOrder || a.name.localeCompare(b.name)), + ); + setEditingCategory(null); + setSuccessMessage(`Category "${updated.name}" updated successfully`); + } catch (err) { + if (err instanceof ApiClientError) { + setUpdateError(err.error.message); + } else { + setUpdateError('Failed to update category. Please try again.'); + } + } finally { + setIsUpdating(false); + } + }; + + const openDeleteConfirm = (categoryId: string) => { + setDeletingCategoryId(categoryId); + setDeleteError(''); + setSuccessMessage(''); + }; + + const closeDeleteConfirm = () => { + if (!isDeleting) { + setDeletingCategoryId(null); + setDeleteError(''); + } + }; + + const handleDeleteCategory = async (categoryId: string) => { + setIsDeleting(true); + setDeleteError(''); + + try { + await deleteBudgetCategory(categoryId); + const deleted = categories.find((cat) => cat.id === categoryId); + setCategories(categories.filter((cat) => cat.id !== categoryId)); + setDeletingCategoryId(null); + setSuccessMessage(`Category "${deleted?.name}" deleted successfully`); + } catch (err) { + if (err instanceof ApiClientError) { + if (err.statusCode === 409) { + setDeleteError( + 'This category cannot be deleted because it is currently in use by one or more budget entries.', + ); + } else { + setDeleteError(err.error.message); + } + } else { + setDeleteError('Failed to delete category. Please try again.'); + } + } finally { + setIsDeleting(false); + } + }; + + if (isLoading) { + return ( +
+
Loading budget categories...
+
+ ); + } + + if (error && categories.length === 0) { + return ( +
+
+

Error

+

{error}

+ +
+
+ ); + } + + return ( +
+
+ {/* Page header */} +
+

Budget Categories

+ +
+ + {successMessage && ( +
+ {successMessage} +
+ )} + + {error && ( +
+ {error} +
+ )} + + {/* Create form */} + {showCreateForm && ( +
+

New Budget Category

+

+ Budget categories group your construction costs (e.g., Materials, Labor, Permits). +

+ + {createError && ( +
+ {createError} +
+ )} + +
+
+
+ + setNewName(e.target.value)} + className={styles.input} + placeholder="e.g., Materials, Labor, Permits" + maxLength={100} + disabled={isCreating} + autoFocus + /> +
+ +
+ +
+ setNewColor(e.target.value)} + className={styles.colorInput} + disabled={isCreating} + /> +
+
+ +
+ + setNewSortOrder(e.target.value)} + className={styles.input} + placeholder="0" + min={0} + disabled={isCreating} + /> +
+
+ +
+ + setNewDescription(e.target.value)} + className={styles.input} + placeholder="Optional description" + maxLength={500} + disabled={isCreating} + /> +
+ +
+ + +
+
+
+ )} + + {/* Categories list */} +
+

Categories ({categories.length})

+ + {categories.length === 0 ? ( +

+ No budget categories yet. Add your first category to start organizing your project + budget. +

+ ) : ( +
+ {categories.map((category) => ( +
+ {editingCategory?.id === category.id ? ( +
+ {updateError && ( +
+ {updateError} +
+ )} +
+
+ + + setEditingCategory({ ...editingCategory, name: e.target.value }) + } + className={styles.input} + maxLength={100} + disabled={isUpdating} + autoFocus + /> +
+ +
+ +
+ + setEditingCategory({ ...editingCategory, color: e.target.value }) + } + className={styles.colorInput} + disabled={isUpdating} + /> +
+
+ +
+ + + setEditingCategory({ + ...editingCategory, + sortOrder: parseInt(e.target.value, 10) || 0, + }) + } + className={styles.input} + min={0} + disabled={isUpdating} + /> +
+
+ +
+ + + setEditingCategory({ + ...editingCategory, + description: e.target.value, + }) + } + className={styles.input} + placeholder="Optional description" + maxLength={500} + disabled={isUpdating} + /> +
+ +
+ + +
+
+ ) : ( + <> +
+
+
+ + +
+ + )} +
+ ))} +
+ )} +
+
+ + {/* Delete confirmation modal */} + {deletingCategoryId && ( +
+
+
+

+ Delete Category +

+

+ Are you sure you want to delete the category " + {categories.find((c) => c.id === deletingCategoryId)?.name} + "? +

+ + {deleteError ? ( +
+ {deleteError} +
+ ) : ( +

+ This action cannot be undone. The category will be permanently removed. +

+ )} + +
+ + {!deleteError && ( + + )} +
+
+
+ )} +
+ ); +} + +export default BudgetCategoriesPage; diff --git a/e2e/fixtures/testData.ts b/e2e/fixtures/testData.ts index 5b04777b7..d26004781 100644 --- a/e2e/fixtures/testData.ts +++ b/e2e/fixtures/testData.ts @@ -20,6 +20,7 @@ export const ROUTES = { login: '/login', workItems: '/work-items', budget: '/budget', + budgetCategories: '/budget/categories', timeline: '/timeline', householdItems: '/household-items', documents: '/documents', @@ -35,4 +36,5 @@ export const API = { setup: '/api/auth/setup', users: '/api/users', profile: '/api/users/me', + budgetCategories: '/api/budget-categories', }; diff --git a/e2e/pages/BudgetCategoriesPage.ts b/e2e/pages/BudgetCategoriesPage.ts new file mode 100644 index 000000000..665dd8eb4 --- /dev/null +++ b/e2e/pages/BudgetCategoriesPage.ts @@ -0,0 +1,403 @@ +/** + * Page Object Model for the Budget Categories page (/budget/categories) + * + * The page uses an inline create form (toggled by "Add Category" button), + * a list of categories with inline edit forms, and a delete confirmation modal. + */ + +import type { Page, Locator } from '@playwright/test'; + +export const BUDGET_CATEGORIES_ROUTE = '/budget/categories'; + +export interface CreateCategoryData { + name: string; + description?: string; + color?: string; + sortOrder?: number | string; +} + +export interface EditCategoryData { + name?: string; + description?: string; + color?: string; + sortOrder?: number | string; +} + +export class BudgetCategoriesPage { + readonly page: Page; + + // Page header + readonly heading: Locator; + readonly addCategoryButton: Locator; + + // Global banners + readonly successBanner: Locator; + readonly errorBanner: Locator; + + // Create form (only visible after clicking "Add Category") + readonly createFormSection: Locator; + readonly createFormHeading: Locator; + readonly createNameInput: Locator; + readonly createDescriptionInput: Locator; + readonly createColorInput: Locator; + readonly createSortOrderInput: Locator; + readonly createSubmitButton: Locator; + readonly createCancelButton: Locator; + readonly createErrorBanner: Locator; + + // Categories list section + readonly categoriesSection: Locator; + readonly categoriesListHeading: Locator; + readonly categoriesList: Locator; + readonly emptyState: Locator; + + // Delete confirmation modal + readonly deleteModal: Locator; + readonly deleteModalTitle: Locator; + readonly deleteModalText: Locator; + readonly deleteModalWarning: Locator; + readonly deleteModalErrorBanner: Locator; + readonly deleteConfirmButton: Locator; + readonly deleteCancelButton: Locator; + + constructor(page: Page) { + this.page = page; + + // Page header + this.heading = page.getByRole('heading', { level: 1, name: 'Budget Categories', exact: true }); + this.addCategoryButton = page.getByRole('button', { name: 'Add Category', exact: true }); + + // Global banners — scoped to first role="alert" outside the form/modal for each type + // The page renders a single success banner and a single error banner in the main content area + this.successBanner = page.locator(`.content > [role="alert"]`).first(); + this.errorBanner = page.locator('[role="alert"]').filter({ hasText: /error|failed|failed/i }); + + // Create form — inside the section with heading "New Budget Category" + this.createFormSection = page + .getByRole('heading', { level: 2, name: 'New Budget Category', exact: true }) + .locator('..'); + this.createFormHeading = page.getByRole('heading', { + level: 2, + name: 'New Budget Category', + exact: true, + }); + this.createNameInput = page.locator('#categoryName'); + this.createDescriptionInput = page.locator('#categoryDescription'); + this.createColorInput = page.locator('#categoryColor'); + this.createSortOrderInput = page.locator('#categorySortOrder'); + this.createSubmitButton = page.getByRole('button', { name: /Create Category|Creating\.\.\./ }); + this.createCancelButton = page + .getByRole('button', { name: 'Cancel', exact: true }) + .filter({ has: page.locator(':scope') }) + .first(); + this.createErrorBanner = page + .getByRole('heading', { level: 2, name: 'New Budget Category', exact: true }) + .locator('..') + .locator('[role="alert"]'); + + // Categories list section — inside the section with heading starting with "Categories" + this.categoriesSection = page + .getByRole('heading', { level: 2, name: /^Categories/ }) + .locator('..'); + this.categoriesListHeading = page.getByRole('heading', { level: 2, name: /^Categories/ }); + this.categoriesList = page.locator('[class*="categoriesList"]'); + this.emptyState = page.getByText(/No budget categories yet/); + + // Delete confirmation modal + this.deleteModal = page.getByRole('dialog', { name: 'Delete Category' }); + this.deleteModalTitle = page.locator('#delete-modal-title'); + this.deleteModalText = this.deleteModal.locator('p').first(); + this.deleteModalWarning = this.deleteModal.locator('[class*="modalWarning"]'); + this.deleteModalErrorBanner = this.deleteModal.locator('[role="alert"]'); + this.deleteConfirmButton = this.deleteModal.getByRole('button', { + name: /Delete Category|Deleting\.\.\./, + }); + this.deleteCancelButton = this.deleteModal.getByRole('button', { name: 'Cancel', exact: true }); + } + + async goto(): Promise { + await this.page.goto(BUDGET_CATEGORIES_ROUTE); + // Wait for the page heading to appear (data loaded) + await this.heading.waitFor({ state: 'visible', timeout: 15000 }); + } + + /** + * Open the create form by clicking "Add Category" + */ + async openCreateForm(): Promise { + await this.addCategoryButton.click(); + await this.createFormHeading.waitFor({ state: 'visible', timeout: 5000 }); + } + + /** + * Fill and submit the create form. + * Only name is required; other fields are optional. + */ + async createCategory(data: CreateCategoryData): Promise { + await this.createNameInput.fill(data.name); + if (data.description !== undefined) { + await this.createDescriptionInput.fill(data.description); + } + if (data.color !== undefined) { + await this.createColorInput.fill(data.color); + } + if (data.sortOrder !== undefined) { + await this.createSortOrderInput.fill(String(data.sortOrder)); + } + await this.createSubmitButton.click(); + } + + /** + * Get all category row locators from the categories list. + * Each row contains the category's display info and action buttons. + */ + async getCategoryRows(): Promise { + return await this.page.locator('[class*="categoryRow"]').all(); + } + + /** + * Find the category row that contains the given category name. + * Returns null if not found. + */ + async getCategoryRow(name: string): Promise { + // Wait for at least one row to be visible before searching + try { + await this.page + .locator('[class*="categoryRow"]') + .first() + .waitFor({ state: 'visible', timeout: 10000 }); + } catch { + return null; + } + const rows = await this.getCategoryRows(); + for (const row of rows) { + const nameEl = row.locator('[class*="categoryName"]'); + const rowText = await nameEl.textContent(); + if (rowText?.trim() === name) { + return row; + } + } + return null; + } + + /** + * Get names of all categories currently visible in the list, in display order. + */ + async getCategoryNames(): Promise { + const nameLocators = await this.page.locator('[class*="categoryName"]').all(); + const names: string[] = []; + for (const loc of nameLocators) { + const text = await loc.textContent(); + if (text) names.push(text.trim()); + } + return names; + } + + /** + * Get the count shown in the "Categories (N)" heading. + */ + async getCategoriesCount(): Promise { + const headingText = await this.categoriesListHeading.textContent(); + const match = headingText?.match(/\((\d+)\)/); + return match ? parseInt(match[1], 10) : 0; + } + + /** + * Click the Edit button for the named category to enter inline edit mode. + */ + async openEditForm(categoryName: string): Promise { + const row = await this.getCategoryRow(categoryName); + if (!row) { + throw new Error(`Category "${categoryName}" not found in list`); + } + const editButton = row.getByRole('button', { name: `Edit ${categoryName}` }); + await editButton.click(); + // Wait for the edit form to appear (identified by its aria-label) + await this.page + .getByRole('form', { name: `Edit ${categoryName}` }) + .waitFor({ state: 'visible', timeout: 5000 }); + } + + /** + * Get the inline edit form for a category currently being edited. + * The form has aria-label="Edit ". + */ + getEditForm(categoryName: string): Locator { + return this.page.getByRole('form', { name: `Edit ${categoryName}` }); + } + + /** + * Fill the inline edit form fields for a category. + * Only updates fields that are provided. + */ + async fillEditForm(categoryId: string, data: EditCategoryData): Promise { + if (data.name !== undefined) { + const nameInput = this.page.locator(`#edit-name-${categoryId}`); + await nameInput.fill(data.name); + } + if (data.description !== undefined) { + const descInput = this.page.locator(`#edit-description-${categoryId}`); + await descInput.fill(data.description); + } + if (data.color !== undefined) { + const colorInput = this.page.locator(`#edit-color-${categoryId}`); + await colorInput.fill(data.color); + } + if (data.sortOrder !== undefined) { + const sortInput = this.page.locator(`#edit-sortorder-${categoryId}`); + await sortInput.fill(String(data.sortOrder)); + } + } + + /** + * Get the Save button within an active edit form. + * Scoped to the row that is currently in edit mode. + */ + getEditSaveButton(categoryName: string): Locator { + return this.page + .getByRole('form', { name: `Edit ${categoryName}` }) + .getByRole('button', { name: /^Save$|^Saving\.\.\.$/ }); + } + + /** + * Get the Cancel button within an active edit form. + */ + getEditCancelButton(categoryName: string): Locator { + return this.page + .getByRole('form', { name: `Edit ${categoryName}` }) + .getByRole('button', { name: 'Cancel', exact: true }); + } + + /** + * Get the error banner inside an active edit form. + */ + getEditErrorBanner(categoryName: string): Locator { + return this.page.getByRole('form', { name: `Edit ${categoryName}` }).locator('[role="alert"]'); + } + + /** + * Click the Delete button for a named category to open the confirmation modal. + */ + async openDeleteModal(categoryName: string): Promise { + const row = await this.getCategoryRow(categoryName); + if (!row) { + throw new Error(`Category "${categoryName}" not found in list`); + } + const deleteButton = row.getByRole('button', { name: `Delete ${categoryName}` }); + await deleteButton.click(); + await this.deleteModal.waitFor({ state: 'visible', timeout: 5000 }); + } + + /** + * Confirm deletion in the delete modal. + */ + async confirmDelete(): Promise { + await this.deleteConfirmButton.click(); + } + + /** + * Cancel deletion and close the modal. + */ + async cancelDelete(): Promise { + await this.deleteCancelButton.click(); + await this.deleteModal.waitFor({ state: 'hidden', timeout: 5000 }); + } + + /** + * Get the success banner text, or null if not visible. + */ + async getSuccessBannerText(): Promise { + try { + // The success banner has role="alert" and appears in the main content area + const banner = this.page + .locator('[role="alert"]') + .filter({ hasText: /successfully/i }) + .first(); + await banner.waitFor({ state: 'visible', timeout: 5000 }); + return await banner.textContent(); + } catch { + return null; + } + } + + /** + * Get the create form error banner text, or null if not visible. + */ + async getCreateErrorText(): Promise { + try { + await this.createErrorBanner.waitFor({ state: 'visible', timeout: 5000 }); + return await this.createErrorBanner.textContent(); + } catch { + return null; + } + } + + /** + * Get the delete modal error banner text, or null if not visible. + */ + async getDeleteModalErrorText(): Promise { + try { + await this.deleteModalErrorBanner.waitFor({ state: 'visible', timeout: 5000 }); + return await this.deleteModalErrorBanner.textContent(); + } catch { + return null; + } + } + + /** + * Wait for the success banner to appear and then disappear (or just appear). + */ + async waitForSuccessBanner(): Promise { + return this.getSuccessBannerText(); + } + + /** + * Wait for the category list to be loaded (at least one row visible). + */ + async waitForCategoriesLoaded(): Promise { + // Either there's at least one row, or the empty state is visible + await Promise.race([ + this.page + .locator('[class*="categoryRow"]') + .first() + .waitFor({ state: 'visible', timeout: 15000 }), + this.emptyState.waitFor({ state: 'visible', timeout: 15000 }), + ]); + } + + /** + * Get the color swatch background color for a named category. + */ + async getCategorySwatchColor(categoryName: string): Promise { + const row = await this.getCategoryRow(categoryName); + if (!row) return null; + const swatch = row.locator('[class*="categorySwatch"]'); + return await swatch.evaluate((el) => { + return (el as HTMLElement).style.backgroundColor; + }); + } + + /** + * Get the sort order displayed for a named category. + */ + async getCategorySortOrder(categoryName: string): Promise { + const row = await this.getCategoryRow(categoryName); + if (!row) return null; + const sortOrderEl = row.locator('[class*="categorySortOrder"]'); + const text = await sortOrderEl.textContent(); + // Text is rendered as "#N" — strip the "#" prefix + return text?.replace('#', '').trim() ?? null; + } + + /** + * Get the description text for a named category. + */ + async getCategoryDescription(categoryName: string): Promise { + const row = await this.getCategoryRow(categoryName); + if (!row) return null; + const descEl = row.locator('[class*="categoryDescription"]'); + const isVisible = await descEl.isVisible(); + if (!isVisible) return null; + return await descEl.textContent(); + } +} diff --git a/e2e/tests/budget/budget-categories.spec.ts b/e2e/tests/budget/budget-categories.spec.ts new file mode 100644 index 000000000..c99800ab6 --- /dev/null +++ b/e2e/tests/budget/budget-categories.spec.ts @@ -0,0 +1,1050 @@ +/** + * E2E tests for Budget Categories CRUD management (Story #142) + * + * UAT Scenarios covered: + * - Scenario 1: Default categories are seeded on first migration + * - Scenario 2: View all budget categories + * - Scenario 3: Create a new budget category — happy path (all fields) + * - Scenario 4: Create a category with name only (minimal required fields) + * - Scenario 5: Create category fails — missing required name + * - Scenario 6: Create category fails — duplicate name + * - Scenario 8: Edit an existing budget category + * - Scenario 11: Delete a budget category — not referenced by any work item + * - Scenario 18: Empty state when all categories deleted + * - Responsive layout (mobile/tablet/desktop) + * - Dark mode rendering + */ + +import { test, expect } from '../../fixtures/auth.js'; +import type { Page } from '@playwright/test'; +import { BudgetCategoriesPage } from '../../pages/BudgetCategoriesPage.js'; +import { API } from '../../fixtures/testData.js'; + +// The 10 default categories seeded by the EPIC-05 migration, in sort_order order +const DEFAULT_CATEGORIES = [ + 'Materials', + 'Labor', + 'Permits', + 'Design', + 'Equipment', + 'Landscaping', + 'Utilities', + 'Insurance', + 'Contingency', + 'Other', +]; + +// ───────────────────────────────────────────────────────────────────────────── +// Helper: create a temporary category via API and return its id for cleanup +// ───────────────────────────────────────────────────────────────────────────── +async function createCategoryViaApi(page: Page, name: string, sortOrder = 999): Promise { + const response = await page.request.post(API.budgetCategories, { + data: { name, sortOrder }, + }); + expect(response.ok()).toBeTruthy(); + const body = (await response.json()) as { id: string }; + return body.id; +} + +async function deleteCategoryViaApi(page: Page, id: string): Promise { + await page.request.delete(`${API.budgetCategories}/${id}`); +} + +// ───────────────────────────────────────────────────────────────────────────── +// Scenario 1 & 2: Default categories present and list view +// ───────────────────────────────────────────────────────────────────────────── +test.describe('Default categories (Scenario 1 & 2)', () => { + test('Exactly 10 default categories are present after fresh migration', async ({ page }) => { + // Given: EPIC-05 migration applied; default seeds loaded + const categoriesPage = new BudgetCategoriesPage(page); + + // When: I navigate to Budget > Categories + await categoriesPage.goto(); + + // Then: I see exactly 10 categories + const count = await categoriesPage.getCategoriesCount(); + expect(count).toBe(DEFAULT_CATEGORIES.length); + + // And: All 10 named default categories appear in the list + const names = await categoriesPage.getCategoryNames(); + for (const expectedName of DEFAULT_CATEGORIES) { + expect(names).toContain(expectedName); + } + }); + + test('List shows name, color swatch and sort order for each category', async ({ page }) => { + // Given: Default categories are seeded + const categoriesPage = new BudgetCategoriesPage(page); + + // When: I navigate to Budget > Categories + await categoriesPage.goto(); + + // Then: The "Materials" row is visible with a color swatch and sort order indicator + const materialsRow = await categoriesPage.getCategoryRow('Materials'); + expect(materialsRow).not.toBeNull(); + if (materialsRow) { + // Color swatch element should be present + const swatch = materialsRow.locator('[class*="categorySwatch"]'); + await expect(swatch).toBeVisible(); + + // Sort order indicator should be present + const sortOrder = materialsRow.locator('[class*="categorySortOrder"]'); + await expect(sortOrder).toBeVisible(); + } + }); + + test('Categories list heading shows correct count', async ({ page }) => { + // Given: Default categories are seeded + const categoriesPage = new BudgetCategoriesPage(page); + + // When: I navigate to Budget > Categories + await categoriesPage.goto(); + + // Then: The "Categories (10)" heading is visible + await expect(categoriesPage.categoriesListHeading).toContainText('10'); + }); +}); + +// ───────────────────────────────────────────────────────────────────────────── +// Scenario 8 (order) & Scenario 2: Categories sorted by sort_order +// ───────────────────────────────────────────────────────────────────────────── +test.describe('Sort order display (Scenario 2 & 13)', () => { + test('Categories are displayed in ascending sort_order: Materials before Labor before Permits', async ({ + page, + }) => { + // Given: Default categories with known sort_orders exist + const categoriesPage = new BudgetCategoriesPage(page); + + // When: I navigate to Budget > Categories + await categoriesPage.goto(); + + // Then: The order is Materials, Labor, Permits (first three defaults) + const names = await categoriesPage.getCategoryNames(); + const materialsIdx = names.indexOf('Materials'); + const laborIdx = names.indexOf('Labor'); + const permitsIdx = names.indexOf('Permits'); + + expect(materialsIdx).toBeGreaterThanOrEqual(0); + expect(laborIdx).toBeGreaterThanOrEqual(0); + expect(permitsIdx).toBeGreaterThanOrEqual(0); + expect(materialsIdx).toBeLessThan(laborIdx); + expect(laborIdx).toBeLessThan(permitsIdx); + }); + + test('Newly created category with lower sort_order appears before higher sort_order categories', async ({ + page, + }) => { + // Given: Default categories have sort_order >= 1 + const categoriesPage = new BudgetCategoriesPage(page); + let createdId: string | null = null; + + try { + // When: I create a category with sort_order = 0 + createdId = await createCategoryViaApi(page, 'E2E Sort Test Zero', 0); + + await categoriesPage.goto(); + + // Then: "E2E Sort Test Zero" appears first in the list + const names = await categoriesPage.getCategoryNames(); + expect(names[0]).toBe('E2E Sort Test Zero'); + } finally { + if (createdId) { + await deleteCategoryViaApi(page, createdId); + } + } + }); +}); + +// ───────────────────────────────────────────────────────────────────────────── +// Scenario 3: Create category — happy path (all fields) +// ───────────────────────────────────────────────────────────────────────────── +test.describe('Create category — happy path (Scenario 3)', () => { + test('Create new category with all fields — appears in list at correct position', async ({ + page, + }) => { + const categoriesPage = new BudgetCategoriesPage(page); + + // Given: I am on the Budget > Categories page + await categoriesPage.goto(); + + // When: I click "Add Category" + await categoriesPage.openCreateForm(); + + // And: I fill in all fields + await categoriesPage.createCategory({ + name: 'Excavation', + description: 'Site clearing and foundation digging', + color: '#8b4513', + sortOrder: 50, + }); + + // Then: Success banner appears + const successText = await categoriesPage.getSuccessBannerText(); + expect(successText).toContain('Excavation'); + + // And: The form closes + await expect(categoriesPage.createFormHeading).not.toBeVisible({ timeout: 5000 }); + + // And: The new category appears in the list + const names = await categoriesPage.getCategoryNames(); + expect(names).toContain('Excavation'); + + // And: The description is shown + const description = await categoriesPage.getCategoryDescription('Excavation'); + expect(description).toBe('Site clearing and foundation digging'); + + // Cleanup: delete the created category via API + const rows = await categoriesPage.getCategoryRows(); + let createdId: string | null = null; + for (const row of rows) { + const nameEl = row.locator('[class*="categoryName"]'); + const rowText = await nameEl.textContent(); + if (rowText?.trim() === 'Excavation') { + // Get the delete button aria-label to find the category + const deleteBtn = row.getByRole('button', { name: 'Delete Excavation' }); + const ariaLabel = await deleteBtn.getAttribute('aria-label'); + // Delete via modal + if (ariaLabel) { + await deleteBtn.click(); + await categoriesPage.deleteModal.waitFor({ state: 'visible', timeout: 5000 }); + await categoriesPage.confirmDelete(); + await categoriesPage.deleteModal.waitFor({ state: 'hidden', timeout: 5000 }); + createdId = 'deleted'; + } + break; + } + } + // If modal-based delete failed, try API fallback via list count + if (!createdId) { + // Reload and try API-based cleanup by finding the new category's id + const response = await page.request.get(API.budgetCategories); + const body = (await response.json()) as { categories: Array<{ id: string; name: string }> }; + const found = body.categories.find((c) => c.name === 'Excavation'); + if (found) { + await deleteCategoryViaApi(page, found.id); + } + } + }); + + test('Create form resets after successful creation', async ({ page }) => { + const categoriesPage = new BudgetCategoriesPage(page); + let createdId: string | null = null; + + try { + await categoriesPage.goto(); + await categoriesPage.openCreateForm(); + + // When: Create a category + await categoriesPage.createCategory({ + name: 'E2E Create Reset Test', + sortOrder: 998, + }); + + // Wait for success + await categoriesPage.getSuccessBannerText(); + + // Then: The create form is dismissed (collapsed) + await expect(categoriesPage.createFormHeading).not.toBeVisible({ timeout: 5000 }); + + // And: "Add Category" button is enabled again + await expect(categoriesPage.addCategoryButton).toBeEnabled(); + } finally { + // Cleanup via API + const response = await page.request.get(API.budgetCategories); + const body = (await response.json()) as { categories: Array<{ id: string; name: string }> }; + const found = body.categories.find((c) => c.name === 'E2E Create Reset Test'); + if (found) { + createdId = found.id; + await deleteCategoryViaApi(page, createdId); + } + } + }); +}); + +// ───────────────────────────────────────────────────────────────────────────── +// Scenario 5: Create category fails — missing required name +// ───────────────────────────────────────────────────────────────────────────── +test.describe('Create category validation (Scenario 5)', () => { + test('Create button is disabled when name field is empty', async ({ page }) => { + const categoriesPage = new BudgetCategoriesPage(page); + + // Given: I am on the Add Category form + await categoriesPage.goto(); + await categoriesPage.openCreateForm(); + + // When: The Name field is empty (default state) + // Then: The "Create Category" button should be disabled + await expect(categoriesPage.createSubmitButton).toBeDisabled(); + }); + + test('Create button becomes enabled when name is filled', async ({ page }) => { + const categoriesPage = new BudgetCategoriesPage(page); + + // Given: Create form is open + await categoriesPage.goto(); + await categoriesPage.openCreateForm(); + + // When: I type a name + await categoriesPage.createNameInput.fill('Test Category'); + + // Then: The "Create Category" button should be enabled + await expect(categoriesPage.createSubmitButton).toBeEnabled(); + }); + + test('Create shows validation error when name is cleared and submitted', async ({ page }) => { + const categoriesPage = new BudgetCategoriesPage(page); + + // Given: Create form is open with a name, then cleared + await categoriesPage.goto(); + await categoriesPage.openCreateForm(); + + // When: I fill in a name, then clear it (via keyboard), then attempt to submit + await categoriesPage.createNameInput.fill('Temp'); + await categoriesPage.createNameInput.fill(''); + await categoriesPage.createNameInput.focus(); + // The button should be disabled due to empty name guard in JSX + const isDisabled = await categoriesPage.createSubmitButton.isDisabled(); + expect(isDisabled).toBe(true); + }); + + test('Cancel button dismisses create form without creating', async ({ page }) => { + const categoriesPage = new BudgetCategoriesPage(page); + + // Given: I am on the Add Category form + await categoriesPage.goto(); + await categoriesPage.openCreateForm(); + + const countBefore = await categoriesPage.getCategoriesCount(); + + // When: I fill in a name but then click Cancel + await categoriesPage.createNameInput.fill('Should Not Be Created'); + const cancelButton = categoriesPage.page.getByRole('button', { name: 'Cancel', exact: true }); + await cancelButton.click(); + + // Then: The form should be dismissed + await expect(categoriesPage.createFormHeading).not.toBeVisible({ timeout: 5000 }); + + // And: No new category was created + const countAfter = await categoriesPage.getCategoriesCount(); + expect(countAfter).toBe(countBefore); + + // And: The category does not appear in the list + const names = await categoriesPage.getCategoryNames(); + expect(names).not.toContain('Should Not Be Created'); + }); +}); + +// ───────────────────────────────────────────────────────────────────────────── +// Scenario 6: Create category fails — duplicate name +// ───────────────────────────────────────────────────────────────────────────── +test.describe('Duplicate name validation (Scenario 6)', () => { + test('Creating category with duplicate name "Labor" shows error', async ({ page }) => { + const categoriesPage = new BudgetCategoriesPage(page); + + // Given: "Labor" already exists (seeded by default) + await categoriesPage.goto(); + await categoriesPage.openCreateForm(); + + // When: I attempt to create a new category with Name = "Labor" + await categoriesPage.createCategory({ name: 'Labor' }); + + // Then: An error message indicates the name must be unique + const errorText = await categoriesPage.getCreateErrorText(); + expect(errorText).toBeTruthy(); + // Error should mention uniqueness or duplication + expect(errorText?.toLowerCase()).toMatch(/already exists|unique|duplicate|conflict/); + + // And: The category count remains 10 (no duplicate created) + const count = await categoriesPage.getCategoriesCount(); + expect(count).toBe(DEFAULT_CATEGORIES.length); + }); + + test('Duplicate name error does not close the create form', async ({ page }) => { + const categoriesPage = new BudgetCategoriesPage(page); + + // Given: "Materials" already exists + await categoriesPage.goto(); + await categoriesPage.openCreateForm(); + + // When: I attempt to create a duplicate + await categoriesPage.createCategory({ name: 'Materials' }); + + // Then: The create form remains visible (not closed on error) + await expect(categoriesPage.createFormHeading).toBeVisible({ timeout: 5000 }); + }); +}); + +// ───────────────────────────────────────────────────────────────────────────── +// Scenario 8: Edit an existing budget category +// ───────────────────────────────────────────────────────────────────────────── +test.describe('Edit category (Scenario 8 & 9)', () => { + test('Edit "Design" description and color — changes persist after reload', async ({ page }) => { + const categoriesPage = new BudgetCategoriesPage(page); + const originalDescription = 'Design'; + + // Given: The "Design" category exists + await categoriesPage.goto(); + + // Record the initial state so we can restore it + const designRow = await categoriesPage.getCategoryRow('Design'); + expect(designRow).not.toBeNull(); + + // When: I click "Edit" on the "Design" category + await categoriesPage.openEditForm('Design'); + + // Get the category id from the edit form inputs (id="edit-name-{id}") + const editNameInputs = await page.locator('[id^="edit-name-"]').all(); + expect(editNameInputs.length).toBe(1); + const inputId = await editNameInputs[0].getAttribute('id'); + const categoryId = inputId?.replace('edit-name-', '') ?? ''; + expect(categoryId).toBeTruthy(); + + // And: I change the Description + await categoriesPage.fillEditForm(categoryId, { + description: 'Architectural drawings and planning', + color: '#a0522d', + }); + + // And: I click "Save" + const saveButton = categoriesPage.getEditSaveButton('Design'); + await saveButton.click(); + + // Then: Success banner appears + const successText = await categoriesPage.getSuccessBannerText(); + expect(successText).toContain('Design'); + + // And: The edit form closes + await expect(categoriesPage.getEditForm('Design')).not.toBeVisible({ timeout: 5000 }); + + // And: The updated description is visible in the list + const description = await categoriesPage.getCategoryDescription('Design'); + expect(description).toBe('Architectural drawings and planning'); + + // And: The change persists when I reload the page + await categoriesPage.goto(); + const descriptionAfterReload = await categoriesPage.getCategoryDescription('Design'); + expect(descriptionAfterReload).toBe('Architectural drawings and planning'); + + // Cleanup: restore the original empty description + await categoriesPage.openEditForm('Design'); + const editInputsAfterReload = await page.locator('[id^="edit-name-"]').all(); + const editInputIdAfterReload = await editInputsAfterReload[0].getAttribute('id'); + const categoryIdAfterReload = editInputIdAfterReload?.replace('edit-name-', '') ?? ''; + await categoriesPage.fillEditForm(categoryIdAfterReload, { + description: originalDescription === 'Design' ? '' : originalDescription, + }); + const saveAfterReload = categoriesPage.getEditSaveButton('Design'); + await saveAfterReload.click(); + await categoriesPage.getSuccessBannerText(); + }); + + test('Edit modal can be cancelled — original values retained', async ({ page }) => { + const categoriesPage = new BudgetCategoriesPage(page); + + // Given: "Equipment" category exists + await categoriesPage.goto(); + + // When: I click "Edit" on "Equipment" + await categoriesPage.openEditForm('Equipment'); + + const editInputs = await page.locator('[id^="edit-name-"]').all(); + const inputId = await editInputs[0].getAttribute('id'); + const categoryId = inputId?.replace('edit-name-', '') ?? ''; + + // And: I change the name but click Cancel + await categoriesPage.fillEditForm(categoryId, { name: 'Modified Equipment Name' }); + const cancelButton = categoriesPage.getEditCancelButton('Equipment'); + await cancelButton.click(); + + // Then: The edit form is dismissed + await expect(categoriesPage.getEditForm('Equipment')).not.toBeVisible({ timeout: 5000 }); + + // And: The original name "Equipment" is still in the list + const names = await categoriesPage.getCategoryNames(); + expect(names).toContain('Equipment'); + expect(names).not.toContain('Modified Equipment Name'); + }); + + test('Edit with empty name shows error or disables save button', async ({ page }) => { + const categoriesPage = new BudgetCategoriesPage(page); + + // Given: "Insurance" category exists + await categoriesPage.goto(); + await categoriesPage.openEditForm('Insurance'); + + const editInputs = await page.locator('[id^="edit-name-"]').all(); + const inputId = await editInputs[0].getAttribute('id'); + const categoryId = inputId?.replace('edit-name-', '') ?? ''; + + // When: I clear the name field + await categoriesPage.fillEditForm(categoryId, { name: '' }); + + // Then: Save button should be disabled (JSX guard: disabled={isUpdating || !editingCategory.name.trim()}) + const saveButton = categoriesPage.getEditSaveButton('Insurance'); + await expect(saveButton).toBeDisabled(); + + // Cleanup: cancel the edit + const cancelButton = categoriesPage.getEditCancelButton('Insurance'); + await cancelButton.click(); + }); + + test('Only one category can be edited at a time — other edit buttons are disabled', async ({ + page, + }) => { + const categoriesPage = new BudgetCategoriesPage(page); + + // Given: I open the edit form for "Utilities" + await categoriesPage.goto(); + await categoriesPage.openEditForm('Utilities'); + + // Then: The edit button for another category (e.g., "Other") should be disabled + const otherRow = await categoriesPage.getCategoryRow('Other'); + expect(otherRow).not.toBeNull(); + if (otherRow) { + const otherEditButton = otherRow.getByRole('button', { name: 'Edit Other' }); + await expect(otherEditButton).toBeDisabled(); + } + + // Cleanup: cancel the edit + const cancelButton = categoriesPage.getEditCancelButton('Utilities'); + await cancelButton.click(); + }); +}); + +// ───────────────────────────────────────────────────────────────────────────── +// Scenario 11: Delete a category not referenced by any work item +// ───────────────────────────────────────────────────────────────────────────── +test.describe('Delete category (Scenario 11)', () => { + test('Delete confirmation modal opens with category name in text', async ({ page }) => { + const categoriesPage = new BudgetCategoriesPage(page); + let createdId: string | null = null; + + try { + // Given: A test category exists that is not referenced + createdId = await createCategoryViaApi(page, 'E2E Delete Modal Test', 997); + + // When: I navigate to Budget > Categories and click Delete + await categoriesPage.goto(); + await categoriesPage.openDeleteModal('E2E Delete Modal Test'); + + // Then: The modal is visible + await expect(categoriesPage.deleteModal).toBeVisible(); + + // And: The modal title says "Delete Category" + await expect(categoriesPage.deleteModalTitle).toHaveText('Delete Category'); + + // And: The modal text mentions the category name + const modalText = await categoriesPage.deleteModalText.textContent(); + expect(modalText).toContain('E2E Delete Modal Test'); + } finally { + if (createdId) { + await deleteCategoryViaApi(page, createdId); + } + } + }); + + test('Confirming deletion removes category from list', async ({ page }) => { + const categoriesPage = new BudgetCategoriesPage(page); + + // Given: A test category exists (created fresh for this test) + const createdId = await createCategoryViaApi(page, 'E2E Delete Confirm Test', 996); + + // When: I navigate to Budget > Categories + await categoriesPage.goto(); + + const countBefore = await categoriesPage.getCategoriesCount(); + + // And: I click Delete on "E2E Delete Confirm Test" and confirm + await categoriesPage.openDeleteModal('E2E Delete Confirm Test'); + await categoriesPage.confirmDelete(); + + // Then: The modal closes + await expect(categoriesPage.deleteModal).not.toBeVisible({ timeout: 5000 }); + + // And: Success banner appears + const successText = await categoriesPage.getSuccessBannerText(); + expect(successText).toContain('deleted'); + + // And: The category is removed from the list + const names = await categoriesPage.getCategoryNames(); + expect(names).not.toContain('E2E Delete Confirm Test'); + + // And: Count decreased by 1 + const countAfter = await categoriesPage.getCategoriesCount(); + expect(countAfter).toBe(countBefore - 1); + + // Note: no API cleanup needed — category was deleted via UI + void createdId; // suppress unused variable warning + }); + + test('Cancelling deletion modal leaves category in list', async ({ page }) => { + const categoriesPage = new BudgetCategoriesPage(page); + let createdId: string | null = null; + + try { + // Given: A test category exists + createdId = await createCategoryViaApi(page, 'E2E Cancel Delete Test', 995); + + await categoriesPage.goto(); + + const countBefore = await categoriesPage.getCategoriesCount(); + + // When: I click Delete then Cancel + await categoriesPage.openDeleteModal('E2E Cancel Delete Test'); + await categoriesPage.cancelDelete(); + + // Then: The category is still in the list + const names = await categoriesPage.getCategoryNames(); + expect(names).toContain('E2E Cancel Delete Test'); + + // And: Count is unchanged + const countAfter = await categoriesPage.getCategoriesCount(); + expect(countAfter).toBe(countBefore); + } finally { + if (createdId) { + await deleteCategoryViaApi(page, createdId); + } + } + }); + + test('Delete confirmation does not show an error for an unreferenced category', async ({ + page, + }) => { + const categoriesPage = new BudgetCategoriesPage(page); + + // Given: An unreferenced category exists + const createdId = await createCategoryViaApi(page, 'E2E Delete No Error Test', 994); + + try { + await categoriesPage.goto(); + + // When: I open the delete modal + await categoriesPage.openDeleteModal('E2E Delete No Error Test'); + + // Then: No error banner is shown — only the warning text + await expect(categoriesPage.deleteModalWarning).toBeVisible(); + const errorText = await categoriesPage.getDeleteModalErrorText(); + expect(errorText).toBeNull(); + } finally { + // Close modal if open + const isModalVisible = await categoriesPage.deleteModal.isVisible(); + if (isModalVisible) { + await categoriesPage.cancelDelete(); + } + // Cleanup + await deleteCategoryViaApi(page, createdId); + } + }); +}); + +// ───────────────────────────────────────────────────────────────────────────── +// Scenario 12: Delete blocked when category is in use (409 error) +// ───────────────────────────────────────────────────────────────────────────── +test.describe('Delete blocked when in use (Scenario 12)', () => { + test('Delete confirmation button is not shown after 409 error', async ({ page }) => { + // This test verifies the UI behavior when the API returns a 409 conflict. + // We simulate this by intercepting the DELETE request and returning 409. + const categoriesPage = new BudgetCategoriesPage(page); + let createdId: string | null = null; + + try { + // Given: A test category exists + createdId = await createCategoryViaApi(page, 'E2E Delete Blocked Test', 993); + + // Intercept the DELETE request for this category and force a 409 response + await page.route(`${API.budgetCategories}/**`, async (route) => { + if (route.request().method() === 'DELETE') { + await route.fulfill({ + status: 409, + contentType: 'application/json', + body: JSON.stringify({ + error: { + code: 'CATEGORY_IN_USE', + message: + 'This category cannot be deleted because it is currently in use by one or more budget entries.', + }, + }), + }); + } else { + await route.continue(); + } + }); + + await categoriesPage.goto(); + + // When: I attempt to delete the category and confirm + await categoriesPage.openDeleteModal('E2E Delete Blocked Test'); + await categoriesPage.confirmDelete(); + + // Then: An error message appears explaining the category is in use + const errorText = await categoriesPage.getDeleteModalErrorText(); + expect(errorText).toBeTruthy(); + expect(errorText?.toLowerCase()).toMatch(/in use|cannot be deleted|budget entries/); + + // And: The "Delete Category" confirm button is hidden (replaced by error) + await expect(categoriesPage.deleteConfirmButton).not.toBeVisible({ timeout: 3000 }); + + // And: The category remains in the list (modal still open) + await expect(categoriesPage.deleteModal).toBeVisible(); + + // Close modal + await categoriesPage.cancelDelete(); + } finally { + // Remove route interception + await page.unroute(`${API.budgetCategories}/**`); + // Cleanup + if (createdId) { + await deleteCategoryViaApi(page, createdId); + } + } + }); +}); + +// ───────────────────────────────────────────────────────────────────────────── +// Scenario 18: Empty state when all categories deleted +// ───────────────────────────────────────────────────────────────────────────── +test.describe('Empty state (Scenario 18)', () => { + test('Empty state message shown when no categories exist', async ({ page }) => { + // Note: This test uses API route mocking to simulate an empty list + // without actually deleting all 10 default categories (which would be destructive). + const categoriesPage = new BudgetCategoriesPage(page); + + // Intercept the GET request to return an empty list + await page.route(`${API.budgetCategories}`, async (route) => { + if (route.request().method() === 'GET') { + await route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ categories: [] }), + }); + } else { + await route.continue(); + } + }); + + try { + // Given: No categories exist (simulated by mocked empty response) + await categoriesPage.goto(); + + // When: The page loads + + // Then: Empty state message is visible + await expect(categoriesPage.emptyState).toBeVisible({ timeout: 10000 }); + + // And: The empty state contains helpful text + const emptyText = await categoriesPage.emptyState.textContent(); + expect(emptyText).toBeTruthy(); + expect(emptyText?.toLowerCase()).toMatch(/no.*categor|add.*first/); + + // And: The "Add Category" button is still visible (call-to-action) + await expect(categoriesPage.addCategoryButton).toBeVisible(); + } finally { + await page.unroute(`${API.budgetCategories}`); + } + }); + + test('Categories count heading shows 0 when list is empty', async ({ page }) => { + const categoriesPage = new BudgetCategoriesPage(page); + + await page.route(`${API.budgetCategories}`, async (route) => { + if (route.request().method() === 'GET') { + await route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ categories: [] }), + }); + } else { + await route.continue(); + } + }); + + try { + await categoriesPage.goto(); + await expect(categoriesPage.categoriesListHeading).toContainText('0'); + } finally { + await page.unroute(`${API.budgetCategories}`); + } + }); +}); + +// ───────────────────────────────────────────────────────────────────────────── +// Page structure and navigation +// ───────────────────────────────────────────────────────────────────────────── +test.describe('Page structure and accessibility', () => { + test('Page has correct h1 heading "Budget Categories"', async ({ page }) => { + const categoriesPage = new BudgetCategoriesPage(page); + + await categoriesPage.goto(); + + await expect(categoriesPage.heading).toBeVisible(); + await expect(categoriesPage.heading).toHaveText('Budget Categories'); + }); + + test('"Add Category" button is visible on page load', async ({ page }) => { + const categoriesPage = new BudgetCategoriesPage(page); + + await categoriesPage.goto(); + + await expect(categoriesPage.addCategoryButton).toBeVisible(); + await expect(categoriesPage.addCategoryButton).toBeEnabled(); + }); + + test('"Add Category" button is disabled while create form is open', async ({ page }) => { + const categoriesPage = new BudgetCategoriesPage(page); + + await categoriesPage.goto(); + await categoriesPage.openCreateForm(); + + // The button becomes disabled once the create form is shown + await expect(categoriesPage.addCategoryButton).toBeDisabled(); + }); + + test('Page URL is /budget/categories', async ({ page }) => { + const categoriesPage = new BudgetCategoriesPage(page); + + await categoriesPage.goto(); + + await page.waitForURL('/budget/categories'); + expect(page.url()).toContain('/budget/categories'); + }); + + test('Navigating to /budget redirects to /budget/categories', async ({ page }) => { + await page.goto('/budget'); + await page.waitForURL('/budget/categories'); + expect(page.url()).toContain('/budget/categories'); + }); +}); + +// ───────────────────────────────────────────────────────────────────────────── +// Responsive layout (Scenario 9) +// ───────────────────────────────────────────────────────────────────────────── +test.describe('Responsive layout (Scenario 9)', () => { + test('Page renders without horizontal scroll on current viewport', async ({ page }) => { + const categoriesPage = new BudgetCategoriesPage(page); + + await categoriesPage.goto(); + + const hasHorizontalScroll = await page.evaluate(() => { + return document.documentElement.scrollWidth > window.innerWidth; + }); + + expect(hasHorizontalScroll).toBe(false); + }); + + test('Create form fields are visible and usable on current viewport', async ({ page }) => { + const categoriesPage = new BudgetCategoriesPage(page); + + await categoriesPage.goto(); + await categoriesPage.openCreateForm(); + + // All form inputs should be visible regardless of viewport + await expect(categoriesPage.createNameInput).toBeVisible(); + await expect(categoriesPage.createDescriptionInput).toBeVisible(); + await expect(categoriesPage.createColorInput).toBeVisible(); + await expect(categoriesPage.createSortOrderInput).toBeVisible(); + await expect(categoriesPage.createSubmitButton).toBeVisible(); + + // Cancel and close the form + const cancelButton = page.getByRole('button', { name: 'Cancel', exact: true }); + await cancelButton.click(); + }); + + test('Category list rows are visible and action buttons accessible on current viewport', async ({ + page, + }) => { + const categoriesPage = new BudgetCategoriesPage(page); + + await categoriesPage.goto(); + + // At least one category row is visible + const rows = await categoriesPage.getCategoryRows(); + expect(rows.length).toBeGreaterThan(0); + + // The first row (Materials) has Edit and Delete buttons + const firstRow = rows[0]; + const firstNameEl = firstRow.locator('[class*="categoryName"]'); + const firstName = (await firstNameEl.textContent())?.trim() ?? ''; + + if (firstName) { + const editButton = firstRow.getByRole('button', { name: `Edit ${firstName}` }); + const deleteButton = firstRow.getByRole('button', { name: `Delete ${firstName}` }); + + await expect(editButton).toBeVisible(); + await expect(deleteButton).toBeVisible(); + } + }); + + test('Desktop: create form fields render in a single row (>= 1024px)', async ({ page }) => { + const viewport = page.viewportSize(); + + // Only run this specific layout assertion on desktop + if (!viewport || viewport.width < 1024) { + test.skip(); + return; + } + + const categoriesPage = new BudgetCategoriesPage(page); + await categoriesPage.goto(); + await categoriesPage.openCreateForm(); + + // On desktop, the name, color, and sort order fields should all be in view + // without requiring any scrolling within the form + const nameInputBox = await categoriesPage.createNameInput.boundingBox(); + const colorInputBox = await categoriesPage.createColorInput.boundingBox(); + const sortOrderBox = await categoriesPage.createSortOrderInput.boundingBox(); + + expect(nameInputBox).not.toBeNull(); + expect(colorInputBox).not.toBeNull(); + expect(sortOrderBox).not.toBeNull(); + + // On desktop they should be roughly on the same vertical line (same row) + if (nameInputBox && colorInputBox && sortOrderBox) { + const rowHeightTolerance = 60; // Allow 60px tolerance for alignment + expect(Math.abs(nameInputBox.y - colorInputBox.y)).toBeLessThan(rowHeightTolerance); + expect(Math.abs(nameInputBox.y - sortOrderBox.y)).toBeLessThan(rowHeightTolerance); + } + + const cancelButton = page.getByRole('button', { name: 'Cancel', exact: true }); + await cancelButton.click(); + }); +}); + +// ───────────────────────────────────────────────────────────────────────────── +// Dark mode rendering (Scenario 10) +// ───────────────────────────────────────────────────────────────────────────── +test.describe('Dark mode rendering (Scenario 10)', () => { + test('Page renders correctly in dark mode — no white-on-white or black-on-black text', async ({ + page, + }) => { + const categoriesPage = new BudgetCategoriesPage(page); + + // Enable dark mode via the data-theme attribute (matches ThemeContext implementation) + await page.goto('/budget/categories'); + await page.evaluate(() => { + document.documentElement.setAttribute('data-theme', 'dark'); + }); + + // Wait for categories to load + await categoriesPage.heading.waitFor({ state: 'visible', timeout: 15000 }); + + // Then: The heading is visible (not hidden by theme issues) + await expect(categoriesPage.heading).toBeVisible(); + + // And: The categories section is visible + await expect(categoriesPage.categoriesListHeading).toBeVisible(); + + // And: At least one category row is visible (content renders) + const rows = await categoriesPage.getCategoryRows(); + expect(rows.length).toBeGreaterThan(0); + + // And: No horizontal scroll in dark mode + const hasHorizontalScroll = await page.evaluate(() => { + return document.documentElement.scrollWidth > window.innerWidth; + }); + expect(hasHorizontalScroll).toBe(false); + }); + + test('Create form is usable in dark mode', async ({ page }) => { + const categoriesPage = new BudgetCategoriesPage(page); + + await page.goto('/budget/categories'); + await page.evaluate(() => { + document.documentElement.setAttribute('data-theme', 'dark'); + }); + + await categoriesPage.heading.waitFor({ state: 'visible', timeout: 15000 }); + await categoriesPage.openCreateForm(); + + // Form inputs should be visible in dark mode + await expect(categoriesPage.createNameInput).toBeVisible(); + await expect(categoriesPage.createSubmitButton).toBeVisible(); + + // Cancel form + const cancelButton = page.getByRole('button', { name: 'Cancel', exact: true }); + await cancelButton.click(); + }); + + test('Delete modal is usable in dark mode', async ({ page }) => { + const categoriesPage = new BudgetCategoriesPage(page); + let createdId: string | null = null; + + try { + createdId = await createCategoryViaApi(page, 'E2E Dark Mode Delete Test', 992); + + await page.goto('/budget/categories'); + await page.evaluate(() => { + document.documentElement.setAttribute('data-theme', 'dark'); + }); + + await categoriesPage.heading.waitFor({ state: 'visible', timeout: 15000 }); + + // Open delete modal in dark mode + await categoriesPage.openDeleteModal('E2E Dark Mode Delete Test'); + + // Modal should be visible and usable + await expect(categoriesPage.deleteModal).toBeVisible(); + await expect(categoriesPage.deleteConfirmButton).toBeVisible(); + await expect(categoriesPage.deleteCancelButton).toBeVisible(); + + // Close modal + await categoriesPage.cancelDelete(); + } finally { + if (createdId) { + await deleteCategoryViaApi(page, createdId); + } + } + }); +}); + +// ───────────────────────────────────────────────────────────────────────────── +// Color field behavior (Scenario 17) +// ───────────────────────────────────────────────────────────────────────────── +test.describe('Color field (Scenario 17)', () => { + test('Color swatch reflects selected color in create form', async ({ page }) => { + const categoriesPage = new BudgetCategoriesPage(page); + + await categoriesPage.goto(); + await categoriesPage.openCreateForm(); + + // The color input has a default value + const defaultColor = await categoriesPage.createColorInput.inputValue(); + expect(defaultColor).toMatch(/^#[0-9a-fA-F]{6}$/); + + // Cancel form + const cancelButton = page.getByRole('button', { name: 'Cancel', exact: true }); + await cancelButton.click(); + }); + + test('Color input accepts hex color values', async ({ page }) => { + const categoriesPage = new BudgetCategoriesPage(page); + let createdId: string | null = null; + + try { + // Create a category with a specific color via API + const response = await page.request.post(API.budgetCategories, { + data: { name: 'E2E Color Test', color: '#ff6b35', sortOrder: 991 }, + }); + expect(response.ok()).toBeTruthy(); + const body = (await response.json()) as { id: string }; + createdId = body.id; + + await categoriesPage.goto(); + + // The category should be in the list with its color swatch + const row = await categoriesPage.getCategoryRow('E2E Color Test'); + expect(row).not.toBeNull(); + + if (row) { + const swatch = row.locator('[class*="categorySwatch"]'); + await expect(swatch).toBeVisible(); + // The swatch should have a non-transparent background color + const bgColor = await swatch.evaluate((el) => (el as HTMLElement).style.backgroundColor); + expect(bgColor).toBeTruthy(); + } + } finally { + if (createdId) { + await deleteCategoryViaApi(page, createdId); + } + } + }); +}); diff --git a/server/src/app.ts b/server/src/app.ts index 290111f6e..7ca359508 100644 --- a/server/src/app.ts +++ b/server/src/app.ts @@ -20,6 +20,7 @@ import tagRoutes from './routes/tags.js'; import noteRoutes from './routes/notes.js'; import subtaskRoutes from './routes/subtasks.js'; import dependencyRoutes from './routes/dependencies.js'; +import budgetCategoryRoutes from './routes/budgetCategories.js'; import { hashPassword, verifyPassword } from './services/userService.js'; const __dirname = dirname(fileURLToPath(import.meta.url)); @@ -74,6 +75,9 @@ export async function buildApp(): Promise { // Dependency routes (nested under work items) await app.register(dependencyRoutes, { prefix: '/api/work-items/:workItemId/dependencies' }); + // Budget category routes + await app.register(budgetCategoryRoutes, { prefix: '/api/budget-categories' }); + // Health check endpoint (liveness) app.get('/api/health', async () => { return { status: 'ok', timestamp: new Date().toISOString() }; diff --git a/server/src/db/migrations/0003_create_budget_tables.sql b/server/src/db/migrations/0003_create_budget_tables.sql new file mode 100644 index 000000000..934c7110e --- /dev/null +++ b/server/src/db/migrations/0003_create_budget_tables.sql @@ -0,0 +1,134 @@ +-- EPIC-05: Budget Management +-- Creates all budget-related tables: categories, vendors, invoices, +-- budget sources, subsidy programs, and junction tables. + +-- Budget categories for organizing construction costs +CREATE TABLE budget_categories ( + id TEXT PRIMARY KEY, + name TEXT UNIQUE NOT NULL, + description TEXT, + color TEXT, + sort_order INTEGER NOT NULL DEFAULT 0, + created_at TEXT NOT NULL DEFAULT (datetime('now')), + updated_at TEXT NOT NULL DEFAULT (datetime('now')) +); + +-- Seed default budget categories +INSERT INTO budget_categories (id, name, description, color, sort_order, created_at, updated_at) VALUES + ('bc-materials', 'Materials', 'Raw materials and building supplies', '#3B82F6', 0, datetime('now'), datetime('now')), + ('bc-labor', 'Labor', 'Contractor and worker labor costs', '#EF4444', 1, datetime('now'), datetime('now')), + ('bc-permits', 'Permits', 'Building permits and regulatory fees', '#F59E0B', 2, datetime('now'), datetime('now')), + ('bc-design', 'Design', 'Architectural and design services', '#8B5CF6', 3, datetime('now'), datetime('now')), + ('bc-equipment', 'Equipment', 'Tools and equipment rental or purchase', '#06B6D4', 4, datetime('now'), datetime('now')), + ('bc-landscaping', 'Landscaping', 'Outdoor landscaping and hardscaping', '#22C55E', 5, datetime('now'), datetime('now')), + ('bc-utilities', 'Utilities', 'Utility connections and installations', '#F97316', 6, datetime('now'), datetime('now')), + ('bc-insurance', 'Insurance', 'Construction and builder risk insurance', '#6366F1', 7, datetime('now'), datetime('now')), + ('bc-contingency', 'Contingency', 'Reserve funds for unexpected costs', '#EC4899', 8, datetime('now'), datetime('now')), + ('bc-other', 'Other', 'Miscellaneous costs not covered by other categories', '#6B7280', 9, datetime('now'), datetime('now')); + +-- Vendor/contractor database +CREATE TABLE vendors ( + id TEXT PRIMARY KEY, + name TEXT NOT NULL, + specialty TEXT, + phone TEXT, + email TEXT, + address TEXT, + notes TEXT, + created_by TEXT REFERENCES users(id) ON DELETE SET NULL, + created_at TEXT NOT NULL DEFAULT (datetime('now')), + updated_at TEXT NOT NULL DEFAULT (datetime('now')) +); + +CREATE INDEX idx_vendors_name ON vendors (name); + +-- Invoice tracking per vendor +CREATE TABLE invoices ( + id TEXT PRIMARY KEY, + vendor_id TEXT NOT NULL REFERENCES vendors(id) ON DELETE CASCADE, + invoice_number TEXT, + amount REAL NOT NULL, + date TEXT NOT NULL, + due_date TEXT, + status TEXT NOT NULL DEFAULT 'pending' CHECK(status IN ('pending', 'paid', 'overdue')), + notes TEXT, + created_by TEXT REFERENCES users(id) ON DELETE SET NULL, + created_at TEXT NOT NULL DEFAULT (datetime('now')), + updated_at TEXT NOT NULL DEFAULT (datetime('now')) +); + +CREATE INDEX idx_invoices_vendor_id ON invoices (vendor_id); +CREATE INDEX idx_invoices_status ON invoices (status); +CREATE INDEX idx_invoices_date ON invoices (date); + +-- Financing sources (bank loans, credit lines, savings, etc.) +CREATE TABLE budget_sources ( + id TEXT PRIMARY KEY, + name TEXT NOT NULL, + source_type TEXT NOT NULL CHECK(source_type IN ('bank_loan', 'credit_line', 'savings', 'other')), + total_amount REAL NOT NULL, + interest_rate REAL, + terms TEXT, + notes TEXT, + status TEXT NOT NULL DEFAULT 'active' CHECK(status IN ('active', 'exhausted', 'closed')), + created_by TEXT REFERENCES users(id) ON DELETE SET NULL, + created_at TEXT NOT NULL DEFAULT (datetime('now')), + updated_at TEXT NOT NULL DEFAULT (datetime('now')) +); + +-- Subsidy/incentive programs +CREATE TABLE subsidy_programs ( + id TEXT PRIMARY KEY, + name TEXT NOT NULL, + description TEXT, + eligibility TEXT, + reduction_type TEXT NOT NULL CHECK(reduction_type IN ('percentage', 'fixed')), + reduction_value REAL NOT NULL, + application_status TEXT NOT NULL DEFAULT 'eligible' CHECK(application_status IN ('eligible', 'applied', 'approved', 'received', 'rejected')), + application_deadline TEXT, + notes TEXT, + created_by TEXT REFERENCES users(id) ON DELETE SET NULL, + created_at TEXT NOT NULL DEFAULT (datetime('now')), + updated_at TEXT NOT NULL DEFAULT (datetime('now')) +); + +-- Junction: subsidy programs <-> budget categories (M:N) +CREATE TABLE subsidy_program_categories ( + subsidy_program_id TEXT NOT NULL REFERENCES subsidy_programs(id) ON DELETE CASCADE, + budget_category_id TEXT NOT NULL REFERENCES budget_categories(id) ON DELETE CASCADE, + PRIMARY KEY (subsidy_program_id, budget_category_id) +); + +-- Junction: work items <-> vendors (M:N) +CREATE TABLE work_item_vendors ( + work_item_id TEXT NOT NULL REFERENCES work_items(id) ON DELETE CASCADE, + vendor_id TEXT NOT NULL REFERENCES vendors(id) ON DELETE CASCADE, + PRIMARY KEY (work_item_id, vendor_id) +); + +CREATE INDEX idx_work_item_vendors_vendor_id ON work_item_vendors (vendor_id); + +-- Junction: work items <-> subsidy programs (M:N) +CREATE TABLE work_item_subsidies ( + work_item_id TEXT NOT NULL REFERENCES work_items(id) ON DELETE CASCADE, + subsidy_program_id TEXT NOT NULL REFERENCES subsidy_programs(id) ON DELETE CASCADE, + PRIMARY KEY (work_item_id, subsidy_program_id) +); + +CREATE INDEX idx_work_item_subsidies_subsidy_program_id ON work_item_subsidies (subsidy_program_id); + +-- Rollback: +-- DROP INDEX IF EXISTS idx_work_item_subsidies_subsidy_program_id; +-- DROP TABLE IF EXISTS work_item_subsidies; +-- DROP INDEX IF EXISTS idx_work_item_vendors_vendor_id; +-- DROP TABLE IF EXISTS work_item_vendors; +-- DROP TABLE IF EXISTS subsidy_program_categories; +-- DROP TABLE IF EXISTS subsidy_programs; +-- DROP TABLE IF EXISTS budget_sources; +-- DROP INDEX IF EXISTS idx_invoices_date; +-- DROP INDEX IF EXISTS idx_invoices_status; +-- DROP INDEX IF EXISTS idx_invoices_vendor_id; +-- DROP TABLE IF EXISTS invoices; +-- DROP INDEX IF EXISTS idx_vendors_name; +-- DROP TABLE IF EXISTS vendors; +-- DROP TABLE IF EXISTS budget_categories; diff --git a/server/src/db/schema.test.ts b/server/src/db/schema.test.ts index 39cff27ec..5f003d273 100644 --- a/server/src/db/schema.test.ts +++ b/server/src/db/schema.test.ts @@ -2124,3 +2124,600 @@ describe('Work Items Database Schema & Migration', () => { }); }); }); + +// ─── EPIC-05: Budget Schema Tests ───────────────────────────────────────────── + +describe('Budget Schema (EPIC-05)', () => { + let sqlite: Database.Database; + let db: BetterSQLite3Database; + + function createTestDb() { + const sqliteDb = new Database(':memory:'); + sqliteDb.pragma('journal_mode = WAL'); + sqliteDb.pragma('foreign_keys = ON'); + runMigrations(sqliteDb); + return { sqlite: sqliteDb, db: drizzle(sqliteDb, { schema }) }; + } + + beforeEach(() => { + const testDb = createTestDb(); + sqlite = testDb.sqlite; + db = testDb.db; + }); + + afterEach(() => { + sqlite.close(); + }); + + // ─── budget_categories table ────────────────────────────────────────────── + + describe('budget_categories table', () => { + it('creates budget_categories table with correct columns', () => { + const columns = sqlite.prepare("PRAGMA table_info('budget_categories')").all() as Array<{ + name: string; + type: string; + notnull: number; + pk: number; + dflt_value: string | null; + }>; + + const columnNames = columns.map((col) => col.name); + expect(columnNames).toContain('id'); + expect(columnNames).toContain('name'); + expect(columnNames).toContain('description'); + expect(columnNames).toContain('color'); + expect(columnNames).toContain('sort_order'); + expect(columnNames).toContain('created_at'); + expect(columnNames).toContain('updated_at'); + + // Primary key + const idCol = columns.find((col) => col.name === 'id'); + expect(idCol?.pk).toBe(1); + + // NOT NULL constraints + const nameCol = columns.find((col) => col.name === 'name'); + expect(nameCol?.notnull).toBe(1); + + const createdAtCol = columns.find((col) => col.name === 'created_at'); + expect(createdAtCol?.notnull).toBe(1); + + const updatedAtCol = columns.find((col) => col.name === 'updated_at'); + expect(updatedAtCol?.notnull).toBe(1); + + // sort_order defaults to 0 + const sortOrderCol = columns.find((col) => col.name === 'sort_order'); + expect(sortOrderCol?.notnull).toBe(1); + expect(sortOrderCol?.dflt_value).toBe('0'); + }); + + it('enforces UNIQUE constraint on name column', async () => { + // Use a name that does not conflict with the 10 seeded categories. + // better-sqlite3 is synchronous and throws (not rejects), so use try/catch. + const now = new Date().toISOString(); + + await db.insert(schema.budgetCategories).values({ + id: 'cat-schema-1', + name: 'Test Schema Cat A', + createdAt: now, + updatedAt: now, + }); + + let error: Error | undefined; + try { + await db.insert(schema.budgetCategories).values({ + id: 'cat-schema-2', + name: 'Test Schema Cat A', // Duplicate name + createdAt: now, + updatedAt: now, + }); + } catch (err) { + error = err as Error; + } + + expect(error).toBeDefined(); + expect(error?.message).toMatch(/UNIQUE constraint failed/); + }); + + it('enforces UNIQUE constraint on seeded name (Materials)', async () => { + // 'Materials' is already seeded by the migration; re-inserting must fail. + // better-sqlite3 throws synchronously, so use try/catch. + const now = new Date().toISOString(); + + let error: Error | undefined; + try { + await db.insert(schema.budgetCategories).values({ + id: 'cat-schema-dup', + name: 'Materials', + createdAt: now, + updatedAt: now, + }); + } catch (err) { + error = err as Error; + } + + expect(error).toBeDefined(); + expect(error?.message).toMatch(/UNIQUE constraint failed/); + }); + + it('can insert a category with all optional fields', async () => { + const now = new Date().toISOString(); + + await db.insert(schema.budgetCategories).values({ + id: 'cat-full', + name: 'Test Schema Full Cat', + description: 'Construction labor costs', + color: '#3B82F6', + sortOrder: 5, + createdAt: now, + updatedAt: now, + }); + + const rows = await db + .select() + .from(schema.budgetCategories) + .where(eq(schema.budgetCategories.id, 'cat-full')); + + expect(rows).toHaveLength(1); + expect(rows[0].name).toBe('Test Schema Full Cat'); + expect(rows[0].description).toBe('Construction labor costs'); + expect(rows[0].color).toBe('#3B82F6'); + expect(rows[0].sortOrder).toBe(5); + }); + + it('can insert a category with null optional fields', async () => { + const now = new Date().toISOString(); + + await db.insert(schema.budgetCategories).values({ + id: 'cat-minimal', + name: 'Test Schema Minimal Cat', + description: null, + color: null, + createdAt: now, + updatedAt: now, + }); + + const rows = await db + .select() + .from(schema.budgetCategories) + .where(eq(schema.budgetCategories.id, 'cat-minimal')); + + expect(rows).toHaveLength(1); + expect(rows[0].description).toBeNull(); + expect(rows[0].color).toBeNull(); + expect(rows[0].sortOrder).toBe(0); // Default value + }); + }); + + // ─── vendors table ──────────────────────────────────────────────────────── + + describe('vendors table', () => { + it('creates vendors table with correct columns', () => { + const columns = sqlite.prepare("PRAGMA table_info('vendors')").all() as Array<{ + name: string; + type: string; + notnull: number; + pk: number; + }>; + + const columnNames = columns.map((col) => col.name); + expect(columnNames).toContain('id'); + expect(columnNames).toContain('name'); + expect(columnNames).toContain('specialty'); + expect(columnNames).toContain('phone'); + expect(columnNames).toContain('email'); + expect(columnNames).toContain('address'); + expect(columnNames).toContain('notes'); + expect(columnNames).toContain('created_by'); + expect(columnNames).toContain('created_at'); + expect(columnNames).toContain('updated_at'); + + const idCol = columns.find((col) => col.name === 'id'); + expect(idCol?.pk).toBe(1); + + const nameCol = columns.find((col) => col.name === 'name'); + expect(nameCol?.notnull).toBe(1); + }); + + it('can insert a vendor with all fields', async () => { + const now = new Date().toISOString(); + + await db.insert(schema.vendors).values({ + id: 'vendor-1', + name: 'ABC Construction', + specialty: 'Electrical', + phone: '555-1234', + email: 'abc@construction.com', + address: '123 Main St', + notes: 'Reliable contractor', + createdAt: now, + updatedAt: now, + }); + + const rows = await db.select().from(schema.vendors).where(eq(schema.vendors.id, 'vendor-1')); + + expect(rows).toHaveLength(1); + expect(rows[0].name).toBe('ABC Construction'); + expect(rows[0].specialty).toBe('Electrical'); + }); + + it('creates idx_vendors_name index', () => { + const indexes = sqlite + .prepare("SELECT name FROM sqlite_master WHERE type='index' AND tbl_name='vendors'") + .all() as Array<{ name: string }>; + + const indexNames = indexes.map((idx) => idx.name); + expect(indexNames).toContain('idx_vendors_name'); + }); + }); + + // ─── invoices table ─────────────────────────────────────────────────────── + + describe('invoices table', () => { + it('creates invoices table with correct columns', () => { + const columns = sqlite.prepare("PRAGMA table_info('invoices')").all() as Array<{ + name: string; + type: string; + notnull: number; + pk: number; + }>; + + const columnNames = columns.map((col) => col.name); + expect(columnNames).toContain('id'); + expect(columnNames).toContain('vendor_id'); + expect(columnNames).toContain('invoice_number'); + expect(columnNames).toContain('amount'); + expect(columnNames).toContain('date'); + expect(columnNames).toContain('due_date'); + expect(columnNames).toContain('status'); + expect(columnNames).toContain('notes'); + expect(columnNames).toContain('created_by'); + expect(columnNames).toContain('created_at'); + expect(columnNames).toContain('updated_at'); + + const amountCol = columns.find((col) => col.name === 'amount'); + expect(amountCol?.notnull).toBe(1); + + const dateCol = columns.find((col) => col.name === 'date'); + expect(dateCol?.notnull).toBe(1); + }); + + it('can insert an invoice and CASCADE deletes when vendor is deleted', async () => { + const now = new Date().toISOString(); + + // Insert vendor + await db.insert(schema.vendors).values({ + id: 'vendor-for-invoice', + name: 'Test Vendor', + createdAt: now, + updatedAt: now, + }); + + // Insert invoice + await db.insert(schema.invoices).values({ + id: 'invoice-1', + vendorId: 'vendor-for-invoice', + amount: 5000.0, + date: '2026-01-15', + createdAt: now, + updatedAt: now, + }); + + // Verify invoice exists + const invoicesBefore = await db + .select() + .from(schema.invoices) + .where(eq(schema.invoices.id, 'invoice-1')); + expect(invoicesBefore).toHaveLength(1); + + // Delete vendor (should cascade) + await db.delete(schema.vendors).where(eq(schema.vendors.id, 'vendor-for-invoice')); + + // Invoice should be gone + const invoicesAfter = await db + .select() + .from(schema.invoices) + .where(eq(schema.invoices.id, 'invoice-1')); + expect(invoicesAfter).toHaveLength(0); + }); + + it('creates idx_invoices_vendor_id, idx_invoices_status, and idx_invoices_date indexes', () => { + const indexes = sqlite + .prepare("SELECT name FROM sqlite_master WHERE type='index' AND tbl_name='invoices'") + .all() as Array<{ name: string }>; + + const indexNames = indexes.map((idx) => idx.name); + expect(indexNames).toContain('idx_invoices_vendor_id'); + expect(indexNames).toContain('idx_invoices_status'); + expect(indexNames).toContain('idx_invoices_date'); + }); + }); + + // ─── budget_sources table ───────────────────────────────────────────────── + + describe('budget_sources table', () => { + it('creates budget_sources table with correct columns', () => { + const columns = sqlite.prepare("PRAGMA table_info('budget_sources')").all() as Array<{ + name: string; + type: string; + notnull: number; + pk: number; + }>; + + const columnNames = columns.map((col) => col.name); + expect(columnNames).toContain('id'); + expect(columnNames).toContain('name'); + expect(columnNames).toContain('source_type'); + expect(columnNames).toContain('total_amount'); + expect(columnNames).toContain('interest_rate'); + expect(columnNames).toContain('terms'); + expect(columnNames).toContain('notes'); + expect(columnNames).toContain('status'); + expect(columnNames).toContain('created_by'); + expect(columnNames).toContain('created_at'); + expect(columnNames).toContain('updated_at'); + + const nameCol = columns.find((col) => col.name === 'name'); + expect(nameCol?.notnull).toBe(1); + + const totalAmountCol = columns.find((col) => col.name === 'total_amount'); + expect(totalAmountCol?.notnull).toBe(1); + }); + + it('can insert a budget source', async () => { + const now = new Date().toISOString(); + + await db.insert(schema.budgetSources).values({ + id: 'source-1', + name: 'Bank Loan', + sourceType: 'bank_loan', + totalAmount: 200000.0, + interestRate: 3.5, + createdAt: now, + updatedAt: now, + }); + + const rows = await db + .select() + .from(schema.budgetSources) + .where(eq(schema.budgetSources.id, 'source-1')); + + expect(rows).toHaveLength(1); + expect(rows[0].name).toBe('Bank Loan'); + expect(rows[0].sourceType).toBe('bank_loan'); + expect(rows[0].totalAmount).toBe(200000.0); + expect(rows[0].status).toBe('active'); // Default value + }); + }); + + // ─── subsidy_programs table ─────────────────────────────────────────────── + + describe('subsidy_programs table', () => { + it('creates subsidy_programs table with correct columns', () => { + const columns = sqlite.prepare("PRAGMA table_info('subsidy_programs')").all() as Array<{ + name: string; + type: string; + notnull: number; + pk: number; + }>; + + const columnNames = columns.map((col) => col.name); + expect(columnNames).toContain('id'); + expect(columnNames).toContain('name'); + expect(columnNames).toContain('description'); + expect(columnNames).toContain('eligibility'); + expect(columnNames).toContain('reduction_type'); + expect(columnNames).toContain('reduction_value'); + expect(columnNames).toContain('application_status'); + expect(columnNames).toContain('application_deadline'); + expect(columnNames).toContain('notes'); + expect(columnNames).toContain('created_at'); + expect(columnNames).toContain('updated_at'); + }); + + it('can insert a subsidy program', async () => { + const now = new Date().toISOString(); + + await db.insert(schema.subsidyPrograms).values({ + id: 'subsidy-1', + name: 'Green Energy Rebate', + description: 'Rebate for solar installation', + reductionType: 'percentage', + reductionValue: 15.0, + applicationStatus: 'eligible', + createdAt: now, + updatedAt: now, + }); + + const rows = await db + .select() + .from(schema.subsidyPrograms) + .where(eq(schema.subsidyPrograms.id, 'subsidy-1')); + + expect(rows).toHaveLength(1); + expect(rows[0].name).toBe('Green Energy Rebate'); + expect(rows[0].reductionType).toBe('percentage'); + expect(rows[0].reductionValue).toBe(15.0); + expect(rows[0].applicationStatus).toBe('eligible'); // Default + }); + }); + + // ─── subsidy_program_categories junction table ──────────────────────────── + + describe('subsidy_program_categories table', () => { + it('creates subsidy_program_categories table with composite primary key', () => { + const columns = sqlite + .prepare("PRAGMA table_info('subsidy_program_categories')") + .all() as Array<{ + name: string; + type: string; + notnull: number; + pk: number; + }>; + + const columnNames = columns.map((col) => col.name); + expect(columnNames).toContain('subsidy_program_id'); + expect(columnNames).toContain('budget_category_id'); + + // Both are part of composite PK + const subsidyCol = columns.find((col) => col.name === 'subsidy_program_id'); + expect(subsidyCol?.pk).toBeGreaterThan(0); + + const categoryCol = columns.find((col) => col.name === 'budget_category_id'); + expect(categoryCol?.pk).toBeGreaterThan(0); + }); + + it('links subsidy programs to budget categories', async () => { + const now = new Date().toISOString(); + + await db.insert(schema.budgetCategories).values({ + id: 'cat-a', + name: 'Insulation', + createdAt: now, + updatedAt: now, + }); + + await db.insert(schema.subsidyPrograms).values({ + id: 'prog-a', + name: 'Energy Efficiency Subsidy', + reductionType: 'fixed', + reductionValue: 500, + createdAt: now, + updatedAt: now, + }); + + await db.insert(schema.subsidyProgramCategories).values({ + subsidyProgramId: 'prog-a', + budgetCategoryId: 'cat-a', + }); + + const rows = await db.select().from(schema.subsidyProgramCategories); + expect(rows).toHaveLength(1); + expect(rows[0].subsidyProgramId).toBe('prog-a'); + expect(rows[0].budgetCategoryId).toBe('cat-a'); + }); + + it('CASCADE deletes junction rows when budget_category is deleted', async () => { + const now = new Date().toISOString(); + + await db.insert(schema.budgetCategories).values({ + id: 'cat-cascade', + name: 'Cascade Category', + createdAt: now, + updatedAt: now, + }); + + await db.insert(schema.subsidyPrograms).values({ + id: 'prog-cascade', + name: 'Cascade Program', + reductionType: 'percentage', + reductionValue: 5, + createdAt: now, + updatedAt: now, + }); + + await db.insert(schema.subsidyProgramCategories).values({ + subsidyProgramId: 'prog-cascade', + budgetCategoryId: 'cat-cascade', + }); + + // Verify it exists + const before = await db.select().from(schema.subsidyProgramCategories); + expect(before).toHaveLength(1); + + // Delete the category + await db.delete(schema.budgetCategories).where(eq(schema.budgetCategories.id, 'cat-cascade')); + + // Junction row should be gone + const after = await db.select().from(schema.subsidyProgramCategories); + expect(after).toHaveLength(0); + }); + + it('CASCADE deletes junction rows when subsidy_program is deleted', async () => { + const now = new Date().toISOString(); + + await db.insert(schema.budgetCategories).values({ + id: 'cat-b', + name: 'Another Category', + createdAt: now, + updatedAt: now, + }); + + await db.insert(schema.subsidyPrograms).values({ + id: 'prog-b', + name: 'Another Program', + reductionType: 'percentage', + reductionValue: 5, + createdAt: now, + updatedAt: now, + }); + + await db.insert(schema.subsidyProgramCategories).values({ + subsidyProgramId: 'prog-b', + budgetCategoryId: 'cat-b', + }); + + // Delete the subsidy program + await db.delete(schema.subsidyPrograms).where(eq(schema.subsidyPrograms.id, 'prog-b')); + + // Junction row should be gone + const after = await db.select().from(schema.subsidyProgramCategories); + expect(after).toHaveLength(0); + }); + }); + + // ─── work_item_vendors junction table ──────────────────────────────────── + + describe('work_item_vendors table', () => { + it('creates work_item_vendors table with composite primary key', () => { + const columns = sqlite.prepare("PRAGMA table_info('work_item_vendors')").all() as Array<{ + name: string; + type: string; + notnull: number; + pk: number; + }>; + + const columnNames = columns.map((col) => col.name); + expect(columnNames).toContain('work_item_id'); + expect(columnNames).toContain('vendor_id'); + }); + + it('creates idx_work_item_vendors_vendor_id index', () => { + const indexes = sqlite + .prepare( + "SELECT name FROM sqlite_master WHERE type='index' AND tbl_name='work_item_vendors'", + ) + .all() as Array<{ name: string }>; + + const indexNames = indexes.map((idx) => idx.name); + expect(indexNames).toContain('idx_work_item_vendors_vendor_id'); + }); + }); + + // ─── work_item_subsidies junction table ────────────────────────────────── + + describe('work_item_subsidies table', () => { + it('creates work_item_subsidies table with composite primary key', () => { + const columns = sqlite.prepare("PRAGMA table_info('work_item_subsidies')").all() as Array<{ + name: string; + type: string; + notnull: number; + pk: number; + }>; + + const columnNames = columns.map((col) => col.name); + expect(columnNames).toContain('work_item_id'); + expect(columnNames).toContain('subsidy_program_id'); + }); + + it('creates idx_work_item_subsidies_subsidy_program_id index', () => { + const indexes = sqlite + .prepare( + "SELECT name FROM sqlite_master WHERE type='index' AND tbl_name='work_item_subsidies'", + ) + .all() as Array<{ name: string }>; + + const indexNames = indexes.map((idx) => idx.name); + expect(indexNames).toContain('idx_work_item_subsidies_subsidy_program_id'); + }); + }); +}); diff --git a/server/src/db/schema.ts b/server/src/db/schema.ts index e210921fb..31d1adf3d 100644 --- a/server/src/db/schema.ts +++ b/server/src/db/schema.ts @@ -10,6 +10,7 @@ import { sqliteTable, text, integer, + real, index, uniqueIndex, primaryKey, @@ -194,3 +195,171 @@ export const workItemDependencies = sqliteTable( successorIdIdx: index('idx_work_item_dependencies_successor_id').on(table.successorId), }), ); + +// ─── EPIC-05: Budget Management ─────────────────────────────────────────────── + +/** + * Budget categories table - organizes construction costs into categories. + * Pre-seeded with 10 default categories; users can add more. + */ +export const budgetCategories = sqliteTable('budget_categories', { + id: text('id').primaryKey(), + name: text('name').unique().notNull(), + description: text('description'), + color: text('color'), + sortOrder: integer('sort_order').notNull().default(0), + createdAt: text('created_at').notNull(), + updatedAt: text('updated_at').notNull(), +}); + +/** + * Vendors table - tracks contractors and vendors involved in the project. + */ +export const vendors = sqliteTable( + 'vendors', + { + id: text('id').primaryKey(), + name: text('name').notNull(), + specialty: text('specialty'), + phone: text('phone'), + email: text('email'), + address: text('address'), + notes: text('notes'), + createdBy: text('created_by').references(() => users.id, { onDelete: 'set null' }), + createdAt: text('created_at').notNull(), + updatedAt: text('updated_at').notNull(), + }, + (table) => ({ + nameIdx: index('idx_vendors_name').on(table.name), + }), +); + +/** + * Invoices table - tracks vendor invoices for payment management. + */ +export const invoices = sqliteTable( + 'invoices', + { + id: text('id').primaryKey(), + vendorId: text('vendor_id') + .notNull() + .references(() => vendors.id, { onDelete: 'cascade' }), + invoiceNumber: text('invoice_number'), + amount: real('amount').notNull(), + date: text('date').notNull(), + dueDate: text('due_date'), + status: text('status', { enum: ['pending', 'paid', 'overdue'] }) + .notNull() + .default('pending'), + notes: text('notes'), + createdBy: text('created_by').references(() => users.id, { onDelete: 'set null' }), + createdAt: text('created_at').notNull(), + updatedAt: text('updated_at').notNull(), + }, + (table) => ({ + vendorIdIdx: index('idx_invoices_vendor_id').on(table.vendorId), + statusIdx: index('idx_invoices_status').on(table.status), + dateIdx: index('idx_invoices_date').on(table.date), + }), +); + +/** + * Budget sources table - financing sources (bank loans, credit lines, savings, etc.). + */ +export const budgetSources = sqliteTable('budget_sources', { + id: text('id').primaryKey(), + name: text('name').notNull(), + sourceType: text('source_type', { + enum: ['bank_loan', 'credit_line', 'savings', 'other'], + }).notNull(), + totalAmount: real('total_amount').notNull(), + interestRate: real('interest_rate'), + terms: text('terms'), + notes: text('notes'), + status: text('status', { enum: ['active', 'exhausted', 'closed'] }) + .notNull() + .default('active'), + createdBy: text('created_by').references(() => users.id, { onDelete: 'set null' }), + createdAt: text('created_at').notNull(), + updatedAt: text('updated_at').notNull(), +}); + +/** + * Subsidy programs table - government/institutional programs reducing construction costs. + */ +export const subsidyPrograms = sqliteTable('subsidy_programs', { + id: text('id').primaryKey(), + name: text('name').notNull(), + description: text('description'), + eligibility: text('eligibility'), + reductionType: text('reduction_type', { enum: ['percentage', 'fixed'] }).notNull(), + reductionValue: real('reduction_value').notNull(), + applicationStatus: text('application_status', { + enum: ['eligible', 'applied', 'approved', 'received', 'rejected'], + }) + .notNull() + .default('eligible'), + applicationDeadline: text('application_deadline'), + notes: text('notes'), + createdBy: text('created_by').references(() => users.id, { onDelete: 'set null' }), + createdAt: text('created_at').notNull(), + updatedAt: text('updated_at').notNull(), +}); + +/** + * Subsidy program categories junction table - links subsidy programs to budget categories (M:N). + */ +export const subsidyProgramCategories = sqliteTable( + 'subsidy_program_categories', + { + subsidyProgramId: text('subsidy_program_id') + .notNull() + .references(() => subsidyPrograms.id, { onDelete: 'cascade' }), + budgetCategoryId: text('budget_category_id') + .notNull() + .references(() => budgetCategories.id, { onDelete: 'cascade' }), + }, + (table) => ({ + pk: primaryKey({ columns: [table.subsidyProgramId, table.budgetCategoryId] }), + }), +); + +/** + * Work item vendors junction table - links work items to vendors (M:N). + */ +export const workItemVendors = sqliteTable( + 'work_item_vendors', + { + workItemId: text('work_item_id') + .notNull() + .references(() => workItems.id, { onDelete: 'cascade' }), + vendorId: text('vendor_id') + .notNull() + .references(() => vendors.id, { onDelete: 'cascade' }), + }, + (table) => ({ + pk: primaryKey({ columns: [table.workItemId, table.vendorId] }), + vendorIdIdx: index('idx_work_item_vendors_vendor_id').on(table.vendorId), + }), +); + +/** + * Work item subsidies junction table - links work items to subsidy programs (M:N). + */ +export const workItemSubsidies = sqliteTable( + 'work_item_subsidies', + { + workItemId: text('work_item_id') + .notNull() + .references(() => workItems.id, { onDelete: 'cascade' }), + subsidyProgramId: text('subsidy_program_id') + .notNull() + .references(() => subsidyPrograms.id, { onDelete: 'cascade' }), + }, + (table) => ({ + pk: primaryKey({ columns: [table.workItemId, table.subsidyProgramId] }), + subsidyProgramIdIdx: index('idx_work_item_subsidies_subsidy_program_id').on( + table.subsidyProgramId, + ), + }), +); diff --git a/server/src/errors/AppError.ts b/server/src/errors/AppError.ts index 805fff949..11f4d671b 100644 --- a/server/src/errors/AppError.ts +++ b/server/src/errors/AppError.ts @@ -57,3 +57,13 @@ export class ConflictError extends AppError { this.name = 'ConflictError'; } } + +export class CategoryInUseError extends AppError { + constructor( + message = 'Budget category is in use and cannot be deleted', + details?: Record, + ) { + super('CATEGORY_IN_USE', 409, message, details); + this.name = 'CategoryInUseError'; + } +} diff --git a/server/src/routes/budgetCategories.test.ts b/server/src/routes/budgetCategories.test.ts new file mode 100644 index 000000000..bd4095d2b --- /dev/null +++ b/server/src/routes/budgetCategories.test.ts @@ -0,0 +1,797 @@ +import { describe, it, expect, beforeEach, afterEach } from '@jest/globals'; +import { mkdtempSync, rmSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { buildApp } from '../app.js'; +import * as userService from '../services/userService.js'; +import * as sessionService from '../services/sessionService.js'; +import type { FastifyInstance } from 'fastify'; +import type { + BudgetCategory, + BudgetCategoryListResponse, + ApiErrorResponse, + CreateBudgetCategoryRequest, +} from '@cornerstone/shared'; +import { budgetCategories, subsidyPrograms, subsidyProgramCategories } from '../db/schema.js'; + +/** + * NOTE: The migration seeds 10 default budget categories: + * Materials, Labor, Permits, Design, Equipment, Landscaping, + * Utilities, Insurance, Contingency, Other. + * + * Tests that insert categories use unique names like "Custom *" to avoid conflicts. + * Tests that check empty/count behavior account for the 10 seeded records. + */ + +describe('Budget Category Routes', () => { + let app: FastifyInstance; + let tempDir: string; + let originalEnv: NodeJS.ProcessEnv; + + /** Number of categories seeded by migration */ + const SEEDED_CATEGORY_COUNT = 10; + + beforeEach(async () => { + originalEnv = { ...process.env }; + + // Create temporary directory for test database + tempDir = mkdtempSync(join(tmpdir(), 'cornerstone-budget-categories-test-')); + process.env.DATABASE_URL = join(tempDir, 'test.db'); + process.env.SECURE_COOKIES = 'false'; + + app = await buildApp(); + }); + + afterEach(async () => { + if (app) { + await app.close(); + } + + process.env = originalEnv; + + try { + rmSync(tempDir, { recursive: true, force: true }); + } catch { + // Ignore cleanup errors + } + }); + + /** + * Helper: Create a user and return a session cookie. + */ + async function createUserWithSession( + email: string, + displayName: string, + password: string, + role: 'admin' | 'member' = 'member', + ): Promise<{ userId: string; cookie: string }> { + const user = await userService.createLocalUser(app.db, email, displayName, password, role); + const sessionToken = sessionService.createSession(app.db, user.id, 3600); + return { + userId: user.id, + cookie: `cornerstone_session=${sessionToken}`, + }; + } + + /** + * Helper: Create a budget category directly in the database. + * Uses "Custom " prefix to avoid conflicts with seeded categories. + */ + function createTestCategory( + name: string, + options: { + description?: string | null; + color?: string | null; + sortOrder?: number; + } = {}, + ) { + const id = `cat-${Date.now()}-${Math.random().toString(36).substring(7)}`; + const now = new Date().toISOString(); + + app.db + .insert(budgetCategories) + .values({ + id, + name, + description: options.description ?? null, + color: options.color ?? null, + sortOrder: options.sortOrder ?? 0, + createdAt: now, + updatedAt: now, + }) + .run(); + + return { id, name, ...options, createdAt: now, updatedAt: now }; + } + + /** + * Helper: Create a subsidy program referencing a budget category. + */ + function createSubsidyProgramReferencing(categoryId: string) { + const programId = `prog-${Date.now()}-${Math.random().toString(36).substring(7)}`; + const now = new Date().toISOString(); + + app.db + .insert(subsidyPrograms) + .values({ + id: programId, + name: `Test Subsidy ${programId}`, // Unique name + reductionType: 'percentage', + reductionValue: 10, + createdAt: now, + updatedAt: now, + }) + .run(); + + app.db + .insert(subsidyProgramCategories) + .values({ + subsidyProgramId: programId, + budgetCategoryId: categoryId, + }) + .run(); + + return programId; + } + + // ─── GET /api/budget-categories ─────────────────────────────────────────── + + describe('GET /api/budget-categories', () => { + it('returns the 10 seeded default categories after migration', async () => { + const { cookie } = await createUserWithSession('user@example.com', 'Test User', 'password'); + + const response = await app.inject({ + method: 'GET', + url: '/api/budget-categories', + headers: { cookie }, + }); + + expect(response.statusCode).toBe(200); + const body = response.json(); + expect(body.categories).toHaveLength(SEEDED_CATEGORY_COUNT); + }); + + it('returns categories sorted by sortOrder ascending', async () => { + const { cookie } = await createUserWithSession('user@example.com', 'Test User', 'password'); + // Add 3 custom categories with high sort orders to verify ordering + createTestCategory('Custom Zeta', { sortOrder: 103 }); + createTestCategory('Custom Alpha', { sortOrder: 101 }); + createTestCategory('Custom Beta', { sortOrder: 102 }); + + const response = await app.inject({ + method: 'GET', + url: '/api/budget-categories', + headers: { cookie }, + }); + + expect(response.statusCode).toBe(200); + const body = response.json(); + + // Our custom categories should be at the end in sort order + const customCats = body.categories.filter((c) => c.name.startsWith('Custom ')); + expect(customCats).toHaveLength(3); + expect(customCats[0].name).toBe('Custom Alpha'); + expect(customCats[1].name).toBe('Custom Beta'); + expect(customCats[2].name).toBe('Custom Zeta'); + }); + + it('returns all category fields including description, color, and sortOrder', async () => { + const { cookie } = await createUserWithSession('user@example.com', 'Test User', 'password'); + const cat = createTestCategory('Custom Roofing', { + description: 'Roof and waterproofing costs', + color: '#FF5733', + sortOrder: 99, + }); + + const response = await app.inject({ + method: 'GET', + url: '/api/budget-categories', + headers: { cookie }, + }); + + expect(response.statusCode).toBe(200); + const body = response.json(); + const found = body.categories.find((c) => c.id === cat.id); + expect(found).toBeDefined(); + expect(found!.name).toBe('Custom Roofing'); + expect(found!.description).toBe('Roof and waterproofing costs'); + expect(found!.color).toBe('#FF5733'); + expect(found!.sortOrder).toBe(99); + }); + + it('returns 401 without authentication', async () => { + const response = await app.inject({ + method: 'GET', + url: '/api/budget-categories', + }); + + expect(response.statusCode).toBe(401); + const body = response.json(); + expect(body.error.code).toBe('UNAUTHORIZED'); + }); + + it('allows member user to list categories', async () => { + const { cookie } = await createUserWithSession( + 'member@example.com', + 'Member User', + 'password', + 'member', + ); + + const response = await app.inject({ + method: 'GET', + url: '/api/budget-categories', + headers: { cookie }, + }); + + expect(response.statusCode).toBe(200); + const body = response.json(); + expect(body.categories.length).toBeGreaterThanOrEqual(SEEDED_CATEGORY_COUNT); + }); + }); + + // ─── POST /api/budget-categories ────────────────────────────────────────── + + describe('POST /api/budget-categories', () => { + it('creates a category with name only (201)', async () => { + const { cookie } = await createUserWithSession('user@example.com', 'Test User', 'password'); + + const requestBody: CreateBudgetCategoryRequest = { name: 'Custom Masonry' }; + + const response = await app.inject({ + method: 'POST', + url: '/api/budget-categories', + headers: { cookie }, + payload: requestBody, + }); + + expect(response.statusCode).toBe(201); + const body = response.json(); + expect(body.id).toBeDefined(); + expect(body.name).toBe('Custom Masonry'); + expect(body.description).toBeNull(); + expect(body.color).toBeNull(); + expect(body.sortOrder).toBe(0); + expect(body.createdAt).toBeDefined(); + expect(body.updatedAt).toBeDefined(); + }); + + it('creates a category with all fields (201)', async () => { + const { cookie } = await createUserWithSession('user@example.com', 'Test User', 'password'); + + const requestBody: CreateBudgetCategoryRequest = { + name: 'Custom Foundation', + description: 'Foundation and concrete costs', + color: '#3B82F6', + sortOrder: 5, + }; + + const response = await app.inject({ + method: 'POST', + url: '/api/budget-categories', + headers: { cookie }, + payload: requestBody, + }); + + expect(response.statusCode).toBe(201); + const body = response.json(); + expect(body.name).toBe('Custom Foundation'); + expect(body.description).toBe('Foundation and concrete costs'); + expect(body.color).toBe('#3B82F6'); + expect(body.sortOrder).toBe(5); + }); + + it('trims name whitespace on creation', async () => { + const { cookie } = await createUserWithSession('user@example.com', 'Test User', 'password'); + + const response = await app.inject({ + method: 'POST', + url: '/api/budget-categories', + headers: { cookie }, + payload: { name: ' Custom Tiling ' }, + }); + + expect(response.statusCode).toBe(201); + const body = response.json(); + expect(body.name).toBe('Custom Tiling'); + }); + + it('returns 400 VALIDATION_ERROR for missing name', async () => { + const { cookie } = await createUserWithSession('user@example.com', 'Test User', 'password'); + + const response = await app.inject({ + method: 'POST', + url: '/api/budget-categories', + headers: { cookie }, + payload: { description: 'No name provided' }, + }); + + expect(response.statusCode).toBe(400); + const body = response.json(); + expect(body.error.code).toBe('VALIDATION_ERROR'); + }); + + it('returns 400 VALIDATION_ERROR for empty name string', async () => { + const { cookie } = await createUserWithSession('user@example.com', 'Test User', 'password'); + + const response = await app.inject({ + method: 'POST', + url: '/api/budget-categories', + headers: { cookie }, + payload: { name: '' }, + }); + + expect(response.statusCode).toBe(400); + const body = response.json(); + expect(body.error.code).toBe('VALIDATION_ERROR'); + }); + + it('returns 400 VALIDATION_ERROR for invalid color format', async () => { + const { cookie } = await createUserWithSession('user@example.com', 'Test User', 'password'); + + const response = await app.inject({ + method: 'POST', + url: '/api/budget-categories', + headers: { cookie }, + payload: { name: 'Custom Glazing', color: 'blue' }, + }); + + expect(response.statusCode).toBe(400); + const body = response.json(); + expect(body.error.code).toBe('VALIDATION_ERROR'); + }); + + it('returns 409 CONFLICT for duplicate of a seeded category name', async () => { + const { cookie } = await createUserWithSession('user@example.com', 'Test User', 'password'); + + // 'Materials' is a seeded category + const response = await app.inject({ + method: 'POST', + url: '/api/budget-categories', + headers: { cookie }, + payload: { name: 'Materials' }, + }); + + expect(response.statusCode).toBe(409); + const body = response.json(); + expect(body.error.code).toBe('CONFLICT'); + expect(body.error.message).toContain('already exists'); + }); + + it('returns 409 CONFLICT for duplicate name (case-insensitive)', async () => { + const { cookie } = await createUserWithSession('user@example.com', 'Test User', 'password'); + + const response = await app.inject({ + method: 'POST', + url: '/api/budget-categories', + headers: { cookie }, + payload: { name: 'MATERIALS' }, + }); + + expect(response.statusCode).toBe(409); + const body = response.json(); + expect(body.error.code).toBe('CONFLICT'); + }); + + it('creates category successfully even when payload has unknown properties (Fastify strips them)', async () => { + // Fastify with additionalProperties: false strips unrecognized fields rather than rejecting. + // The category should be created with only the recognized fields. + const { cookie } = await createUserWithSession('user@example.com', 'Test User', 'password'); + + const response = await app.inject({ + method: 'POST', + url: '/api/budget-categories', + headers: { cookie }, + payload: { name: 'Custom Test', unknownField: 'value' }, + }); + + // Fastify strips extra properties and creates the category + expect(response.statusCode).toBe(201); + const body = response.json(); + expect(body.name).toBe('Custom Test'); + }); + + it('returns 401 without authentication', async () => { + const response = await app.inject({ + method: 'POST', + url: '/api/budget-categories', + payload: { name: 'Custom Test' }, + }); + + expect(response.statusCode).toBe(401); + const body = response.json(); + expect(body.error.code).toBe('UNAUTHORIZED'); + }); + + it('allows member user to create a category', async () => { + const { cookie } = await createUserWithSession( + 'member@example.com', + 'Member User', + 'password', + 'member', + ); + + const response = await app.inject({ + method: 'POST', + url: '/api/budget-categories', + headers: { cookie }, + payload: { name: 'Custom Heating' }, + }); + + expect(response.statusCode).toBe(201); + const body = response.json(); + expect(body.name).toBe('Custom Heating'); + }); + }); + + // ─── GET /api/budget-categories/:id ─────────────────────────────────────── + + describe('GET /api/budget-categories/:id', () => { + it('returns a seeded category by ID', async () => { + const { cookie } = await createUserWithSession('user@example.com', 'Test User', 'password'); + + const response = await app.inject({ + method: 'GET', + url: '/api/budget-categories/bc-materials', + headers: { cookie }, + }); + + expect(response.statusCode).toBe(200); + const body = response.json(); + expect(body.id).toBe('bc-materials'); + expect(body.name).toBe('Materials'); + }); + + it('returns a custom category by ID', async () => { + const { cookie } = await createUserWithSession('user@example.com', 'Test User', 'password'); + const cat = createTestCategory('Custom Cooling', { color: '#FF5733', sortOrder: 1 }); + + const response = await app.inject({ + method: 'GET', + url: `/api/budget-categories/${cat.id}`, + headers: { cookie }, + }); + + expect(response.statusCode).toBe(200); + const body = response.json(); + expect(body.id).toBe(cat.id); + expect(body.name).toBe('Custom Cooling'); + expect(body.color).toBe('#FF5733'); + }); + + it('returns 404 NOT_FOUND for non-existent category', async () => { + const { cookie } = await createUserWithSession('user@example.com', 'Test User', 'password'); + + const response = await app.inject({ + method: 'GET', + url: '/api/budget-categories/non-existent-id', + headers: { cookie }, + }); + + expect(response.statusCode).toBe(404); + const body = response.json(); + expect(body.error.code).toBe('NOT_FOUND'); + }); + + it('returns 401 without authentication', async () => { + const response = await app.inject({ + method: 'GET', + url: '/api/budget-categories/some-id', + }); + + expect(response.statusCode).toBe(401); + const body = response.json(); + expect(body.error.code).toBe('UNAUTHORIZED'); + }); + }); + + // ─── PATCH /api/budget-categories/:id ───────────────────────────────────── + + describe('PATCH /api/budget-categories/:id', () => { + it('updates the name of an existing category', async () => { + const { cookie } = await createUserWithSession('user@example.com', 'Test User', 'password'); + const cat = createTestCategory('Custom Old Name', { color: '#FF0000' }); + + const response = await app.inject({ + method: 'PATCH', + url: `/api/budget-categories/${cat.id}`, + headers: { cookie }, + payload: { name: 'Custom New Name' }, + }); + + expect(response.statusCode).toBe(200); + const body = response.json(); + expect(body.id).toBe(cat.id); + expect(body.name).toBe('Custom New Name'); + expect(body.color).toBe('#FF0000'); // Unchanged + }); + + it('updates description only (partial update)', async () => { + const { cookie } = await createUserWithSession('user@example.com', 'Test User', 'password'); + const cat = createTestCategory('Custom Structural'); + + const response = await app.inject({ + method: 'PATCH', + url: `/api/budget-categories/${cat.id}`, + headers: { cookie }, + payload: { description: 'Structural costs' }, + }); + + expect(response.statusCode).toBe(200); + const body = response.json(); + expect(body.description).toBe('Structural costs'); + expect(body.name).toBe('Custom Structural'); // Unchanged + }); + + it('clears color by setting to null', async () => { + const { cookie } = await createUserWithSession('user@example.com', 'Test User', 'password'); + const cat = createTestCategory('Custom Framing', { color: '#FF0000' }); + + const response = await app.inject({ + method: 'PATCH', + url: `/api/budget-categories/${cat.id}`, + headers: { cookie }, + payload: { color: null }, + }); + + expect(response.statusCode).toBe(200); + const body = response.json(); + expect(body.color).toBeNull(); + }); + + it('updates sortOrder', async () => { + const { cookie } = await createUserWithSession('user@example.com', 'Test User', 'password'); + const cat = createTestCategory('Custom Waterproofing', { sortOrder: 1 }); + + const response = await app.inject({ + method: 'PATCH', + url: `/api/budget-categories/${cat.id}`, + headers: { cookie }, + payload: { sortOrder: 99 }, + }); + + expect(response.statusCode).toBe(200); + const body = response.json(); + expect(body.sortOrder).toBe(99); + }); + + it('allows updating name to the same value (no conflict)', async () => { + const { cookie } = await createUserWithSession('user@example.com', 'Test User', 'password'); + const cat = createTestCategory('Custom Scaffolding'); + + const response = await app.inject({ + method: 'PATCH', + url: `/api/budget-categories/${cat.id}`, + headers: { cookie }, + payload: { name: 'Custom Scaffolding' }, + }); + + expect(response.statusCode).toBe(200); + const body = response.json(); + expect(body.name).toBe('Custom Scaffolding'); + }); + + it('returns 409 CONFLICT when name conflicts with another category (seeded)', async () => { + const { cookie } = await createUserWithSession('user@example.com', 'Test User', 'password'); + const cat = createTestCategory('Custom Crane Rental'); + + const response = await app.inject({ + method: 'PATCH', + url: `/api/budget-categories/${cat.id}`, + headers: { cookie }, + payload: { name: 'Labor' }, // Seeded category name + }); + + expect(response.statusCode).toBe(409); + const body = response.json(); + expect(body.error.code).toBe('CONFLICT'); + }); + + it('returns 404 NOT_FOUND for non-existent category', async () => { + const { cookie } = await createUserWithSession('user@example.com', 'Test User', 'password'); + + const response = await app.inject({ + method: 'PATCH', + url: '/api/budget-categories/non-existent-id', + headers: { cookie }, + payload: { name: 'Updated' }, + }); + + expect(response.statusCode).toBe(404); + const body = response.json(); + expect(body.error.code).toBe('NOT_FOUND'); + }); + + it('returns 400 VALIDATION_ERROR for empty payload (minProperties constraint)', async () => { + const { cookie } = await createUserWithSession('user@example.com', 'Test User', 'password'); + const cat = createTestCategory('Custom Scaffolding B'); + + const response = await app.inject({ + method: 'PATCH', + url: `/api/budget-categories/${cat.id}`, + headers: { cookie }, + payload: {}, + }); + + expect(response.statusCode).toBe(400); + const body = response.json(); + expect(body.error.code).toBe('VALIDATION_ERROR'); + }); + + it('returns 400 for invalid color in PATCH', async () => { + const { cookie } = await createUserWithSession('user@example.com', 'Test User', 'password'); + const cat = createTestCategory('Custom Scaffolding C'); + + const response = await app.inject({ + method: 'PATCH', + url: `/api/budget-categories/${cat.id}`, + headers: { cookie }, + payload: { color: 'not-valid' }, + }); + + expect(response.statusCode).toBe(400); + const body = response.json(); + expect(body.error.code).toBe('VALIDATION_ERROR'); + }); + + it('returns 401 without authentication', async () => { + const response = await app.inject({ + method: 'PATCH', + url: '/api/budget-categories/some-id', + payload: { name: 'Updated' }, + }); + + expect(response.statusCode).toBe(401); + const body = response.json(); + expect(body.error.code).toBe('UNAUTHORIZED'); + }); + + it('allows member user to update a category', async () => { + const { cookie } = await createUserWithSession( + 'member@example.com', + 'Member User', + 'password', + 'member', + ); + const cat = createTestCategory('Custom Joinery'); + + const response = await app.inject({ + method: 'PATCH', + url: `/api/budget-categories/${cat.id}`, + headers: { cookie }, + payload: { name: 'Custom Cabinet' }, + }); + + expect(response.statusCode).toBe(200); + const body = response.json(); + expect(body.name).toBe('Custom Cabinet'); + }); + }); + + // ─── DELETE /api/budget-categories/:id ──────────────────────────────────── + + describe('DELETE /api/budget-categories/:id', () => { + it('deletes a custom category successfully (204)', async () => { + const { cookie } = await createUserWithSession('user@example.com', 'Test User', 'password'); + const cat = createTestCategory('Custom Slab'); + + const response = await app.inject({ + method: 'DELETE', + url: `/api/budget-categories/${cat.id}`, + headers: { cookie }, + }); + + expect(response.statusCode).toBe(204); + expect(response.body).toBe(''); + }); + + it('category is no longer returned in list after deletion', async () => { + const { cookie } = await createUserWithSession('user@example.com', 'Test User', 'password'); + const cat = createTestCategory('Custom Beam'); + + await app.inject({ + method: 'DELETE', + url: `/api/budget-categories/${cat.id}`, + headers: { cookie }, + }); + + const listResponse = await app.inject({ + method: 'GET', + url: '/api/budget-categories', + headers: { cookie }, + }); + + const body = listResponse.json(); + expect(body.categories.find((c) => c.id === cat.id)).toBeUndefined(); + }); + + it('can delete a seeded category that is not referenced', async () => { + // 'bc-other' is a seeded category not referenced by any subsidy program + const { cookie } = await createUserWithSession('user@example.com', 'Test User', 'password'); + + const response = await app.inject({ + method: 'DELETE', + url: '/api/budget-categories/bc-other', + headers: { cookie }, + }); + + expect(response.statusCode).toBe(204); + }); + + it('returns 404 NOT_FOUND for non-existent category', async () => { + const { cookie } = await createUserWithSession('user@example.com', 'Test User', 'password'); + + const response = await app.inject({ + method: 'DELETE', + url: '/api/budget-categories/non-existent-id', + headers: { cookie }, + }); + + expect(response.statusCode).toBe(404); + const body = response.json(); + expect(body.error.code).toBe('NOT_FOUND'); + }); + + it('returns 409 CATEGORY_IN_USE when category is referenced by a subsidy program', async () => { + const { cookie } = await createUserWithSession('user@example.com', 'Test User', 'password'); + const cat = createTestCategory('Custom Insulation'); + createSubsidyProgramReferencing(cat.id); + + const response = await app.inject({ + method: 'DELETE', + url: `/api/budget-categories/${cat.id}`, + headers: { cookie }, + }); + + expect(response.statusCode).toBe(409); + const body = response.json(); + expect(body.error.code).toBe('CATEGORY_IN_USE'); + }); + + it('returns error details with subsidyProgramCount when category is in use', async () => { + const { cookie } = await createUserWithSession('user@example.com', 'Test User', 'password'); + const cat = createTestCategory('Custom Ventilation'); + createSubsidyProgramReferencing(cat.id); + + const response = await app.inject({ + method: 'DELETE', + url: `/api/budget-categories/${cat.id}`, + headers: { cookie }, + }); + + expect(response.statusCode).toBe(409); + const body = response.json(); + expect(body.error.details).toBeDefined(); + expect(body.error.details?.subsidyProgramCount).toBe(1); + }); + + it('returns 401 without authentication', async () => { + const response = await app.inject({ + method: 'DELETE', + url: '/api/budget-categories/some-id', + }); + + expect(response.statusCode).toBe(401); + const body = response.json(); + expect(body.error.code).toBe('UNAUTHORIZED'); + }); + + it('allows member user to delete a category', async () => { + const { cookie } = await createUserWithSession( + 'member@example.com', + 'Member User', + 'password', + 'member', + ); + const cat = createTestCategory('Custom Scaffold'); + + const response = await app.inject({ + method: 'DELETE', + url: `/api/budget-categories/${cat.id}`, + headers: { cookie }, + }); + + expect(response.statusCode).toBe(204); + }); + }); +}); diff --git a/server/src/routes/budgetCategories.ts b/server/src/routes/budgetCategories.ts new file mode 100644 index 000000000..523a4aea5 --- /dev/null +++ b/server/src/routes/budgetCategories.ts @@ -0,0 +1,145 @@ +import type { FastifyInstance } from 'fastify'; +import { UnauthorizedError } from '../errors/AppError.js'; +import * as budgetCategoryService from '../services/budgetCategoryService.js'; +import type { CreateBudgetCategoryRequest, UpdateBudgetCategoryRequest } from '@cornerstone/shared'; + +// JSON schema for POST /api/budget-categories (create category) +const createBudgetCategorySchema = { + body: { + type: 'object', + required: ['name'], + properties: { + name: { type: 'string', minLength: 1, maxLength: 100 }, + description: { type: ['string', 'null'], maxLength: 500 }, + color: { type: ['string', 'null'], pattern: '^#[0-9A-Fa-f]{6}$' }, + sortOrder: { type: 'integer', minimum: 0 }, + }, + additionalProperties: false, + }, +}; + +// JSON schema for PATCH /api/budget-categories/:id (update category) +const updateBudgetCategorySchema = { + body: { + type: 'object', + properties: { + name: { type: 'string', minLength: 1, maxLength: 100 }, + description: { type: ['string', 'null'], maxLength: 500 }, + color: { type: ['string', 'null'], pattern: '^#[0-9A-Fa-f]{6}$' }, + sortOrder: { type: 'integer', minimum: 0 }, + }, + additionalProperties: false, + minProperties: 1, + }, + params: { + type: 'object', + required: ['id'], + properties: { + id: { type: 'string' }, + }, + }, +}; + +// JSON schema for path parameter validation (GET by ID / DELETE) +const categoryIdSchema = { + params: { + type: 'object', + required: ['id'], + properties: { + id: { type: 'string' }, + }, + }, +}; + +export default async function budgetCategoryRoutes(fastify: FastifyInstance) { + /** + * GET /api/budget-categories + * List all budget categories, sorted by sort_order ascending. + * Auth required: Yes (both admin and member) + */ + fastify.get('/', async (request, reply) => { + if (!request.user) { + throw new UnauthorizedError(); + } + + const categories = budgetCategoryService.listBudgetCategories(fastify.db); + return reply.status(200).send({ categories }); + }); + + /** + * POST /api/budget-categories + * Create a new budget category. + * Auth required: Yes (both admin and member) + */ + fastify.post<{ Body: CreateBudgetCategoryRequest }>( + '/', + { schema: createBudgetCategorySchema }, + async (request, reply) => { + if (!request.user) { + throw new UnauthorizedError(); + } + + const category = budgetCategoryService.createBudgetCategory(fastify.db, request.body); + return reply.status(201).send(category); + }, + ); + + /** + * GET /api/budget-categories/:id + * Get a single budget category by ID. + * Auth required: Yes (both admin and member) + */ + fastify.get<{ Params: { id: string } }>( + '/:id', + { schema: categoryIdSchema }, + async (request, reply) => { + if (!request.user) { + throw new UnauthorizedError(); + } + + const category = budgetCategoryService.getBudgetCategoryById(fastify.db, request.params.id); + return reply.status(200).send(category); + }, + ); + + /** + * PATCH /api/budget-categories/:id + * Update a budget category's name, description, color, and/or sort order. + * Auth required: Yes (both admin and member) + */ + fastify.patch<{ Params: { id: string }; Body: UpdateBudgetCategoryRequest }>( + '/:id', + { schema: updateBudgetCategorySchema }, + async (request, reply) => { + if (!request.user) { + throw new UnauthorizedError(); + } + + const category = budgetCategoryService.updateBudgetCategory( + fastify.db, + request.params.id, + request.body, + ); + return reply.status(200).send(category); + }, + ); + + /** + * DELETE /api/budget-categories/:id + * Delete a budget category. + * Fails with 409 CATEGORY_IN_USE if referenced by work items or subsidy programs. + * Auth required: Yes (both admin and member) + */ + fastify.delete<{ Params: { id: string } }>( + '/:id', + { schema: categoryIdSchema }, + async (request, reply) => { + if (!request.user) { + throw new UnauthorizedError(); + } + + budgetCategoryService.deleteBudgetCategory(fastify.db, request.params.id); + return reply.status(204).send(); + }, + ); +} diff --git a/server/src/services/budgetCategoryService.test.ts b/server/src/services/budgetCategoryService.test.ts new file mode 100644 index 000000000..978f0474e --- /dev/null +++ b/server/src/services/budgetCategoryService.test.ts @@ -0,0 +1,806 @@ +import { describe, it, expect, beforeEach, afterEach } from '@jest/globals'; +import Database from 'better-sqlite3'; +import { drizzle } from 'drizzle-orm/better-sqlite3'; +import type { BetterSQLite3Database } from 'drizzle-orm/better-sqlite3'; +import { eq } from 'drizzle-orm'; +import { runMigrations } from '../db/migrate.js'; +import * as schema from '../db/schema.js'; +import * as budgetCategoryService from './budgetCategoryService.js'; +import { + NotFoundError, + ValidationError, + ConflictError, + CategoryInUseError, +} from '../errors/AppError.js'; +import type { CreateBudgetCategoryRequest, UpdateBudgetCategoryRequest } from '@cornerstone/shared'; + +/** + * NOTE: The migration seeds 10 default budget categories: + * Materials, Labor, Permits, Design, Equipment, Landscaping, + * Utilities, Insurance, Contingency, Other. + * + * Tests use distinct names to avoid UNIQUE constraint violations. + * Test-specific names use a prefix like "Test Cat" or "Custom Cat" + * to avoid collisions with the seeded defaults. + */ + +describe('Budget Category Service', () => { + let sqlite: Database.Database; + let db: BetterSQLite3Database; + + /** Number of categories seeded by migration */ + const SEEDED_CATEGORY_COUNT = 10; + + /** + * Creates a fresh in-memory database with migrations applied. + */ + function createTestDb() { + const sqliteDb = new Database(':memory:'); + sqliteDb.pragma('journal_mode = WAL'); + sqliteDb.pragma('foreign_keys = ON'); + runMigrations(sqliteDb); + return { sqlite: sqliteDb, db: drizzle(sqliteDb, { schema }) }; + } + + let categoryTimestampOffset = 0; + + /** + * Helper: Create a test budget category directly in the database. + * Uses unique names to avoid conflicts with migration-seeded categories. + */ + function createTestCategory( + name: string, + options: { + description?: string | null; + color?: string | null; + sortOrder?: number; + } = {}, + ) { + const id = `cat-${Date.now()}-${Math.random().toString(36).substring(7)}`; + const timestamp = new Date(Date.now() + categoryTimestampOffset).toISOString(); + categoryTimestampOffset += 1; + + db.insert(schema.budgetCategories) + .values({ + id, + name, + description: options.description ?? null, + color: options.color ?? null, + sortOrder: options.sortOrder ?? 0, + createdAt: timestamp, + updatedAt: timestamp, + }) + .run(); + + return { id, name, ...options, createdAt: timestamp, updatedAt: timestamp }; + } + + /** + * Helper: Create a subsidy program that references a budget category. + */ + function createSubsidyProgramReferencing(categoryId: string) { + const programId = `prog-${Date.now()}-${Math.random().toString(36).substring(7)}`; + const now = new Date().toISOString(); + + db.insert(schema.subsidyPrograms) + .values({ + id: programId, + name: `Program ${programId}`, // Unique name + reductionType: 'percentage', + reductionValue: 10, + createdAt: now, + updatedAt: now, + }) + .run(); + + db.insert(schema.subsidyProgramCategories) + .values({ + subsidyProgramId: programId, + budgetCategoryId: categoryId, + }) + .run(); + + return programId; + } + + beforeEach(() => { + const testDb = createTestDb(); + sqlite = testDb.sqlite; + db = testDb.db; + categoryTimestampOffset = 0; + }); + + afterEach(() => { + sqlite.close(); + }); + + // ─── listBudgetCategories() ──────────────────────────────────────────────── + + describe('listBudgetCategories()', () => { + it('returns the 10 seeded default categories after migration', () => { + // Migration seeds 10 default categories + const result = budgetCategoryService.listBudgetCategories(db); + expect(result).toHaveLength(SEEDED_CATEGORY_COUNT); + }); + + it('returns categories sorted by sortOrder ascending', () => { + // The seeded categories are already sorted 0-9. + // Add custom ones with specific sort orders to verify ordering. + createTestCategory('Test Cat Alpha', { sortOrder: 100 }); + createTestCategory('Test Cat Gamma', { sortOrder: 102 }); + createTestCategory('Test Cat Beta', { sortOrder: 101 }); + + const result = budgetCategoryService.listBudgetCategories(db); + + // Find our test categories in the sorted list + const testCats = result.filter((c) => c.name.startsWith('Test Cat')); + expect(testCats).toHaveLength(3); + expect(testCats[0].name).toBe('Test Cat Alpha'); + expect(testCats[1].name).toBe('Test Cat Beta'); + expect(testCats[2].name).toBe('Test Cat Gamma'); + }); + + it('includes newly created categories in the result', () => { + const countBefore = budgetCategoryService.listBudgetCategories(db).length; + + createTestCategory('Custom Insulation'); + + const result = budgetCategoryService.listBudgetCategories(db); + expect(result).toHaveLength(countBefore + 1); + }); + + it('returns all category properties', () => { + const cat = createTestCategory('Custom Roofing', { + description: 'Roof and waterproofing costs', + color: '#FF5733', + sortOrder: 99, + }); + + const result = budgetCategoryService.listBudgetCategories(db); + + const found = result.find((c) => c.id === cat.id); + expect(found).toBeDefined(); + expect(found!.name).toBe('Custom Roofing'); + expect(found!.description).toBe('Roof and waterproofing costs'); + expect(found!.color).toBe('#FF5733'); + expect(found!.sortOrder).toBe(99); + expect(found!.createdAt).toBeDefined(); + expect(found!.updatedAt).toBeDefined(); + }); + + it('returns category with null description and null color', () => { + const cat = createTestCategory('Custom Plumbing', { description: null, color: null }); + + const result = budgetCategoryService.listBudgetCategories(db); + + const found = result.find((c) => c.id === cat.id); + expect(found).toBeDefined(); + expect(found!.description).toBeNull(); + expect(found!.color).toBeNull(); + }); + }); + + // ─── getBudgetCategoryById() ─────────────────────────────────────────────── + + describe('getBudgetCategoryById()', () => { + it('returns a seeded category by ID', () => { + // 'Materials' is seeded with id 'bc-materials' + const result = budgetCategoryService.getBudgetCategoryById(db, 'bc-materials'); + + expect(result.id).toBe('bc-materials'); + expect(result.name).toBe('Materials'); + }); + + it('returns a newly created category by ID', () => { + const cat = createTestCategory('Custom Electrical', { color: '#FF5733', sortOrder: 1 }); + + const result = budgetCategoryService.getBudgetCategoryById(db, cat.id); + + expect(result.id).toBe(cat.id); + expect(result.name).toBe('Custom Electrical'); + expect(result.color).toBe('#FF5733'); + expect(result.sortOrder).toBe(1); + }); + + it('throws NotFoundError when category does not exist', () => { + expect(() => { + budgetCategoryService.getBudgetCategoryById(db, 'non-existent-id'); + }).toThrow(NotFoundError); + + expect(() => { + budgetCategoryService.getBudgetCategoryById(db, 'non-existent-id'); + }).toThrow('Budget category not found'); + }); + + it('returns category with null description and color', () => { + const cat = createTestCategory('Custom Windows', { description: null, color: null }); + + const result = budgetCategoryService.getBudgetCategoryById(db, cat.id); + + expect(result.description).toBeNull(); + expect(result.color).toBeNull(); + }); + }); + + // ─── createBudgetCategory() ──────────────────────────────────────────────── + + describe('createBudgetCategory()', () => { + it('creates a category with name only', () => { + const data: CreateBudgetCategoryRequest = { name: 'Custom Masonry' }; + + const result = budgetCategoryService.createBudgetCategory(db, data); + + expect(result.id).toBeDefined(); + expect(result.name).toBe('Custom Masonry'); + expect(result.description).toBeNull(); + expect(result.color).toBeNull(); + expect(result.sortOrder).toBe(0); + expect(result.createdAt).toBeDefined(); + expect(result.updatedAt).toBeDefined(); + }); + + it('creates a category with all fields', () => { + const data: CreateBudgetCategoryRequest = { + name: 'Custom Foundation', + description: 'Foundation and concrete costs', + color: '#3B82F6', + sortOrder: 5, + }; + + const result = budgetCategoryService.createBudgetCategory(db, data); + + expect(result.name).toBe('Custom Foundation'); + expect(result.description).toBe('Foundation and concrete costs'); + expect(result.color).toBe('#3B82F6'); + expect(result.sortOrder).toBe(5); + }); + + it('trims leading and trailing whitespace from name', () => { + const data: CreateBudgetCategoryRequest = { name: ' Custom Tiling ' }; + + const result = budgetCategoryService.createBudgetCategory(db, data); + + expect(result.name).toBe('Custom Tiling'); + }); + + it('creates category with sortOrder of 0 (default)', () => { + const data: CreateBudgetCategoryRequest = { name: 'Custom HVAC', sortOrder: 0 }; + + const result = budgetCategoryService.createBudgetCategory(db, data); + + expect(result.sortOrder).toBe(0); + }); + + it('stores category in the database (persists)', () => { + const data: CreateBudgetCategoryRequest = { name: 'Custom Siding' }; + const created = budgetCategoryService.createBudgetCategory(db, data); + + const fetched = budgetCategoryService.getBudgetCategoryById(db, created.id); + expect(fetched.id).toBe(created.id); + expect(fetched.name).toBe('Custom Siding'); + }); + + it('accepts uppercase hex color', () => { + const data: CreateBudgetCategoryRequest = { name: 'Custom Painting', color: '#FF5733' }; + + const result = budgetCategoryService.createBudgetCategory(db, data); + + expect(result.color).toBe('#FF5733'); + }); + + it('accepts lowercase hex color', () => { + const data: CreateBudgetCategoryRequest = { name: 'Custom Flooring', color: '#ff5733' }; + + const result = budgetCategoryService.createBudgetCategory(db, data); + + expect(result.color).toBe('#ff5733'); + }); + + it('accepts mixed-case hex color', () => { + const data: CreateBudgetCategoryRequest = { name: 'Custom Drywall', color: '#Ff5733' }; + + const result = budgetCategoryService.createBudgetCategory(db, data); + + expect(result.color).toBe('#Ff5733'); + }); + + it('throws ValidationError for empty name', () => { + const data: CreateBudgetCategoryRequest = { name: '' }; + + expect(() => { + budgetCategoryService.createBudgetCategory(db, data); + }).toThrow(ValidationError); + expect(() => { + budgetCategoryService.createBudgetCategory(db, data); + }).toThrow('Budget category name must be between 1 and 100 characters'); + }); + + it('throws ValidationError for whitespace-only name', () => { + const data: CreateBudgetCategoryRequest = { name: ' ' }; + + expect(() => { + budgetCategoryService.createBudgetCategory(db, data); + }).toThrow(ValidationError); + }); + + it('throws ValidationError for name exceeding 100 characters', () => { + const data: CreateBudgetCategoryRequest = { name: 'a'.repeat(101) }; + + expect(() => { + budgetCategoryService.createBudgetCategory(db, data); + }).toThrow(ValidationError); + expect(() => { + budgetCategoryService.createBudgetCategory(db, data); + }).toThrow('Budget category name must be between 1 and 100 characters'); + }); + + it('accepts name with exactly 100 characters', () => { + const name = 'X'.repeat(100); + const data: CreateBudgetCategoryRequest = { name }; + + const result = budgetCategoryService.createBudgetCategory(db, data); + + expect(result.name).toBe(name); + }); + + it('throws ValidationError for description exceeding 500 characters', () => { + const data: CreateBudgetCategoryRequest = { + name: 'Custom Staging', + description: 'a'.repeat(501), + }; + + expect(() => { + budgetCategoryService.createBudgetCategory(db, data); + }).toThrow(ValidationError); + expect(() => { + budgetCategoryService.createBudgetCategory(db, data); + }).toThrow('Budget category description must be at most 500 characters'); + }); + + it('accepts description with exactly 500 characters', () => { + const data: CreateBudgetCategoryRequest = { + name: 'Custom Staging', + description: 'a'.repeat(500), + }; + + const result = budgetCategoryService.createBudgetCategory(db, data); + + expect(result.description).toHaveLength(500); + }); + + it('throws ValidationError for invalid hex color (no hash)', () => { + const data: CreateBudgetCategoryRequest = { name: 'Custom Glazing', color: 'FF5733' }; + + expect(() => { + budgetCategoryService.createBudgetCategory(db, data); + }).toThrow(ValidationError); + expect(() => { + budgetCategoryService.createBudgetCategory(db, data); + }).toThrow('Color must be a hex color code in format #RRGGBB'); + }); + + it('throws ValidationError for invalid hex color (word)', () => { + const data: CreateBudgetCategoryRequest = { name: 'Custom Cladding', color: 'blue' }; + + expect(() => { + budgetCategoryService.createBudgetCategory(db, data); + }).toThrow(ValidationError); + }); + + it('throws ValidationError for 3-digit hex color', () => { + const data: CreateBudgetCategoryRequest = { name: 'Custom Fascia', color: '#FFF' }; + + expect(() => { + budgetCategoryService.createBudgetCategory(db, data); + }).toThrow(ValidationError); + }); + + it('throws ValidationError for negative sortOrder', () => { + const data: CreateBudgetCategoryRequest = { name: 'Custom Grading', sortOrder: -1 }; + + expect(() => { + budgetCategoryService.createBudgetCategory(db, data); + }).toThrow(ValidationError); + expect(() => { + budgetCategoryService.createBudgetCategory(db, data); + }).toThrow('Sort order must be a non-negative integer'); + }); + + it('throws ConflictError for duplicate name (exact match with seeded category)', () => { + // 'Materials' is a seeded default category name + const data: CreateBudgetCategoryRequest = { name: 'Materials' }; + + expect(() => { + budgetCategoryService.createBudgetCategory(db, data); + }).toThrow(ConflictError); + expect(() => { + budgetCategoryService.createBudgetCategory(db, data); + }).toThrow('A budget category with this name already exists'); + }); + + it('throws ConflictError for duplicate name (case-insensitive match with seeded)', () => { + const data: CreateBudgetCategoryRequest = { name: 'MATERIALS' }; + + expect(() => { + budgetCategoryService.createBudgetCategory(db, data); + }).toThrow(ConflictError); + }); + + it('throws ConflictError for duplicate name after trimming', () => { + const data: CreateBudgetCategoryRequest = { name: ' MATERIALS ' }; + + expect(() => { + budgetCategoryService.createBudgetCategory(db, data); + }).toThrow(ConflictError); + }); + + it('throws ConflictError for duplicate name with a newly created category', () => { + createTestCategory('Custom Terrace'); + + const data: CreateBudgetCategoryRequest = { name: 'Custom Terrace' }; + + expect(() => { + budgetCategoryService.createBudgetCategory(db, data); + }).toThrow(ConflictError); + }); + + it('accepts null color without validation error', () => { + const data: CreateBudgetCategoryRequest = { name: 'Custom Gutters', color: null }; + + const result = budgetCategoryService.createBudgetCategory(db, data); + + expect(result.color).toBeNull(); + }); + + it('accepts null description without validation error', () => { + const data: CreateBudgetCategoryRequest = { name: 'Custom Attic', description: null }; + + const result = budgetCategoryService.createBudgetCategory(db, data); + + expect(result.description).toBeNull(); + }); + }); + + // ─── updateBudgetCategory() ──────────────────────────────────────────────── + + describe('updateBudgetCategory()', () => { + it('updates the name of an existing category', () => { + const cat = createTestCategory('Custom Sprinklers'); + + const data: UpdateBudgetCategoryRequest = { name: 'Custom Irrigation' }; + const result = budgetCategoryService.updateBudgetCategory(db, cat.id, data); + + expect(result.id).toBe(cat.id); + expect(result.name).toBe('Custom Irrigation'); + }); + + it('updates only description (partial update)', () => { + const cat = createTestCategory('Custom Fencing', { + color: '#FF0000', + sortOrder: 50, + }); + + const data: UpdateBudgetCategoryRequest = { description: 'Updated fence description' }; + const result = budgetCategoryService.updateBudgetCategory(db, cat.id, data); + + expect(result.name).toBe('Custom Fencing'); + expect(result.description).toBe('Updated fence description'); + expect(result.color).toBe('#FF0000'); + expect(result.sortOrder).toBe(50); + }); + + it('updates only color (partial update)', () => { + const cat = createTestCategory('Custom Barn', { color: '#FF0000' }); + + const data: UpdateBudgetCategoryRequest = { color: '#00FF00' }; + const result = budgetCategoryService.updateBudgetCategory(db, cat.id, data); + + expect(result.name).toBe('Custom Barn'); + expect(result.color).toBe('#00FF00'); + }); + + it('removes color by setting to null', () => { + const cat = createTestCategory('Custom Deck', { color: '#FF0000' }); + + const data: UpdateBudgetCategoryRequest = { color: null }; + const result = budgetCategoryService.updateBudgetCategory(db, cat.id, data); + + expect(result.color).toBeNull(); + }); + + it('removes description by setting to null', () => { + const cat = createTestCategory('Custom Garage', { description: 'Some description' }); + + const data: UpdateBudgetCategoryRequest = { description: null }; + const result = budgetCategoryService.updateBudgetCategory(db, cat.id, data); + + expect(result.description).toBeNull(); + }); + + it('updates only sortOrder (partial update)', () => { + const cat = createTestCategory('Custom Carport', { sortOrder: 1 }); + + const data: UpdateBudgetCategoryRequest = { sortOrder: 10 }; + const result = budgetCategoryService.updateBudgetCategory(db, cat.id, data); + + expect(result.sortOrder).toBe(10); + expect(result.name).toBe('Custom Carport'); + }); + + it('updates all fields at once', () => { + const cat = createTestCategory('Custom Pergola', { + description: 'Old desc', + color: '#000000', + sortOrder: 1, + }); + + const data: UpdateBudgetCategoryRequest = { + name: 'Custom Awning', + description: 'New description', + color: '#FFFFFF', + sortOrder: 99, + }; + const result = budgetCategoryService.updateBudgetCategory(db, cat.id, data); + + expect(result.name).toBe('Custom Awning'); + expect(result.description).toBe('New description'); + expect(result.color).toBe('#FFFFFF'); + expect(result.sortOrder).toBe(99); + }); + + it('trims name before updating', () => { + const cat = createTestCategory('Custom Patio'); + + const data: UpdateBudgetCategoryRequest = { name: ' Custom Garden ' }; + const result = budgetCategoryService.updateBudgetCategory(db, cat.id, data); + + expect(result.name).toBe('Custom Garden'); + }); + + it('allows updating name to the same value (no conflict)', () => { + const cat = createTestCategory('Custom Pool'); + + const data: UpdateBudgetCategoryRequest = { name: 'Custom Pool' }; + const result = budgetCategoryService.updateBudgetCategory(db, cat.id, data); + + expect(result.name).toBe('Custom Pool'); + }); + + it('sets updatedAt to a new timestamp on update', async () => { + const cat = createTestCategory('Custom Spa'); + + await new Promise((resolve) => setTimeout(resolve, 1)); + + const data: UpdateBudgetCategoryRequest = { name: 'Custom Hot Tub' }; + const result = budgetCategoryService.updateBudgetCategory(db, cat.id, data); + + expect(result.updatedAt).not.toBe(cat.updatedAt); + }); + + it('can update a seeded category', () => { + // Update the seeded 'Materials' category (id: bc-materials) + const data: UpdateBudgetCategoryRequest = { sortOrder: 50 }; + const result = budgetCategoryService.updateBudgetCategory(db, 'bc-materials', data); + + expect(result.id).toBe('bc-materials'); + expect(result.name).toBe('Materials'); + expect(result.sortOrder).toBe(50); + }); + + it('throws NotFoundError when category does not exist', () => { + const data: UpdateBudgetCategoryRequest = { name: 'Test' }; + + expect(() => { + budgetCategoryService.updateBudgetCategory(db, 'non-existent-id', data); + }).toThrow(NotFoundError); + expect(() => { + budgetCategoryService.updateBudgetCategory(db, 'non-existent-id', data); + }).toThrow('Budget category not found'); + }); + + it('throws ValidationError when no fields are provided', () => { + const cat = createTestCategory('Custom Sauna'); + + const data: UpdateBudgetCategoryRequest = {}; + + expect(() => { + budgetCategoryService.updateBudgetCategory(db, cat.id, data); + }).toThrow(ValidationError); + expect(() => { + budgetCategoryService.updateBudgetCategory(db, cat.id, data); + }).toThrow('At least one field must be provided'); + }); + + it('throws ValidationError for empty name', () => { + const cat = createTestCategory('Custom Cellar'); + + const data: UpdateBudgetCategoryRequest = { name: '' }; + + expect(() => { + budgetCategoryService.updateBudgetCategory(db, cat.id, data); + }).toThrow(ValidationError); + }); + + it('throws ValidationError for whitespace-only name', () => { + const cat = createTestCategory('Custom Basement'); + + const data: UpdateBudgetCategoryRequest = { name: ' ' }; + + expect(() => { + budgetCategoryService.updateBudgetCategory(db, cat.id, data); + }).toThrow(ValidationError); + }); + + it('throws ValidationError for name exceeding 100 characters', () => { + const cat = createTestCategory('Custom Atrium'); + + const data: UpdateBudgetCategoryRequest = { name: 'a'.repeat(101) }; + + expect(() => { + budgetCategoryService.updateBudgetCategory(db, cat.id, data); + }).toThrow(ValidationError); + }); + + it('throws ValidationError for description exceeding 500 characters', () => { + const cat = createTestCategory('Custom Balcony'); + + const data: UpdateBudgetCategoryRequest = { description: 'a'.repeat(501) }; + + expect(() => { + budgetCategoryService.updateBudgetCategory(db, cat.id, data); + }).toThrow(ValidationError); + }); + + it('throws ValidationError for invalid hex color format', () => { + const cat = createTestCategory('Custom Terrace B'); + + const data: UpdateBudgetCategoryRequest = { color: 'not-a-color' }; + + expect(() => { + budgetCategoryService.updateBudgetCategory(db, cat.id, data); + }).toThrow(ValidationError); + }); + + it('throws ValidationError for negative sortOrder', () => { + const cat = createTestCategory('Custom Lobby'); + + const data: UpdateBudgetCategoryRequest = { sortOrder: -5 }; + + expect(() => { + budgetCategoryService.updateBudgetCategory(db, cat.id, data); + }).toThrow(ValidationError); + }); + + it('throws ConflictError when new name conflicts with another category', () => { + const cat = createTestCategory('Custom Studio'); + + // Try to rename to a seeded category name + const data: UpdateBudgetCategoryRequest = { name: 'Labor' }; + + expect(() => { + budgetCategoryService.updateBudgetCategory(db, cat.id, data); + }).toThrow(ConflictError); + expect(() => { + budgetCategoryService.updateBudgetCategory(db, cat.id, data); + }).toThrow('A budget category with this name already exists'); + }); + + it('throws ConflictError for case-insensitive name conflict with another category', () => { + const cat = createTestCategory('Custom Veranda'); + + const data: UpdateBudgetCategoryRequest = { name: 'LABOR' }; + + expect(() => { + budgetCategoryService.updateBudgetCategory(db, cat.id, data); + }).toThrow(ConflictError); + }); + }); + + // ─── deleteBudgetCategory() ──────────────────────────────────────────────── + + describe('deleteBudgetCategory()', () => { + it('deletes a custom category successfully', () => { + const cat = createTestCategory('Custom Driveway'); + + budgetCategoryService.deleteBudgetCategory(db, cat.id); + + expect(() => { + budgetCategoryService.getBudgetCategoryById(db, cat.id); + }).toThrow(NotFoundError); + }); + + it('removes category from the list after deletion', () => { + const cat1 = createTestCategory('Custom Walkway'); + createTestCategory('Custom Path'); + + const countBefore = budgetCategoryService.listBudgetCategories(db).length; + + budgetCategoryService.deleteBudgetCategory(db, cat1.id); + + const result = budgetCategoryService.listBudgetCategories(db); + expect(result).toHaveLength(countBefore - 1); + + const found = result.find((c) => c.id === cat1.id); + expect(found).toBeUndefined(); + }); + + it('can delete a seeded category', () => { + // bc-other is a seeded default not used by any subsidy program + budgetCategoryService.deleteBudgetCategory(db, 'bc-other'); + + expect(() => { + budgetCategoryService.getBudgetCategoryById(db, 'bc-other'); + }).toThrow(NotFoundError); + }); + + it('throws NotFoundError when category does not exist', () => { + expect(() => { + budgetCategoryService.deleteBudgetCategory(db, 'non-existent-id'); + }).toThrow(NotFoundError); + expect(() => { + budgetCategoryService.deleteBudgetCategory(db, 'non-existent-id'); + }).toThrow('Budget category not found'); + }); + + it('throws CategoryInUseError when category is referenced by a subsidy program', () => { + const cat = createTestCategory('Custom Ventilation'); + createSubsidyProgramReferencing(cat.id); + + expect(() => { + budgetCategoryService.deleteBudgetCategory(db, cat.id); + }).toThrow(CategoryInUseError); + expect(() => { + budgetCategoryService.deleteBudgetCategory(db, cat.id); + }).toThrow('Budget category is in use and cannot be deleted'); + }); + + it('includes subsidyProgramCount in CategoryInUseError details', () => { + const cat = createTestCategory('Custom Skylights'); + createSubsidyProgramReferencing(cat.id); + + let thrownError: CategoryInUseError | null = null; + try { + budgetCategoryService.deleteBudgetCategory(db, cat.id); + } catch (err) { + if (err instanceof CategoryInUseError) { + thrownError = err; + } + } + + expect(thrownError).not.toBeNull(); + expect(thrownError?.details?.subsidyProgramCount).toBe(1); + expect(thrownError?.details?.workItemCount).toBe(0); + }); + + it('successfully deletes a category not referenced by any subsidy program', () => { + const cat1 = createTestCategory('Custom Dormer'); + const cat2 = createTestCategory('Custom Gable'); + // Reference cat2, but not cat1 + createSubsidyProgramReferencing(cat2.id); + + // cat1 should be deletable + budgetCategoryService.deleteBudgetCategory(db, cat1.id); + + expect(() => { + budgetCategoryService.getBudgetCategoryById(db, cat1.id); + }).toThrow(NotFoundError); + }); + + it('CategoryInUseError has code CATEGORY_IN_USE and statusCode 409', () => { + const cat = createTestCategory('Custom Eave'); + createSubsidyProgramReferencing(cat.id); + + let thrownError: CategoryInUseError | null = null; + try { + budgetCategoryService.deleteBudgetCategory(db, cat.id); + } catch (err) { + if (err instanceof CategoryInUseError) { + thrownError = err; + } + } + + expect(thrownError?.code).toBe('CATEGORY_IN_USE'); + expect(thrownError?.statusCode).toBe(409); + }); + }); +}); diff --git a/server/src/services/budgetCategoryService.ts b/server/src/services/budgetCategoryService.ts new file mode 100644 index 000000000..f824bf2e2 --- /dev/null +++ b/server/src/services/budgetCategoryService.ts @@ -0,0 +1,256 @@ +import { randomUUID } from 'node:crypto'; +import { eq, asc, sql } from 'drizzle-orm'; +import type { BetterSQLite3Database } from 'drizzle-orm/better-sqlite3'; +import type * as schemaTypes from '../db/schema.js'; +import { budgetCategories, subsidyProgramCategories } from '../db/schema.js'; +import type { + BudgetCategory, + CreateBudgetCategoryRequest, + UpdateBudgetCategoryRequest, +} from '@cornerstone/shared'; +import { + NotFoundError, + ValidationError, + ConflictError, + CategoryInUseError, +} from '../errors/AppError.js'; + +type DbType = BetterSQLite3Database; + +/** + * Convert database budget category row to BudgetCategory shape. + */ +function toBudgetCategory(row: typeof budgetCategories.$inferSelect): BudgetCategory { + return { + id: row.id, + name: row.name, + description: row.description, + color: row.color, + sortOrder: row.sortOrder, + createdAt: row.createdAt, + updatedAt: row.updatedAt, + }; +} + +/** + * Validate hex color format (#RRGGBB). + */ +function isValidHexColor(color: string): boolean { + return /^#[0-9A-Fa-f]{6}$/.test(color); +} + +/** + * List all budget categories, sorted by sort_order ascending. + */ +export function listBudgetCategories(db: DbType): BudgetCategory[] { + const rows = db.select().from(budgetCategories).orderBy(asc(budgetCategories.sortOrder)).all(); + return rows.map(toBudgetCategory); +} + +/** + * Get a single budget category by ID. + * @throws NotFoundError if category does not exist + */ +export function getBudgetCategoryById(db: DbType, id: string): BudgetCategory { + const row = db.select().from(budgetCategories).where(eq(budgetCategories.id, id)).get(); + if (!row) { + throw new NotFoundError('Budget category not found'); + } + return toBudgetCategory(row); +} + +/** + * Create a new budget category. + * @throws ValidationError if name is invalid, description too long, or color format invalid + * @throws ConflictError if a category with the same name already exists (case-insensitive) + */ +export function createBudgetCategory( + db: DbType, + data: CreateBudgetCategoryRequest, +): BudgetCategory { + // Validate name + const trimmedName = data.name.trim(); + if (trimmedName.length === 0 || trimmedName.length > 100) { + throw new ValidationError('Budget category name must be between 1 and 100 characters'); + } + + // Validate description length + if ( + data.description !== undefined && + data.description !== null && + data.description.length > 500 + ) { + throw new ValidationError('Budget category description must be at most 500 characters'); + } + + // Validate color format + if (data.color !== undefined && data.color !== null && !isValidHexColor(data.color)) { + throw new ValidationError('Color must be a hex color code in format #RRGGBB'); + } + + // Validate sortOrder + if (data.sortOrder !== undefined && data.sortOrder < 0) { + throw new ValidationError('Sort order must be a non-negative integer'); + } + + // Check for duplicate name (case-insensitive) + const existing = db + .select() + .from(budgetCategories) + .where(sql`LOWER(${budgetCategories.name}) = LOWER(${trimmedName})`) + .get(); + + if (existing) { + throw new ConflictError('A budget category with this name already exists'); + } + + // Create category + const id = randomUUID(); + const now = new Date().toISOString(); + + db.insert(budgetCategories) + .values({ + id, + name: trimmedName, + description: data.description ?? null, + color: data.color ?? null, + sortOrder: data.sortOrder ?? 0, + createdAt: now, + updatedAt: now, + }) + .run(); + + return { + id, + name: trimmedName, + description: data.description ?? null, + color: data.color ?? null, + sortOrder: data.sortOrder ?? 0, + createdAt: now, + updatedAt: now, + }; +} + +/** + * Update a budget category's name, description, color, and/or sort order. + * @throws NotFoundError if category does not exist + * @throws ValidationError if fields are invalid or no fields provided + * @throws ConflictError if new name conflicts with existing category (case-insensitive) + */ +export function updateBudgetCategory( + db: DbType, + id: string, + data: UpdateBudgetCategoryRequest, +): BudgetCategory { + // Check category exists + const existing = db.select().from(budgetCategories).where(eq(budgetCategories.id, id)).get(); + if (!existing) { + throw new NotFoundError('Budget category not found'); + } + + // Validate at least one field provided + if ( + data.name === undefined && + data.description === undefined && + data.color === undefined && + data.sortOrder === undefined + ) { + throw new ValidationError('At least one field must be provided'); + } + + // Build update object + const updates: Partial = {}; + + // Validate and add name if provided + if (data.name !== undefined) { + const trimmedName = data.name.trim(); + if (trimmedName.length === 0 || trimmedName.length > 100) { + throw new ValidationError('Budget category name must be between 1 and 100 characters'); + } + + // Check for duplicate name (case-insensitive), excluding current category + const duplicate = db + .select() + .from(budgetCategories) + .where( + sql`LOWER(${budgetCategories.name}) = LOWER(${trimmedName}) AND ${budgetCategories.id} != ${id}`, + ) + .get(); + + if (duplicate) { + throw new ConflictError('A budget category with this name already exists'); + } + + updates.name = trimmedName; + } + + // Validate and add description if provided + if (data.description !== undefined) { + if (data.description !== null && data.description.length > 500) { + throw new ValidationError('Budget category description must be at most 500 characters'); + } + updates.description = data.description; + } + + // Validate and add color if provided + if (data.color !== undefined) { + if (data.color !== null && !isValidHexColor(data.color)) { + throw new ValidationError('Color must be a hex color code in format #RRGGBB'); + } + updates.color = data.color; + } + + // Validate and add sortOrder if provided + if (data.sortOrder !== undefined) { + if (data.sortOrder < 0) { + throw new ValidationError('Sort order must be a non-negative integer'); + } + updates.sortOrder = data.sortOrder; + } + + // Set updated timestamp + const now = new Date().toISOString(); + updates.updatedAt = now; + + // Perform update + db.update(budgetCategories).set(updates).where(eq(budgetCategories.id, id)).run(); + + // Fetch and return updated category + const updated = db.select().from(budgetCategories).where(eq(budgetCategories.id, id)).get(); + return toBudgetCategory(updated!); +} + +/** + * Delete a budget category. + * Fails if the category is referenced by any subsidy programs. + * (Work item references will be checked here once the budget_category_id FK is added to work_items.) + * @throws NotFoundError if category does not exist + * @throws CategoryInUseError if category is referenced by subsidy programs or work items + */ +export function deleteBudgetCategory(db: DbType, id: string): void { + // Check category exists + const existing = db.select().from(budgetCategories).where(eq(budgetCategories.id, id)).get(); + if (!existing) { + throw new NotFoundError('Budget category not found'); + } + + // Check for subsidy program references + const subsidyRefs = db + .select() + .from(subsidyProgramCategories) + .where(eq(subsidyProgramCategories.budgetCategoryId, id)) + .all(); + + // Work item references will be added when budget_category_id FK is added to work_items (later story) + const workItemCount = 0; + + if (subsidyRefs.length > 0 || workItemCount > 0) { + throw new CategoryInUseError('Budget category is in use and cannot be deleted', { + subsidyProgramCount: subsidyRefs.length, + workItemCount, + }); + } + + // Delete category + db.delete(budgetCategories).where(eq(budgetCategories.id, id)).run(); +} diff --git a/shared/src/index.ts b/shared/src/index.ts index 9cdbe2565..299e71ce8 100644 --- a/shared/src/index.ts +++ b/shared/src/index.ts @@ -64,3 +64,12 @@ export type { CreateDependencyRequest, DependencyCreatedResponse, } from './types/dependency.js'; + +// Budget Categories +export type { + BudgetCategory, + CreateBudgetCategoryRequest, + UpdateBudgetCategoryRequest, + BudgetCategoryListResponse, + BudgetCategoryResponse, +} from './types/budgetCategory.js'; diff --git a/shared/src/types/budgetCategory.ts b/shared/src/types/budgetCategory.ts new file mode 100644 index 000000000..a28d27291 --- /dev/null +++ b/shared/src/types/budgetCategory.ts @@ -0,0 +1,51 @@ +/** + * Budget category types and interfaces. + * Budget categories organize construction costs (e.g., Materials, Labor, Permits). + */ + +/** + * Budget category entity as returned by the API. + */ +export interface BudgetCategory { + id: string; + name: string; + description: string | null; + color: string | null; + sortOrder: number; + createdAt: string; + updatedAt: string; +} + +/** + * Request body for creating a new budget category. + */ +export interface CreateBudgetCategoryRequest { + name: string; + description?: string | null; + color?: string | null; + sortOrder?: number; +} + +/** + * Request body for updating a budget category. + * All fields are optional; at least one must be provided. + */ +export interface UpdateBudgetCategoryRequest { + name?: string; + description?: string | null; + color?: string | null; + sortOrder?: number; +} + +/** + * Response for GET /api/budget-categories - list all categories. + */ +export interface BudgetCategoryListResponse { + categories: BudgetCategory[]; +} + +/** + * Response for single-category endpoints (POST, GET by ID, PATCH). + * The category object is returned directly (not wrapped). + */ +export type BudgetCategoryResponse = BudgetCategory; diff --git a/shared/src/types/errors.ts b/shared/src/types/errors.ts index a510eb523..25d4d89ba 100644 --- a/shared/src/types/errors.ts +++ b/shared/src/types/errors.ts @@ -18,4 +18,5 @@ export type ErrorCode = | 'OIDC_ERROR' | 'EMAIL_CONFLICT' | 'CIRCULAR_DEPENDENCY' - | 'DUPLICATE_DEPENDENCY'; + | 'DUPLICATE_DEPENDENCY' + | 'CATEGORY_IN_USE'; From 4e7d1386cd4aee18ed846db3ccce03851a7f5649 Mon Sep 17 00:00:00 2001 From: Frank Steiler Date: Fri, 20 Feb 2026 09:58:14 +0100 Subject: [PATCH 05/69] feat(vendors): vendor/contractor management UI (Story #143) (#151) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(budget): implement vendor management API endpoints (Story #143) - Add vendor shared types (Vendor, VendorDetail, CRUD request/response) - Add VENDOR_IN_USE error code - Implement vendorService with paginated list, search, CRUD, invoice stats - Implement vendor routes (GET/POST/PATCH/DELETE /api/vendors) - Outstanding balance computed from pending+overdue invoices Co-Authored-By: Claude backend-developer (Opus 4.6) Co-Authored-By: Claude product-architect (Opus 4.6) * feat(vendors): implement vendor/contractor management UI (Story #143) Add complete frontend for vendor management including: - Typed API client (vendorsApi.ts) matching GET/POST/PATCH/DELETE /api/vendors - VendorsPage: paginated list with search, desktop table, mobile cards, create modal, delete with 409 conflict handling, empty states - VendorDetailPage: breadcrumb navigation, stats cards (invoice count, outstanding balance with Intl.NumberFormat), inline editing, delete confirmation, invoices placeholder section - Routes /budget/vendors and /budget/vendors/:id registered in App.tsx - "Vendors" NavLink added to Sidebar (adjacent to Budget Categories) - Sidebar.test.tsx link count updated from 10 to 11 Fixes #143 Co-Authored-By: Claude frontend-developer (Sonnet 4.6) * test(e2e): add Playwright E2E tests for vendor/contractor management (Story #143) Coverage for all automated UAT scenarios on /budget/vendors and /budget/vendors/:id: - Scenario 1: Empty state (no vendors, search no-match) - Scenario 2: Create vendor — full details (happy path) - Scenario 3: Create vendor — name only (minimal required fields) - Scenario 4: Create validation — disabled submit when name empty, cancel cancels - Scenario 5: View vendor detail page — all fields, stats, invoices placeholder - Scenario 6: Edit vendor details — phone/notes persist; cancel restores; empty name guard - Scenario 8: Delete no-reference vendor — modal confirms name; list updated - Scenario 9: Delete blocked (409) — error shown in modal; confirm button hidden - Scenario 11: Pagination — controls visible when totalPages > 1; hidden on single page - Scenario 12: Search by name (case-insensitive, URL param synced) - Scenario 13: Search by specialty - Scenario 14: Table shows scannable key info (name, specialty, phone, email, columns) - Navigation: vendor → detail → breadcrumb back to list - Scenario 17: Responsive layout — no horizontal scroll; mobile cards vs desktop table - Dark mode: list, detail, modal all render without layout breakage New files: - e2e/pages/VendorsPage.ts (POM for /budget/vendors) - e2e/pages/VendorDetailPage.ts (POM for /budget/vendors/:id) - e2e/tests/budget/vendors.spec.ts (38 tests across 12 describe groups) - e2e/fixtures/testData.ts (added budgetVendors route + vendors API endpoint) Fixes #143 Co-Authored-By: Claude e2e-test-engineer (Sonnet 4.5) * test(vendors): add unit and integration tests for Story #143 vendor management Adds 230 tests across 5 test files covering the complete vendor/contractor management feature: service layer, API routes, API client, and both React pages. - server/src/services/vendorService.test.ts (75 tests) listVendors: pagination, search, sorting, LIKE wildcard escaping getVendorById: found/not found, invoice stats, createdBy resolution createVendor: success, all fields, trimming, validation errors updateVendor: partial update, null clearing, updatedAt refresh, validation deleteVendor: success, not found, VendorInUseError (invoices + work items) - server/src/routes/vendors.test.ts (44 tests) GET/POST/GET:id/PATCH/DELETE endpoints; auth (401), 404, 409, validation (400) All routes verify auth-required and member access - client/src/lib/vendorsApi.test.ts (27 tests) fetchVendors: query string params, search/sort/page, response parsing fetchVendor/createVendor/updateVendor/deleteVendor: request/response, errors - client/src/pages/VendorsPage/VendorsPage.test.tsx (42 tests) Loading, empty state, search-empty state, vendor list, pagination, sort controls Create modal: field validation, success/error flows Delete modal: 409 VENDOR_IN_USE, confirm button hiding after error - client/src/pages/VendorDetailPage/VendorDetailPage.test.tsx (42 tests) Loading, error (404/500/network), vendor detail display, stats, links Edit mode: pre-fill, validation, save/cancel, error handling Delete modal: VENDOR_IN_USE (409), confirm button hiding, navigation Co-Authored-By: Claude qa-integration-tester (Sonnet 4.6) * style(vendors): format test files Co-Authored-By: Claude orchestrator (Opus 4.6) --------- Co-authored-by: Claude frontend-developer (Opus 4.6) --- client/src/App.tsx | 4 + .../src/components/Sidebar/Sidebar.test.tsx | 10 +- client/src/components/Sidebar/Sidebar.tsx | 7 + client/src/lib/vendorsApi.test.ts | 548 ++++++++ client/src/lib/vendorsApi.ts | 75 + .../VendorDetailPage.module.css | 704 ++++++++++ .../VendorDetailPage.test.tsx | 765 ++++++++++ .../VendorDetailPage/VendorDetailPage.tsx | 491 +++++++ .../pages/VendorsPage/VendorsPage.module.css | 854 ++++++++++++ .../pages/VendorsPage/VendorsPage.test.tsx | 734 ++++++++++ client/src/pages/VendorsPage/VendorsPage.tsx | 716 ++++++++++ e2e/fixtures/testData.ts | 2 + e2e/pages/VendorDetailPage.ts | 272 ++++ e2e/pages/VendorsPage.ts | 333 +++++ e2e/tests/budget/vendors.spec.ts | 1234 +++++++++++++++++ server/src/app.ts | 4 + server/src/errors/AppError.ts | 10 + server/src/routes/vendors.test.ts | 924 ++++++++++++ server/src/routes/vendors.ts | 171 +++ server/src/services/vendorService.test.ts | 1009 ++++++++++++++ server/src/services/vendorService.ts | 310 +++++ shared/src/index.ts | 11 + shared/src/types/errors.ts | 3 +- shared/src/types/vendor.ts | 81 ++ 24 files changed, 9266 insertions(+), 6 deletions(-) create mode 100644 client/src/lib/vendorsApi.test.ts create mode 100644 client/src/lib/vendorsApi.ts create mode 100644 client/src/pages/VendorDetailPage/VendorDetailPage.module.css create mode 100644 client/src/pages/VendorDetailPage/VendorDetailPage.test.tsx create mode 100644 client/src/pages/VendorDetailPage/VendorDetailPage.tsx create mode 100644 client/src/pages/VendorsPage/VendorsPage.module.css create mode 100644 client/src/pages/VendorsPage/VendorsPage.test.tsx create mode 100644 client/src/pages/VendorsPage/VendorsPage.tsx create mode 100644 e2e/pages/VendorDetailPage.ts create mode 100644 e2e/pages/VendorsPage.ts create mode 100644 e2e/tests/budget/vendors.spec.ts create mode 100644 server/src/routes/vendors.test.ts create mode 100644 server/src/routes/vendors.ts create mode 100644 server/src/services/vendorService.test.ts create mode 100644 server/src/services/vendorService.ts create mode 100644 shared/src/types/vendor.ts diff --git a/client/src/App.tsx b/client/src/App.tsx index 7e99eeb61..dabd38e81 100644 --- a/client/src/App.tsx +++ b/client/src/App.tsx @@ -14,6 +14,8 @@ const WorkItemDetailPage = lazy(() => import('./pages/WorkItemDetailPage/WorkIte const BudgetCategoriesPage = lazy( () => import('./pages/BudgetCategoriesPage/BudgetCategoriesPage'), ); +const VendorsPage = lazy(() => import('./pages/VendorsPage/VendorsPage')); +const VendorDetailPage = lazy(() => import('./pages/VendorDetailPage/VendorDetailPage')); const TimelinePage = lazy(() => import('./pages/TimelinePage/TimelinePage')); const HouseholdItemsPage = lazy(() => import('./pages/HouseholdItemsPage/HouseholdItemsPage')); const DocumentsPage = lazy(() => import('./pages/DocumentsPage/DocumentsPage')); @@ -56,6 +58,8 @@ export function App() { } /> } /> + } /> + } /> } /> } /> diff --git a/client/src/components/Sidebar/Sidebar.test.tsx b/client/src/components/Sidebar/Sidebar.test.tsx index 8b6af2df6..71db8ba60 100644 --- a/client/src/components/Sidebar/Sidebar.test.tsx +++ b/client/src/components/Sidebar/Sidebar.test.tsx @@ -64,12 +64,12 @@ describe('Sidebar', () => { onClose: mockOnClose, }); - it('renders all 9 navigation links plus 1 GitHub footer link', () => { + it('renders all 10 navigation links plus 1 GitHub footer link', () => { renderWithRouter(); const links = screen.getAllByRole('link'); - // 9 nav links + 1 GitHub link in the footer - expect(links).toHaveLength(10); + // 10 nav links + 1 GitHub link in the footer + expect(links).toHaveLength(11); }); it('renders navigation with correct aria-label', () => { @@ -359,8 +359,8 @@ describe('Sidebar', () => { const links = screen.getAllByRole('link'); const buttons = screen.getAllByRole('button'); - // 9 nav links + 1 GitHub link in the footer - expect(links).toHaveLength(10); + // 10 nav links + 1 GitHub link in the footer + expect(links).toHaveLength(11); // 3 buttons: close button + theme toggle + logout button expect(buttons).toHaveLength(3); expect(buttons[0]).toHaveAttribute('aria-label', 'Close menu'); diff --git a/client/src/components/Sidebar/Sidebar.tsx b/client/src/components/Sidebar/Sidebar.tsx index 2e8aed737..1f65848bb 100644 --- a/client/src/components/Sidebar/Sidebar.tsx +++ b/client/src/components/Sidebar/Sidebar.tsx @@ -52,6 +52,13 @@ export function Sidebar({ isOpen, onClose }: SidebarProps) { > Budget Categories + `${styles.navLink} ${isActive ? styles.active : ''}`} + onClick={onClose} + > + Vendors + `${styles.navLink} ${isActive ? styles.active : ''}`} diff --git a/client/src/lib/vendorsApi.test.ts b/client/src/lib/vendorsApi.test.ts new file mode 100644 index 000000000..02c0e22fd --- /dev/null +++ b/client/src/lib/vendorsApi.test.ts @@ -0,0 +1,548 @@ +import { jest, describe, it, expect, beforeEach, afterEach } from '@jest/globals'; +import { + fetchVendors, + fetchVendor, + createVendor, + updateVendor, + deleteVendor, +} from './vendorsApi.js'; +import type { Vendor, VendorDetail } from '@cornerstone/shared'; +import type { VendorListResponse } from './vendorsApi.js'; + +describe('vendorsApi', () => { + let mockFetch: jest.MockedFunction; + + beforeEach(() => { + mockFetch = jest.fn(); + globalThis.fetch = mockFetch; + }); + + afterEach(() => { + jest.restoreAllMocks(); + }); + + // Sample data + const sampleVendor: Vendor = { + id: 'vendor-1', + name: 'Smith Plumbing', + specialty: 'Plumbing', + phone: '+1 555-1234', + email: 'smith@plumbing.com', + address: '123 Main St', + notes: 'Reliable', + createdBy: { id: 'user-1', displayName: 'Creator', email: 'creator@test.com' }, + createdAt: '2026-01-01T00:00:00.000Z', + updatedAt: '2026-01-01T00:00:00.000Z', + }; + + const sampleVendorDetail: VendorDetail = { + ...sampleVendor, + invoiceCount: 3, + outstandingBalance: 800, + }; + + // ─── fetchVendors ────────────────────────────────────────────────────────── + + describe('fetchVendors', () => { + it('sends GET request to /vendors when no params given', async () => { + const mockResponse: VendorListResponse = { + vendors: [], + pagination: { page: 1, pageSize: 25, totalItems: 0, totalPages: 0 }, + }; + + mockFetch.mockResolvedValueOnce({ + ok: true, + json: async () => mockResponse, + } as Response); + + await fetchVendors(); + + expect(mockFetch).toHaveBeenCalledWith('/api/vendors', expect.any(Object)); + }); + + it('sends GET request to /vendors without query string when no params', async () => { + const mockResponse: VendorListResponse = { + vendors: [], + pagination: { page: 1, pageSize: 25, totalItems: 0, totalPages: 0 }, + }; + + mockFetch.mockResolvedValueOnce({ + ok: true, + json: async () => mockResponse, + } as Response); + + await fetchVendors(); + + const call = mockFetch.mock.calls[0]; + expect(call[0]).toBe('/api/vendors'); + }); + + it('includes page parameter in query string when provided', async () => { + const mockResponse: VendorListResponse = { + vendors: [], + pagination: { page: 2, pageSize: 25, totalItems: 30, totalPages: 2 }, + }; + + mockFetch.mockResolvedValueOnce({ + ok: true, + json: async () => mockResponse, + } as Response); + + await fetchVendors({ page: 2 }); + + expect(mockFetch).toHaveBeenCalledWith(expect.stringContaining('page=2'), expect.any(Object)); + }); + + it('includes pageSize parameter when provided', async () => { + const mockResponse: VendorListResponse = { + vendors: [], + pagination: { page: 1, pageSize: 10, totalItems: 0, totalPages: 0 }, + }; + + mockFetch.mockResolvedValueOnce({ + ok: true, + json: async () => mockResponse, + } as Response); + + await fetchVendors({ pageSize: 10 }); + + expect(mockFetch).toHaveBeenCalledWith( + expect.stringContaining('pageSize=10'), + expect.any(Object), + ); + }); + + it('includes q search parameter when provided', async () => { + const mockResponse: VendorListResponse = { + vendors: [sampleVendor], + pagination: { page: 1, pageSize: 25, totalItems: 1, totalPages: 1 }, + }; + + mockFetch.mockResolvedValueOnce({ + ok: true, + json: async () => mockResponse, + } as Response); + + await fetchVendors({ q: 'smith' }); + + expect(mockFetch).toHaveBeenCalledWith( + expect.stringContaining('q=smith'), + expect.any(Object), + ); + }); + + it('includes sortBy and sortOrder when provided', async () => { + const mockResponse: VendorListResponse = { + vendors: [], + pagination: { page: 1, pageSize: 25, totalItems: 0, totalPages: 0 }, + }; + + mockFetch.mockResolvedValueOnce({ + ok: true, + json: async () => mockResponse, + } as Response); + + await fetchVendors({ sortBy: 'specialty', sortOrder: 'desc' }); + + expect(mockFetch).toHaveBeenCalledWith( + expect.stringContaining('sortBy=specialty'), + expect.any(Object), + ); + expect(mockFetch).toHaveBeenCalledWith( + expect.stringContaining('sortOrder=desc'), + expect.any(Object), + ); + }); + + it('omits q parameter when not provided', async () => { + const mockResponse: VendorListResponse = { + vendors: [], + pagination: { page: 1, pageSize: 25, totalItems: 0, totalPages: 0 }, + }; + + mockFetch.mockResolvedValueOnce({ + ok: true, + json: async () => mockResponse, + } as Response); + + await fetchVendors({ page: 1 }); + + const call = mockFetch.mock.calls[0]; + expect(call[0]).not.toContain('q='); + }); + + it('returns parsed vendor list response', async () => { + const mockResponse: VendorListResponse = { + vendors: [sampleVendor], + pagination: { page: 1, pageSize: 25, totalItems: 1, totalPages: 1 }, + }; + + mockFetch.mockResolvedValueOnce({ + ok: true, + json: async () => mockResponse, + } as Response); + + const result = await fetchVendors(); + + expect(result.vendors).toHaveLength(1); + expect(result.vendors[0].name).toBe('Smith Plumbing'); + expect(result.pagination.totalItems).toBe(1); + }); + + it('throws ApiClientError when server returns 401', async () => { + mockFetch.mockResolvedValueOnce({ + ok: false, + status: 401, + json: async () => ({ error: { code: 'UNAUTHORIZED', message: 'Unauthorized' } }), + } as Response); + + await expect(fetchVendors()).rejects.toThrow(); + }); + + it('throws when server returns 500', async () => { + mockFetch.mockResolvedValueOnce({ + ok: false, + status: 500, + json: async () => ({ error: { code: 'INTERNAL_ERROR', message: 'Server error' } }), + } as Response); + + await expect(fetchVendors()).rejects.toThrow(); + }); + }); + + // ─── fetchVendor ─────────────────────────────────────────────────────────── + + describe('fetchVendor', () => { + it('sends GET request to /vendors/:id', async () => { + mockFetch.mockResolvedValueOnce({ + ok: true, + json: async () => ({ vendor: sampleVendorDetail }), + } as Response); + + await fetchVendor('vendor-1'); + + expect(mockFetch).toHaveBeenCalledWith('/api/vendors/vendor-1', expect.any(Object)); + }); + + it('returns VendorDetail with invoiceCount and outstandingBalance', async () => { + mockFetch.mockResolvedValueOnce({ + ok: true, + json: async () => ({ vendor: sampleVendorDetail }), + } as Response); + + const result = await fetchVendor('vendor-1'); + + expect(result.id).toBe('vendor-1'); + expect(result.name).toBe('Smith Plumbing'); + expect(result.invoiceCount).toBe(3); + expect(result.outstandingBalance).toBe(800); + }); + + it('unwraps the vendor from the response envelope', async () => { + const envelope = { vendor: sampleVendorDetail }; + + mockFetch.mockResolvedValueOnce({ + ok: true, + json: async () => envelope, + } as Response); + + const result = await fetchVendor('vendor-1'); + + // Should return the VendorDetail directly, not the envelope + expect(result).toEqual(sampleVendorDetail); + }); + + it('throws ApiClientError for 404 NOT_FOUND', async () => { + mockFetch.mockResolvedValueOnce({ + ok: false, + status: 404, + json: async () => ({ error: { code: 'NOT_FOUND', message: 'Vendor not found' } }), + } as Response); + + await expect(fetchVendor('nonexistent')).rejects.toThrow(); + }); + + it('throws ApiClientError for 401 UNAUTHORIZED', async () => { + mockFetch.mockResolvedValueOnce({ + ok: false, + status: 401, + json: async () => ({ error: { code: 'UNAUTHORIZED', message: 'Unauthorized' } }), + } as Response); + + await expect(fetchVendor('vendor-1')).rejects.toThrow(); + }); + }); + + // ─── createVendor ────────────────────────────────────────────────────────── + + describe('createVendor', () => { + it('sends POST request to /vendors with name only', async () => { + const newVendor: Vendor = { ...sampleVendor, id: 'vendor-new' }; + + mockFetch.mockResolvedValueOnce({ + ok: true, + status: 201, + json: async () => ({ vendor: newVendor }), + } as Response); + + const requestData = { name: 'New Vendor' }; + await createVendor(requestData); + + expect(mockFetch).toHaveBeenCalledWith( + '/api/vendors', + expect.objectContaining({ + method: 'POST', + body: JSON.stringify(requestData), + }), + ); + }); + + it('sends POST request with all optional fields', async () => { + const newVendor: Vendor = { ...sampleVendor, id: 'vendor-full' }; + + mockFetch.mockResolvedValueOnce({ + ok: true, + status: 201, + json: async () => ({ vendor: newVendor }), + } as Response); + + const requestData = { + name: 'Full Vendor', + specialty: 'Roofing', + phone: '555-1111', + email: 'full@vendor.com', + address: '100 Oak Ave', + notes: 'Test notes', + }; + await createVendor(requestData); + + expect(mockFetch).toHaveBeenCalledWith( + '/api/vendors', + expect.objectContaining({ + method: 'POST', + body: JSON.stringify(requestData), + }), + ); + }); + + it('returns the created vendor (unwrapped from envelope)', async () => { + const newVendor: Vendor = { ...sampleVendor, id: 'vendor-created', name: 'Created Vendor' }; + + mockFetch.mockResolvedValueOnce({ + ok: true, + status: 201, + json: async () => ({ vendor: newVendor }), + } as Response); + + const result = await createVendor({ name: 'Created Vendor' }); + + expect(result).toEqual(newVendor); + expect(result.id).toBe('vendor-created'); + expect(result.name).toBe('Created Vendor'); + }); + + it('throws ApiClientError for 400 VALIDATION_ERROR', async () => { + mockFetch.mockResolvedValueOnce({ + ok: false, + status: 400, + json: async () => ({ + error: { code: 'VALIDATION_ERROR', message: 'name is required' }, + }), + } as Response); + + await expect(createVendor({ name: '' })).rejects.toThrow(); + }); + + it('throws ApiClientError for 401 UNAUTHORIZED', async () => { + mockFetch.mockResolvedValueOnce({ + ok: false, + status: 401, + json: async () => ({ error: { code: 'UNAUTHORIZED', message: 'Unauthorized' } }), + } as Response); + + await expect(createVendor({ name: 'New Vendor' })).rejects.toThrow(); + }); + }); + + // ─── updateVendor ────────────────────────────────────────────────────────── + + describe('updateVendor', () => { + it('sends PATCH request to /vendors/:id with body', async () => { + const updated: VendorDetail = { ...sampleVendorDetail, name: 'Updated Name' }; + + mockFetch.mockResolvedValueOnce({ + ok: true, + json: async () => ({ vendor: updated }), + } as Response); + + const updateData = { name: 'Updated Name' }; + await updateVendor('vendor-1', updateData); + + expect(mockFetch).toHaveBeenCalledWith( + '/api/vendors/vendor-1', + expect.objectContaining({ + method: 'PATCH', + body: JSON.stringify(updateData), + }), + ); + }); + + it('returns the updated VendorDetail (unwrapped from envelope)', async () => { + const updated: VendorDetail = { + ...sampleVendorDetail, + name: 'Updated Vendor', + specialty: 'Landscaping', + }; + + mockFetch.mockResolvedValueOnce({ + ok: true, + json: async () => ({ vendor: updated }), + } as Response); + + const result = await updateVendor('vendor-1', { name: 'Updated Vendor' }); + + expect(result).toEqual(updated); + expect(result.name).toBe('Updated Vendor'); + expect(result.invoiceCount).toBe(3); + }); + + it('handles partial update (only specialty)', async () => { + const updated: VendorDetail = { ...sampleVendorDetail, specialty: 'New Specialty' }; + + mockFetch.mockResolvedValueOnce({ + ok: true, + json: async () => ({ vendor: updated }), + } as Response); + + const updateData = { specialty: 'New Specialty' }; + await updateVendor('vendor-1', updateData); + + expect(mockFetch).toHaveBeenCalledWith( + '/api/vendors/vendor-1', + expect.objectContaining({ + method: 'PATCH', + body: JSON.stringify(updateData), + }), + ); + }); + + it('handles null fields in update (clearing optional fields)', async () => { + const updated: VendorDetail = { ...sampleVendorDetail, specialty: null }; + + mockFetch.mockResolvedValueOnce({ + ok: true, + json: async () => ({ vendor: updated }), + } as Response); + + const updateData = { specialty: null }; + await updateVendor('vendor-1', updateData); + + expect(mockFetch).toHaveBeenCalledWith( + '/api/vendors/vendor-1', + expect.objectContaining({ + method: 'PATCH', + body: JSON.stringify(updateData), + }), + ); + }); + + it('throws ApiClientError for 404 NOT_FOUND', async () => { + mockFetch.mockResolvedValueOnce({ + ok: false, + status: 404, + json: async () => ({ error: { code: 'NOT_FOUND', message: 'Vendor not found' } }), + } as Response); + + await expect(updateVendor('nonexistent', { name: 'Updated' })).rejects.toThrow(); + }); + + it('throws ApiClientError for 400 VALIDATION_ERROR', async () => { + mockFetch.mockResolvedValueOnce({ + ok: false, + status: 400, + json: async () => ({ + error: { code: 'VALIDATION_ERROR', message: 'At least one field must be provided' }, + }), + } as Response); + + await expect(updateVendor('vendor-1', {})).rejects.toThrow(); + }); + + it('throws ApiClientError for 401 UNAUTHORIZED', async () => { + mockFetch.mockResolvedValueOnce({ + ok: false, + status: 401, + json: async () => ({ error: { code: 'UNAUTHORIZED', message: 'Unauthorized' } }), + } as Response); + + await expect(updateVendor('vendor-1', { name: 'Test' })).rejects.toThrow(); + }); + }); + + // ─── deleteVendor ────────────────────────────────────────────────────────── + + describe('deleteVendor', () => { + it('sends DELETE request to /vendors/:id', async () => { + mockFetch.mockResolvedValueOnce({ + ok: true, + status: 204, + } as Response); + + await deleteVendor('vendor-1'); + + expect(mockFetch).toHaveBeenCalledWith( + '/api/vendors/vendor-1', + expect.objectContaining({ + method: 'DELETE', + }), + ); + }); + + it('returns void on successful deletion', async () => { + mockFetch.mockResolvedValueOnce({ + ok: true, + status: 204, + } as Response); + + const result = await deleteVendor('vendor-1'); + + expect(result).toBeUndefined(); + }); + + it('throws ApiClientError for 404 NOT_FOUND', async () => { + mockFetch.mockResolvedValueOnce({ + ok: false, + status: 404, + json: async () => ({ error: { code: 'NOT_FOUND', message: 'Vendor not found' } }), + } as Response); + + await expect(deleteVendor('nonexistent')).rejects.toThrow(); + }); + + it('throws ApiClientError for 409 VENDOR_IN_USE', async () => { + mockFetch.mockResolvedValueOnce({ + ok: false, + status: 409, + json: async () => ({ + error: { + code: 'VENDOR_IN_USE', + message: 'Vendor is in use and cannot be deleted', + details: { invoiceCount: 2, workItemCount: 1 }, + }, + }), + } as Response); + + await expect(deleteVendor('vendor-in-use')).rejects.toThrow(); + }); + + it('throws ApiClientError for 401 UNAUTHORIZED', async () => { + mockFetch.mockResolvedValueOnce({ + ok: false, + status: 401, + json: async () => ({ error: { code: 'UNAUTHORIZED', message: 'Unauthorized' } }), + } as Response); + + await expect(deleteVendor('vendor-1')).rejects.toThrow(); + }); + }); +}); diff --git a/client/src/lib/vendorsApi.ts b/client/src/lib/vendorsApi.ts new file mode 100644 index 000000000..f9b1eaf4c --- /dev/null +++ b/client/src/lib/vendorsApi.ts @@ -0,0 +1,75 @@ +import { get, post, patch, del } from './apiClient.js'; +import type { + Vendor, + VendorDetail, + VendorListQuery, + CreateVendorRequest, + UpdateVendorRequest, +} from '@cornerstone/shared'; + +export interface VendorListResponse { + vendors: Vendor[]; + pagination: { + page: number; + pageSize: number; + totalItems: number; + totalPages: number; + }; +} + +/** + * Fetches a paginated list of vendors with optional search and sorting. + */ +export function fetchVendors(params?: VendorListQuery): Promise { + const queryParams = new URLSearchParams(); + + if (params?.page !== undefined) { + queryParams.set('page', params.page.toString()); + } + if (params?.pageSize !== undefined) { + queryParams.set('pageSize', params.pageSize.toString()); + } + if (params?.q) { + queryParams.set('q', params.q); + } + if (params?.sortBy) { + queryParams.set('sortBy', params.sortBy); + } + if (params?.sortOrder) { + queryParams.set('sortOrder', params.sortOrder); + } + + const queryString = queryParams.toString(); + const path = queryString ? `/vendors?${queryString}` : '/vendors'; + + return get(path); +} + +/** + * Fetches a single vendor by ID with invoice statistics. + */ +export function fetchVendor(id: string): Promise { + return get<{ vendor: VendorDetail }>(`/vendors/${id}`).then((r) => r.vendor); +} + +/** + * Creates a new vendor. + */ +export function createVendor(data: CreateVendorRequest): Promise { + return post<{ vendor: Vendor }>('/vendors', data).then((r) => r.vendor); +} + +/** + * Updates an existing vendor. + */ +export function updateVendor(id: string, data: UpdateVendorRequest): Promise { + return patch<{ vendor: VendorDetail }>(`/vendors/${id}`, data).then((r) => r.vendor); +} + +/** + * Deletes a vendor. + * @throws {ApiClientError} with statusCode 409 if the vendor is in use. + */ +export function deleteVendor(id: string): Promise { + return del(`/vendors/${id}`); +} diff --git a/client/src/pages/VendorDetailPage/VendorDetailPage.module.css b/client/src/pages/VendorDetailPage/VendorDetailPage.module.css new file mode 100644 index 000000000..73a828045 --- /dev/null +++ b/client/src/pages/VendorDetailPage/VendorDetailPage.module.css @@ -0,0 +1,704 @@ +.container { + padding: var(--spacing-8); + max-width: 900px; + margin: 0 auto; +} + +.content { + display: flex; + flex-direction: column; + gap: var(--spacing-6); +} + +/* ---- Breadcrumb ---- */ + +.breadcrumb { + display: flex; + align-items: center; + gap: var(--spacing-2); + font-size: var(--font-size-sm); +} + +.backLink { + background: none; + border: none; + padding: 0; + color: var(--color-primary); + font-size: var(--font-size-sm); + cursor: pointer; + text-decoration: underline; + text-underline-offset: 2px; +} + +.backLink:hover { + color: var(--color-primary-hover); +} + +.backLink:focus-visible { + outline: 2px solid var(--color-primary); + outline-offset: 2px; + border-radius: var(--radius-sm); +} + +.breadcrumbSeparator { + color: var(--color-text-placeholder); +} + +.breadcrumbCurrent { + color: var(--color-text-muted); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + max-width: 300px; +} + +/* ---- Page header ---- */ + +.pageHeader { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: var(--spacing-4); +} + +.pageHeading { + display: flex; + flex-direction: column; + gap: var(--spacing-1); + flex: 1; + min-width: 0; +} + +.pageTitle { + font-size: var(--font-size-4xl); + font-weight: var(--font-weight-bold); + color: var(--color-text-primary); + margin: 0; + word-break: break-word; +} + +.pageSubtitle { + font-size: var(--font-size-lg); + color: var(--color-text-muted); + font-weight: var(--font-weight-normal); +} + +.pageActions { + display: flex; + gap: var(--spacing-2); + flex-shrink: 0; +} + +/* ---- Stats grid ---- */ + +.statsGrid { + display: grid; + grid-template-columns: repeat(2, 1fr); + gap: var(--spacing-4); +} + +.statCard { + background: var(--color-bg-primary); + border-radius: var(--radius-lg); + box-shadow: var(--shadow-sm); + padding: var(--spacing-5); + display: flex; + flex-direction: column; + gap: var(--spacing-1); +} + +.statLabel { + font-size: var(--font-size-xs); + font-weight: var(--font-weight-semibold); + color: var(--color-text-muted); + text-transform: uppercase; + letter-spacing: 0.05em; +} + +.statValue { + font-size: var(--font-size-3xl); + font-weight: var(--font-weight-bold); + color: var(--color-text-primary); + font-variant-numeric: tabular-nums; +} + +.statValueDanger { + color: var(--color-danger); +} + +/* ---- Card ---- */ + +.card { + background: var(--color-bg-primary); + border-radius: var(--radius-lg); + box-shadow: var(--shadow-sm); + padding: var(--spacing-6); +} + +.cardHeader { + display: flex; + align-items: center; + justify-content: space-between; + margin-bottom: var(--spacing-4); +} + +.cardTitle { + font-size: var(--font-size-xl); + font-weight: var(--font-weight-semibold); + color: var(--color-text-primary); + margin: 0; +} + +/* ---- Info list (view mode) ---- */ + +.infoList { + display: flex; + flex-direction: column; + gap: var(--spacing-0); + margin: 0; +} + +.infoRow { + display: flex; + padding: var(--spacing-3) 0; + border-bottom: 1px solid var(--color-border); + gap: var(--spacing-4); +} + +.infoRow:last-child { + border-bottom: none; +} + +.infoLabel { + font-size: var(--font-size-sm); + font-weight: var(--font-weight-medium); + color: var(--color-text-muted); + min-width: 120px; + flex-shrink: 0; +} + +.infoValue { + font-size: var(--font-size-sm); + color: var(--color-text-primary); + flex: 1; + margin: 0; + word-break: break-word; +} + +.infoValueNotes { + white-space: pre-wrap; + line-height: 1.6; +} + +.infoLink { + color: var(--color-primary); + text-decoration: underline; + text-underline-offset: 2px; +} + +.infoLink:hover { + color: var(--color-primary-hover); +} + +/* ---- Coming soon placeholder ---- */ + +.comingSoon { + padding: var(--spacing-8); + text-align: center; +} + +.comingSoonText { + font-size: var(--font-size-sm); + color: var(--color-text-muted); + margin: 0; + max-width: 40ch; + margin-left: auto; + margin-right: auto; + line-height: 1.6; +} + +/* ---- Loading / Error states ---- */ + +.loading { + display: flex; + align-items: center; + justify-content: center; + min-height: 400px; + font-size: var(--font-size-base); + color: var(--color-text-muted); +} + +.errorCard { + background: var(--color-bg-primary); + border-radius: var(--radius-lg); + box-shadow: var(--shadow-sm); + padding: var(--spacing-8); + text-align: center; +} + +.errorTitle { + font-size: var(--font-size-2xl); + font-weight: var(--font-weight-bold); + color: var(--color-danger); + margin: 0 0 var(--spacing-4) 0; +} + +.errorActions { + display: flex; + gap: var(--spacing-3); + justify-content: center; + margin-top: var(--spacing-4); +} + +.errorBanner { + background-color: var(--color-danger-bg); + border: 1px solid var(--color-danger-border); + border-radius: var(--radius-md); + color: var(--color-danger-active); + padding: var(--spacing-3); + font-size: var(--font-size-sm); + margin-bottom: var(--spacing-4); +} + +/* ---- Form (edit mode) ---- */ + +.form { + display: flex; + flex-direction: column; + gap: var(--spacing-4); +} + +.formRow { + display: flex; + gap: var(--spacing-4); + align-items: flex-start; +} + +.field { + display: flex; + flex-direction: column; + gap: var(--spacing-1); +} + +.fieldGrow { + flex: 1; + display: flex; + flex-direction: column; + gap: var(--spacing-1); +} + +.label { + font-size: var(--font-size-sm); + font-weight: var(--font-weight-medium); + color: var(--color-text-primary); +} + +.required { + color: var(--color-danger); + margin-left: var(--spacing-0-5); +} + +.input { + padding: var(--spacing-2) var(--spacing-3); + border: 1px solid var(--color-border-strong); + border-radius: var(--radius-md); + font-size: var(--font-size-sm); + color: var(--color-text-primary); + background-color: var(--color-bg-primary); + transition: var(--transition-input); + width: 100%; + box-sizing: border-box; +} + +.input:focus-visible { + outline: none; + border-color: var(--color-primary); + box-shadow: var(--shadow-focus-subtle); +} + +.input:disabled { + background-color: var(--color-bg-secondary); + color: var(--color-text-disabled); + cursor: not-allowed; +} + +.textarea { + padding: var(--spacing-2) var(--spacing-3); + border: 1px solid var(--color-border-strong); + border-radius: var(--radius-md); + font-size: var(--font-size-sm); + color: var(--color-text-primary); + background-color: var(--color-bg-primary); + transition: var(--transition-input); + width: 100%; + box-sizing: border-box; + resize: vertical; + font-family: inherit; + line-height: 1.5; +} + +.textarea:focus-visible { + outline: none; + border-color: var(--color-primary); + box-shadow: var(--shadow-focus-subtle); +} + +.textarea:disabled { + background-color: var(--color-bg-secondary); + color: var(--color-text-disabled); + cursor: not-allowed; +} + +.formActions { + display: flex; + gap: var(--spacing-3); + align-items: center; + padding-top: var(--spacing-2); +} + +/* ---- Buttons ---- */ + +.button { + padding: var(--spacing-2-5) var(--spacing-4); + background-color: var(--color-primary); + color: var(--color-primary-text); + font-size: var(--font-size-sm); + font-weight: var(--font-weight-medium); + border: none; + border-radius: var(--radius-md); + cursor: pointer; + transition: var(--transition-button); + white-space: nowrap; +} + +.button:hover:not(:disabled) { + background-color: var(--color-primary-hover); +} + +.button:focus-visible { + outline: none; + box-shadow: var(--shadow-focus); +} + +.button:disabled { + background-color: var(--color-text-placeholder); + cursor: not-allowed; +} + +.secondaryButton { + padding: var(--spacing-2-5) var(--spacing-4); + background-color: var(--color-bg-tertiary); + color: var(--color-text-secondary); + font-size: var(--font-size-sm); + font-weight: var(--font-weight-medium); + border: 1px solid var(--color-border-strong); + border-radius: var(--radius-md); + cursor: pointer; + transition: var(--transition-button-border); +} + +.secondaryButton:hover { + background-color: var(--color-border); +} + +.secondaryButton:focus-visible { + outline: none; + box-shadow: var(--shadow-focus); +} + +.saveButton { + padding: var(--spacing-2-5) var(--spacing-4); + background-color: var(--color-primary); + color: var(--color-primary-text); + font-size: var(--font-size-sm); + font-weight: var(--font-weight-medium); + border: none; + border-radius: var(--radius-md); + cursor: pointer; + transition: var(--transition-button); +} + +.saveButton:hover:not(:disabled) { + background-color: var(--color-primary-hover); +} + +.saveButton:focus-visible { + outline: none; + box-shadow: var(--shadow-focus); +} + +.saveButton:disabled { + background-color: var(--color-text-placeholder); + cursor: not-allowed; +} + +.editButton { + padding: var(--spacing-2-5) var(--spacing-4); + background-color: var(--color-bg-tertiary); + color: var(--color-text-secondary); + font-size: var(--font-size-sm); + font-weight: var(--font-weight-medium); + border: 1px solid var(--color-border-strong); + border-radius: var(--radius-md); + cursor: pointer; + transition: var(--transition-button-border); +} + +.editButton:hover { + background-color: var(--color-bg-secondary); +} + +.editButton:focus-visible { + outline: none; + box-shadow: var(--shadow-focus); +} + +.deleteButton { + padding: var(--spacing-2-5) var(--spacing-4); + background-color: var(--color-danger-bg); + color: var(--color-danger); + font-size: var(--font-size-sm); + font-weight: var(--font-weight-medium); + border: 1px solid var(--color-danger-border); + border-radius: var(--radius-md); + cursor: pointer; + transition: background-color var(--transition-normal); +} + +.deleteButton:hover:not(:disabled) { + background-color: var(--color-danger-bg-strong); +} + +.deleteButton:focus-visible { + outline: none; + box-shadow: var(--shadow-focus-danger); +} + +.cancelButton { + padding: var(--spacing-2-5) var(--spacing-4); + background-color: var(--color-bg-tertiary); + color: var(--color-text-secondary); + font-size: var(--font-size-sm); + font-weight: var(--font-weight-medium); + border: 1px solid var(--color-border-strong); + border-radius: var(--radius-md); + cursor: pointer; + transition: var(--transition-button-border); +} + +.cancelButton:hover:not(:disabled) { + background-color: var(--color-border); +} + +.cancelButton:focus-visible { + outline: none; + box-shadow: var(--shadow-focus); +} + +.cancelButton:disabled { + opacity: 0.5; + cursor: not-allowed; +} + +.confirmDeleteButton { + padding: var(--spacing-2-5) var(--spacing-4); + background-color: var(--color-danger); + color: var(--color-danger-text); + font-size: var(--font-size-sm); + font-weight: var(--font-weight-medium); + border: none; + border-radius: var(--radius-md); + cursor: pointer; + transition: background-color var(--transition-normal); +} + +.confirmDeleteButton:hover:not(:disabled) { + background-color: var(--color-danger-hover); +} + +.confirmDeleteButton:focus-visible { + outline: none; + box-shadow: var(--shadow-focus-danger); +} + +.confirmDeleteButton:disabled { + background-color: var(--color-text-placeholder); + cursor: not-allowed; +} + +/* ---- Modal ---- */ + +.modal { + position: fixed; + top: 0; + left: 0; + right: 0; + bottom: 0; + z-index: var(--z-modal); + display: flex; + align-items: center; + justify-content: center; +} + +.modalBackdrop { + position: absolute; + top: 0; + left: 0; + right: 0; + bottom: 0; + background-color: var(--color-overlay); +} + +.modalContent { + position: relative; + background: var(--color-bg-primary); + border-radius: var(--radius-lg); + box-shadow: var(--shadow-2xl); + padding: var(--spacing-6); + max-width: 28rem; + width: calc(100% - var(--spacing-8)); + margin: var(--spacing-4); +} + +.modalTitle { + font-size: var(--font-size-xl); + font-weight: var(--font-weight-semibold); + color: var(--color-text-primary); + margin: 0 0 var(--spacing-4) 0; +} + +.modalText { + font-size: var(--font-size-sm); + color: var(--color-text-secondary); + margin: 0 0 var(--spacing-3) 0; +} + +.modalWarning { + font-size: var(--font-size-sm); + color: var(--color-danger); + margin: 0 0 var(--spacing-6) 0; +} + +.modalActions { + display: flex; + gap: var(--spacing-3); + justify-content: flex-end; +} + +/* ============================================================ + * RESPONSIVE — Mobile (max 767px) + * ============================================================ */ + +@media (max-width: 767px) { + .container { + padding: var(--spacing-4); + } + + .content { + gap: var(--spacing-4); + } + + .pageTitle { + font-size: var(--font-size-2xl); + } + + .pageHeader { + flex-direction: column; + align-items: stretch; + } + + .pageActions { + flex-direction: row; + justify-content: stretch; + } + + .editButton, + .deleteButton { + flex: 1; + text-align: center; + min-height: 44px; + } + + /* Stats grid: single column */ + .statsGrid { + grid-template-columns: 1fr; + } + + .card { + padding: var(--spacing-4); + } + + /* Info rows stack */ + .infoRow { + flex-direction: column; + gap: var(--spacing-1); + } + + .infoLabel { + min-width: unset; + } + + /* Form rows stack */ + .formRow { + flex-direction: column; + } + + .formActions { + flex-direction: column; + align-items: stretch; + } + + .saveButton, + .formActions .cancelButton { + width: 100%; + text-align: center; + min-height: 44px; + } + + /* Modal */ + .modalContent { + margin: 0; + width: 100%; + max-width: 100%; + border-radius: var(--radius-lg) var(--radius-lg) 0 0; + position: fixed; + bottom: 0; + left: 0; + right: 0; + } + + .modalActions { + flex-direction: column-reverse; + } + + .modalActions .cancelButton, + .confirmDeleteButton { + width: 100%; + text-align: center; + } + + .errorActions { + flex-direction: column; + } +} + +/* ============================================================ + * RESPONSIVE — Tablet (768px – 1024px) + * ============================================================ */ + +@media (min-width: 768px) and (max-width: 1024px) { + .container { + padding: var(--spacing-6); + } + + /* Touch-friendly minimum heights */ + .button, + .secondaryButton, + .saveButton, + .editButton, + .deleteButton, + .cancelButton, + .confirmDeleteButton { + min-height: 44px; + } +} diff --git a/client/src/pages/VendorDetailPage/VendorDetailPage.test.tsx b/client/src/pages/VendorDetailPage/VendorDetailPage.test.tsx new file mode 100644 index 000000000..a5e198615 --- /dev/null +++ b/client/src/pages/VendorDetailPage/VendorDetailPage.test.tsx @@ -0,0 +1,765 @@ +/** + * @jest-environment jsdom + */ +import { jest, describe, it, expect, beforeEach } from '@jest/globals'; +import { screen, waitFor, render, within } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { MemoryRouter, Route, Routes } from 'react-router-dom'; +import type * as VendorsApiTypes from '../../lib/vendorsApi.js'; +import { ApiClientError } from '../../lib/apiClient.js'; +import type { VendorDetail } from '@cornerstone/shared'; + +// Mock the API module BEFORE importing the component +const mockFetchVendor = jest.fn(); +const mockUpdateVendor = jest.fn(); +const mockDeleteVendor = jest.fn(); + +jest.unstable_mockModule('../../lib/vendorsApi.js', () => ({ + fetchVendors: jest.fn(), + fetchVendor: mockFetchVendor, + createVendor: jest.fn(), + updateVendor: mockUpdateVendor, + deleteVendor: mockDeleteVendor, +})); + +describe('VendorDetailPage', () => { + let VendorDetailPage: React.ComponentType; + + // ─── Sample Data ───────────────────────────────────────────────────────── + + const sampleVendor: VendorDetail = { + id: 'vendor-1', + name: 'Smith Plumbing', + specialty: 'Plumbing', + phone: '+1 555-1234', + email: 'smith@plumbing.com', + address: '123 Main St, Springfield', + notes: 'Very reliable contractor.', + createdBy: { id: 'user-1', displayName: 'Admin User', email: 'admin@example.com' }, + invoiceCount: 3, + outstandingBalance: 2500.0, + createdAt: '2026-01-01T00:00:00.000Z', + updatedAt: '2026-01-01T00:00:00.000Z', + }; + + const vendorWithNoStats: VendorDetail = { + id: 'vendor-2', + name: 'Jones Electric', + specialty: null, + phone: null, + email: null, + address: null, + notes: null, + createdBy: null, + invoiceCount: 0, + outstandingBalance: 0, + createdAt: '2026-01-02T00:00:00.000Z', + updatedAt: '2026-01-02T00:00:00.000Z', + }; + + beforeEach(async () => { + if (!VendorDetailPage) { + const module = await import('./VendorDetailPage.js'); + VendorDetailPage = module.default; + } + + mockFetchVendor.mockReset(); + mockUpdateVendor.mockReset(); + mockDeleteVendor.mockReset(); + }); + + /** + * Renders the VendorDetailPage in a router context with the given vendor ID param. + */ + function renderPage(vendorId: string = 'vendor-1') { + return render( + + + } /> + Vendors List Page
} /> + + , + ); + } + + // ─── Loading state ───────────────────────────────────────────────────────── + + describe('loading state', () => { + it('shows loading indicator while fetching vendor', () => { + // Never resolves — stays in loading state + mockFetchVendor.mockReturnValueOnce(new Promise(() => {})); + + renderPage(); + + expect(screen.getByText(/loading vendor/i)).toBeInTheDocument(); + }); + + it('hides loading indicator after data loads', async () => { + mockFetchVendor.mockResolvedValueOnce(sampleVendor); + + renderPage(); + + await waitFor(() => { + expect(screen.queryByText(/loading vendor/i)).not.toBeInTheDocument(); + }); + }); + }); + + // ─── Vendor detail display ───────────────────────────────────────────────── + + describe('vendor detail display', () => { + it('renders vendor name as page heading', async () => { + mockFetchVendor.mockResolvedValueOnce(sampleVendor); + + renderPage(); + + await waitFor(() => { + expect( + screen.getByRole('heading', { name: /smith plumbing/i, level: 1 }), + ).toBeInTheDocument(); + }); + }); + + it('renders vendor specialty', async () => { + mockFetchVendor.mockResolvedValueOnce(sampleVendor); + + renderPage(); + + await waitFor(() => { + // Specialty appears both in the page subtitle and in the info list — use getAllByText + const specialtyElements = screen.getAllByText('Plumbing'); + expect(specialtyElements.length).toBeGreaterThan(0); + }); + }); + + it('renders stat card for invoice count', async () => { + mockFetchVendor.mockResolvedValueOnce(sampleVendor); + + renderPage(); + + await waitFor(() => { + expect(screen.getByText(/total invoices/i)).toBeInTheDocument(); + expect(screen.getByText('3')).toBeInTheDocument(); + }); + }); + + it('renders stat card for outstanding balance', async () => { + mockFetchVendor.mockResolvedValueOnce(sampleVendor); + + renderPage(); + + await waitFor(() => { + expect(screen.getByText(/outstanding balance/i)).toBeInTheDocument(); + // $2,500.00 formatted via Intl.NumberFormat + expect(screen.getByText(/\$2,500\.00/)).toBeInTheDocument(); + }); + }); + + it('renders $0.00 outstanding balance when vendor has no invoices', async () => { + mockFetchVendor.mockResolvedValueOnce(vendorWithNoStats); + + renderPage(); + + await waitFor(() => { + expect(screen.getByText(/\$0\.00/)).toBeInTheDocument(); + }); + }); + + it('renders vendor phone as a tel link', async () => { + mockFetchVendor.mockResolvedValueOnce(sampleVendor); + + renderPage(); + + await waitFor(() => { + const phoneLink = screen.getByRole('link', { name: '+1 555-1234' }); + expect(phoneLink).toHaveAttribute('href', 'tel:+1 555-1234'); + }); + }); + + it('renders vendor email as a mailto link', async () => { + mockFetchVendor.mockResolvedValueOnce(sampleVendor); + + renderPage(); + + await waitFor(() => { + const emailLink = screen.getByRole('link', { name: 'smith@plumbing.com' }); + expect(emailLink).toHaveAttribute('href', 'mailto:smith@plumbing.com'); + }); + }); + + it('renders vendor address', async () => { + mockFetchVendor.mockResolvedValueOnce(sampleVendor); + + renderPage(); + + await waitFor(() => { + expect(screen.getByText('123 Main St, Springfield')).toBeInTheDocument(); + }); + }); + + it('renders vendor notes', async () => { + mockFetchVendor.mockResolvedValueOnce(sampleVendor); + + renderPage(); + + await waitFor(() => { + expect(screen.getByText('Very reliable contractor.')).toBeInTheDocument(); + }); + }); + + it('renders createdBy display name', async () => { + mockFetchVendor.mockResolvedValueOnce(sampleVendor); + + renderPage(); + + await waitFor(() => { + expect(screen.getByText('Admin User')).toBeInTheDocument(); + }); + }); + + it('renders "—" for optional null fields', async () => { + mockFetchVendor.mockResolvedValueOnce(vendorWithNoStats); + + renderPage(); + + await waitFor(() => { + // Multiple dashes for null specialty, phone, email, address + const dashes = screen.getAllByText('—'); + expect(dashes.length).toBeGreaterThan(0); + }); + }); + + it('renders Edit and Delete buttons when not in edit mode', async () => { + mockFetchVendor.mockResolvedValueOnce(sampleVendor); + + renderPage(); + + await waitFor(() => { + expect(screen.getByRole('button', { name: /^edit$/i })).toBeInTheDocument(); + expect(screen.getByRole('button', { name: /^delete$/i })).toBeInTheDocument(); + }); + }); + + it('renders "Vendor Information" section heading', async () => { + mockFetchVendor.mockResolvedValueOnce(sampleVendor); + + renderPage(); + + await waitFor(() => { + expect(screen.getByRole('heading', { name: /vendor information/i })).toBeInTheDocument(); + }); + }); + + it('renders breadcrumb back link to Vendors list', async () => { + mockFetchVendor.mockResolvedValueOnce(sampleVendor); + + renderPage(); + + await waitFor(() => { + expect(screen.getByRole('button', { name: /vendors/i })).toBeInTheDocument(); + }); + }); + }); + + // ─── Error state ──────────────────────────────────────────────────────────── + + describe('error state', () => { + it('shows error alert with 404 message when vendor not found', async () => { + mockFetchVendor.mockRejectedValueOnce( + new ApiClientError(404, { code: 'NOT_FOUND', message: 'Vendor not found' }), + ); + + renderPage(); + + await waitFor(() => { + expect(screen.getByRole('alert')).toBeInTheDocument(); + expect(screen.getByText(/vendor not found.*deleted/i)).toBeInTheDocument(); + }); + }); + + it('shows API error message for non-404 errors', async () => { + mockFetchVendor.mockRejectedValueOnce( + new ApiClientError(500, { code: 'INTERNAL_ERROR', message: 'Database error' }), + ); + + renderPage(); + + await waitFor(() => { + expect(screen.getByRole('alert')).toBeInTheDocument(); + expect(screen.getByText('Database error')).toBeInTheDocument(); + }); + }); + + it('shows generic error message for network errors', async () => { + mockFetchVendor.mockRejectedValueOnce(new Error('Network error')); + + renderPage(); + + await waitFor(() => { + expect(screen.getByText(/failed to load vendor/i)).toBeInTheDocument(); + }); + }); + + it('shows "Back to Vendors" button on error', async () => { + mockFetchVendor.mockRejectedValueOnce( + new ApiClientError(404, { code: 'NOT_FOUND', message: 'Vendor not found' }), + ); + + renderPage(); + + await waitFor(() => { + expect(screen.getByRole('button', { name: /back to vendors/i })).toBeInTheDocument(); + }); + }); + + it('shows Retry button on error', async () => { + mockFetchVendor.mockRejectedValueOnce(new Error('Network error')); + + renderPage(); + + await waitFor(() => { + expect(screen.getByRole('button', { name: /retry/i })).toBeInTheDocument(); + }); + }); + + it('retries loading when Retry is clicked', async () => { + mockFetchVendor + .mockRejectedValueOnce(new Error('First failure')) + .mockResolvedValueOnce(sampleVendor); + + const user = userEvent.setup(); + renderPage(); + + await waitFor(() => { + expect(screen.getByRole('button', { name: /retry/i })).toBeInTheDocument(); + }); + + await user.click(screen.getByRole('button', { name: /retry/i })); + + await waitFor(() => { + expect( + screen.getByRole('heading', { name: /smith plumbing/i, level: 1 }), + ).toBeInTheDocument(); + }); + }); + }); + + // ─── Edit mode ────────────────────────────────────────────────────────────── + + describe('edit mode', () => { + it('enters edit mode when Edit button is clicked', async () => { + mockFetchVendor.mockResolvedValueOnce(sampleVendor); + + const user = userEvent.setup(); + renderPage(); + + await waitFor(() => { + expect(screen.getByRole('button', { name: /^edit$/i })).toBeInTheDocument(); + }); + + await user.click(screen.getByRole('button', { name: /^edit$/i })); + + // "Save Changes" form button should appear + expect(screen.getByRole('button', { name: /save changes/i })).toBeInTheDocument(); + }); + + it('pre-fills edit form with current vendor values', async () => { + mockFetchVendor.mockResolvedValueOnce(sampleVendor); + + const user = userEvent.setup(); + renderPage(); + + await waitFor(() => { + expect(screen.getByRole('button', { name: /^edit$/i })).toBeInTheDocument(); + }); + + await user.click(screen.getByRole('button', { name: /^edit$/i })); + + expect(screen.getByDisplayValue('Smith Plumbing')).toBeInTheDocument(); + expect(screen.getByDisplayValue('Plumbing')).toBeInTheDocument(); + expect(screen.getByDisplayValue('+1 555-1234')).toBeInTheDocument(); + expect(screen.getByDisplayValue('smith@plumbing.com')).toBeInTheDocument(); + expect(screen.getByDisplayValue('123 Main St, Springfield')).toBeInTheDocument(); + }); + + it('hides Edit and Delete buttons during editing', async () => { + mockFetchVendor.mockResolvedValueOnce(sampleVendor); + + const user = userEvent.setup(); + renderPage(); + + await waitFor(() => { + expect(screen.getByRole('button', { name: /^edit$/i })).toBeInTheDocument(); + }); + + await user.click(screen.getByRole('button', { name: /^edit$/i })); + + expect(screen.queryByRole('button', { name: /^edit$/i })).not.toBeInTheDocument(); + expect(screen.queryByRole('button', { name: /^delete$/i })).not.toBeInTheDocument(); + }); + + it('cancels edit mode when Cancel is clicked', async () => { + mockFetchVendor.mockResolvedValueOnce(sampleVendor); + + const user = userEvent.setup(); + renderPage(); + + await waitFor(() => { + expect(screen.getByRole('button', { name: /^edit$/i })).toBeInTheDocument(); + }); + + await user.click(screen.getByRole('button', { name: /^edit$/i })); + await user.click(screen.getByRole('button', { name: /cancel/i })); + + // Back to view mode — Edit button reappears + expect(screen.getByRole('button', { name: /^edit$/i })).toBeInTheDocument(); + expect(screen.queryByRole('button', { name: /save changes/i })).not.toBeInTheDocument(); + }); + + it('"Save Changes" is disabled when name is cleared', async () => { + mockFetchVendor.mockResolvedValueOnce(sampleVendor); + + const user = userEvent.setup(); + renderPage(); + + await waitFor(() => { + expect(screen.getByRole('button', { name: /^edit$/i })).toBeInTheDocument(); + }); + + await user.click(screen.getByRole('button', { name: /^edit$/i })); + + // Clear the name field + const nameInput = screen.getByDisplayValue('Smith Plumbing'); + await user.clear(nameInput); + + expect(screen.getByRole('button', { name: /save changes/i })).toBeDisabled(); + }); + + it('successfully saves changes and returns to view mode', async () => { + const updatedVendor: VendorDetail = { + ...sampleVendor, + name: 'Smith Plumbing Updated', + specialty: 'General Plumbing', + }; + + mockFetchVendor.mockResolvedValueOnce(sampleVendor); + mockUpdateVendor.mockResolvedValueOnce(updatedVendor); + + const user = userEvent.setup(); + renderPage(); + + await waitFor(() => { + expect(screen.getByRole('button', { name: /^edit$/i })).toBeInTheDocument(); + }); + + await user.click(screen.getByRole('button', { name: /^edit$/i })); + + const nameInput = screen.getByDisplayValue('Smith Plumbing'); + await user.clear(nameInput); + await user.type(nameInput, 'Smith Plumbing Updated'); + + await user.click(screen.getByRole('button', { name: /save changes/i })); + + await waitFor(() => { + expect(mockUpdateVendor).toHaveBeenCalledWith( + 'vendor-1', + expect.objectContaining({ name: 'Smith Plumbing Updated' }), + ); + }); + + // Back to view mode after save + await waitFor(() => { + expect(screen.queryByRole('button', { name: /save changes/i })).not.toBeInTheDocument(); + expect(screen.getByRole('button', { name: /^edit$/i })).toBeInTheDocument(); + }); + }); + + it('updates page heading after successful save', async () => { + const updatedVendor: VendorDetail = { + ...sampleVendor, + name: 'Smith Plumbing Co.', + }; + + mockFetchVendor.mockResolvedValueOnce(sampleVendor); + mockUpdateVendor.mockResolvedValueOnce(updatedVendor); + + const user = userEvent.setup(); + renderPage(); + + await waitFor(() => { + expect(screen.getByRole('button', { name: /^edit$/i })).toBeInTheDocument(); + }); + + await user.click(screen.getByRole('button', { name: /^edit$/i })); + + const nameInput = screen.getByDisplayValue('Smith Plumbing'); + await user.clear(nameInput); + await user.type(nameInput, 'Smith Plumbing Co.'); + + await user.click(screen.getByRole('button', { name: /save changes/i })); + + await waitFor(() => { + expect( + screen.getByRole('heading', { name: /smith plumbing co\./i, level: 1 }), + ).toBeInTheDocument(); + }); + }); + + it('shows edit error when save API fails', async () => { + mockFetchVendor.mockResolvedValueOnce(sampleVendor); + mockUpdateVendor.mockRejectedValueOnce( + new ApiClientError(400, { + code: 'VALIDATION_ERROR', + message: 'Vendor name must be between 1 and 200 characters', + }), + ); + + const user = userEvent.setup(); + renderPage(); + + await waitFor(() => { + expect(screen.getByRole('button', { name: /^edit$/i })).toBeInTheDocument(); + }); + + await user.click(screen.getByRole('button', { name: /^edit$/i })); + + await user.click(screen.getByRole('button', { name: /save changes/i })); + + await waitFor(() => { + expect(screen.getByRole('alert')).toBeInTheDocument(); + expect( + screen.getByText(/vendor name must be between 1 and 200 characters/i), + ).toBeInTheDocument(); + }); + }); + + it('shows generic edit error for non-ApiClientError failures', async () => { + mockFetchVendor.mockResolvedValueOnce(sampleVendor); + mockUpdateVendor.mockRejectedValueOnce(new Error('Network error')); + + const user = userEvent.setup(); + renderPage(); + + await waitFor(() => { + expect(screen.getByRole('button', { name: /^edit$/i })).toBeInTheDocument(); + }); + + await user.click(screen.getByRole('button', { name: /^edit$/i })); + + await user.click(screen.getByRole('button', { name: /save changes/i })); + + await waitFor(() => { + expect(screen.getByText(/failed to update vendor/i)).toBeInTheDocument(); + }); + }); + + it('stays in edit mode after save failure (does not close)', async () => { + mockFetchVendor.mockResolvedValueOnce(sampleVendor); + mockUpdateVendor.mockRejectedValueOnce( + new ApiClientError(500, { code: 'INTERNAL_ERROR', message: 'Server error' }), + ); + + const user = userEvent.setup(); + renderPage(); + + await waitFor(() => { + expect(screen.getByRole('button', { name: /^edit$/i })).toBeInTheDocument(); + }); + + await user.click(screen.getByRole('button', { name: /^edit$/i })); + + await user.click(screen.getByRole('button', { name: /save changes/i })); + + await waitFor(() => { + expect(screen.getByRole('alert')).toBeInTheDocument(); + }); + + // Should still be in edit mode + expect(screen.getByRole('button', { name: /save changes/i })).toBeInTheDocument(); + }); + }); + + // ─── Delete modal ──────────────────────────────────────────────────────────── + + describe('delete confirmation modal', () => { + it('shows delete modal when Delete button is clicked', async () => { + mockFetchVendor.mockResolvedValueOnce(sampleVendor); + + const user = userEvent.setup(); + renderPage(); + + await waitFor(() => { + expect(screen.getByRole('button', { name: /^delete$/i })).toBeInTheDocument(); + }); + + await user.click(screen.getByRole('button', { name: /^delete$/i })); + + expect(screen.getByRole('dialog')).toBeInTheDocument(); + expect(screen.getByRole('heading', { name: /delete vendor/i })).toBeInTheDocument(); + }); + + it('shows vendor name in the delete modal', async () => { + mockFetchVendor.mockResolvedValueOnce(sampleVendor); + + const user = userEvent.setup(); + renderPage(); + + await waitFor(() => { + expect(screen.getByRole('button', { name: /^delete$/i })).toBeInTheDocument(); + }); + + await user.click(screen.getByRole('button', { name: /^delete$/i })); + + const dialog = screen.getByRole('dialog'); + expect(dialog).toHaveTextContent('Smith Plumbing'); + }); + + it('closes delete modal when Cancel is clicked', async () => { + mockFetchVendor.mockResolvedValueOnce(sampleVendor); + + const user = userEvent.setup(); + renderPage(); + + await waitFor(() => { + expect(screen.getByRole('button', { name: /^delete$/i })).toBeInTheDocument(); + }); + + await user.click(screen.getByRole('button', { name: /^delete$/i })); + await user.click(screen.getByRole('button', { name: /cancel/i })); + + await waitFor(() => { + expect(screen.queryByRole('dialog')).not.toBeInTheDocument(); + }); + }); + + it('navigates to vendors list after successful deletion', async () => { + mockFetchVendor.mockResolvedValueOnce(sampleVendor); + mockDeleteVendor.mockResolvedValueOnce(undefined); + + const user = userEvent.setup(); + renderPage(); + + await waitFor(() => { + expect(screen.getByRole('button', { name: /^delete$/i })).toBeInTheDocument(); + }); + + await user.click(screen.getByRole('button', { name: /^delete$/i })); + + const dialog = screen.getByRole('dialog'); + await user.click(within(dialog).getByRole('button', { name: /delete vendor/i })); + + await waitFor(() => { + expect(mockDeleteVendor).toHaveBeenCalledWith('vendor-1'); + }); + + // Should navigate to the vendors list (the route stub renders "Vendors List Page") + await waitFor(() => { + expect(screen.getByText('Vendors List Page')).toBeInTheDocument(); + }); + }); + + it('shows VENDOR_IN_USE error (409) in delete modal', async () => { + mockFetchVendor.mockResolvedValueOnce(sampleVendor); + mockDeleteVendor.mockRejectedValueOnce( + new ApiClientError(409, { + code: 'VENDOR_IN_USE', + message: 'Vendor is in use and cannot be deleted', + details: { invoiceCount: 3, workItemCount: 0 }, + }), + ); + + const user = userEvent.setup(); + renderPage(); + + await waitFor(() => { + expect(screen.getByRole('button', { name: /^delete$/i })).toBeInTheDocument(); + }); + + await user.click(screen.getByRole('button', { name: /^delete$/i })); + + const dialog = screen.getByRole('dialog'); + await user.click(within(dialog).getByRole('button', { name: /delete vendor/i })); + + await waitFor(() => { + expect(within(dialog).getByRole('alert')).toBeInTheDocument(); + expect(within(dialog).getByText(/referenced by one or more invoices/i)).toBeInTheDocument(); + }); + }); + + it('hides "Delete Vendor" confirm button after 409 error', async () => { + mockFetchVendor.mockResolvedValueOnce(sampleVendor); + mockDeleteVendor.mockRejectedValueOnce( + new ApiClientError(409, { + code: 'VENDOR_IN_USE', + message: 'Vendor is in use and cannot be deleted', + details: { invoiceCount: 1, workItemCount: 0 }, + }), + ); + + const user = userEvent.setup(); + renderPage(); + + await waitFor(() => { + expect(screen.getByRole('button', { name: /^delete$/i })).toBeInTheDocument(); + }); + + await user.click(screen.getByRole('button', { name: /^delete$/i })); + + const dialog = screen.getByRole('dialog'); + await user.click(within(dialog).getByRole('button', { name: /delete vendor/i })); + + await waitFor(() => { + expect(within(dialog).getByRole('alert')).toBeInTheDocument(); + }); + + expect( + within(dialog).queryByRole('button', { name: /delete vendor/i }), + ).not.toBeInTheDocument(); + }); + + it('shows generic delete error for non-409 failures', async () => { + mockFetchVendor.mockResolvedValueOnce(sampleVendor); + mockDeleteVendor.mockRejectedValueOnce(new Error('Network error')); + + const user = userEvent.setup(); + renderPage(); + + await waitFor(() => { + expect(screen.getByRole('button', { name: /^delete$/i })).toBeInTheDocument(); + }); + + await user.click(screen.getByRole('button', { name: /^delete$/i })); + + const dialog = screen.getByRole('dialog'); + await user.click(within(dialog).getByRole('button', { name: /delete vendor/i })); + + await waitFor(() => { + expect(within(dialog).getByText(/failed to delete vendor/i)).toBeInTheDocument(); + }); + }); + }); + + // ─── Invoices section ───────────────────────────────────────────────────── + + describe('invoices section', () => { + it('renders "Invoices" section heading', async () => { + mockFetchVendor.mockResolvedValueOnce(sampleVendor); + + renderPage(); + + await waitFor(() => { + expect(screen.getByRole('heading', { name: /^invoices$/i })).toBeInTheDocument(); + }); + }); + + it('shows "coming soon" placeholder text for invoices', async () => { + mockFetchVendor.mockResolvedValueOnce(sampleVendor); + + renderPage(); + + await waitFor(() => { + expect(screen.getByText(/coming soon/i)).toBeInTheDocument(); + }); + }); + }); +}); diff --git a/client/src/pages/VendorDetailPage/VendorDetailPage.tsx b/client/src/pages/VendorDetailPage/VendorDetailPage.tsx new file mode 100644 index 000000000..e8ded977c --- /dev/null +++ b/client/src/pages/VendorDetailPage/VendorDetailPage.tsx @@ -0,0 +1,491 @@ +import { useState, useEffect, type FormEvent } from 'react'; +import { useParams, useNavigate } from 'react-router-dom'; +import type { VendorDetail, UpdateVendorRequest } from '@cornerstone/shared'; +import { fetchVendor, updateVendor, deleteVendor } from '../../lib/vendorsApi.js'; +import { ApiClientError } from '../../lib/apiClient.js'; +import styles from './VendorDetailPage.module.css'; + +function formatCurrency(amount: number): string { + return new Intl.NumberFormat('en-US', { + style: 'currency', + currency: 'USD', + minimumFractionDigits: 2, + maximumFractionDigits: 2, + }).format(amount); +} + +export function VendorDetailPage() { + const { id } = useParams<{ id: string }>(); + const navigate = useNavigate(); + + const [vendor, setVendor] = useState(null); + const [isLoading, setIsLoading] = useState(true); + const [error, setError] = useState(null); + + // Edit state + const [isEditing, setIsEditing] = useState(false); + const [editForm, setEditForm] = useState({}); + const [isUpdating, setIsUpdating] = useState(false); + const [editError, setEditError] = useState(''); + + // Delete state + const [showDeleteConfirm, setShowDeleteConfirm] = useState(false); + const [isDeleting, setIsDeleting] = useState(false); + const [deleteError, setDeleteError] = useState(''); + + useEffect(() => { + if (!id) return; + void loadVendor(); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [id]); + + const loadVendor = async () => { + if (!id) return; + setIsLoading(true); + setError(null); + + try { + const data = await fetchVendor(id); + setVendor(data); + } catch (err) { + if (err instanceof ApiClientError) { + if (err.statusCode === 404) { + setError('Vendor not found. It may have been deleted.'); + } else { + setError(err.error.message); + } + } else { + setError('Failed to load vendor. Please try again.'); + } + } finally { + setIsLoading(false); + } + }; + + const startEdit = () => { + if (!vendor) return; + setEditForm({ + name: vendor.name, + specialty: vendor.specialty ?? '', + phone: vendor.phone ?? '', + email: vendor.email ?? '', + address: vendor.address ?? '', + notes: vendor.notes ?? '', + }); + setEditError(''); + setIsEditing(true); + }; + + const cancelEdit = () => { + setIsEditing(false); + setEditError(''); + }; + + const handleUpdate = async (event: FormEvent) => { + event.preventDefault(); + if (!vendor || !id) return; + + const trimmedName = (editForm.name ?? '').trim(); + if (!trimmedName) { + setEditError('Vendor name is required.'); + return; + } + if (trimmedName.length > 200) { + setEditError('Vendor name must be 200 characters or less.'); + return; + } + + setIsUpdating(true); + setEditError(''); + + try { + const updated = await updateVendor(id, { + name: trimmedName, + specialty: (editForm.specialty as string)?.trim() || null, + phone: (editForm.phone as string)?.trim() || null, + email: (editForm.email as string)?.trim() || null, + address: (editForm.address as string)?.trim() || null, + notes: (editForm.notes as string)?.trim() || null, + }); + setVendor(updated); + setIsEditing(false); + } catch (err) { + if (err instanceof ApiClientError) { + setEditError(err.error.message); + } else { + setEditError('Failed to update vendor. Please try again.'); + } + } finally { + setIsUpdating(false); + } + }; + + const openDeleteConfirm = () => { + setDeleteError(''); + setShowDeleteConfirm(true); + }; + + const closeDeleteConfirm = () => { + if (!isDeleting) { + setShowDeleteConfirm(false); + setDeleteError(''); + } + }; + + const handleDelete = async () => { + if (!id) return; + + setIsDeleting(true); + setDeleteError(''); + + try { + await deleteVendor(id); + navigate('/budget/vendors'); + } catch (err) { + if (err instanceof ApiClientError) { + if (err.statusCode === 409) { + setDeleteError( + 'This vendor cannot be deleted because they are referenced by one or more invoices.', + ); + } else { + setDeleteError(err.error.message); + } + } else { + setDeleteError('Failed to delete vendor. Please try again.'); + } + } finally { + setIsDeleting(false); + } + }; + + if (isLoading) { + return ( +
+
Loading vendor...
+
+ ); + } + + if (error || !vendor) { + return ( +
+
+

Error

+

{error ?? 'Vendor not found.'}

+
+ + +
+
+
+ ); + } + + return ( +
+
+ {/* Back navigation */} +
+ + + {vendor.name} +
+ + {/* Page heading */} +
+
+

{vendor.name}

+ {vendor.specialty && {vendor.specialty}} +
+
+ {!isEditing && ( + <> + + + + )} +
+
+ + {/* Stats cards */} +
+
+ Total Invoices + {vendor.invoiceCount} +
+
+ Outstanding Balance + 0 ? styles.statValueDanger : ''}`} + > + {formatCurrency(vendor.outstandingBalance)} + +
+
+ + {/* Info card — view or edit */} +
+
+

Vendor Information

+
+ + {isEditing ? ( +
+ {editError && ( +
+ {editError} +
+ )} + +
+ + setEditForm({ ...editForm, name: e.target.value })} + className={styles.input} + maxLength={200} + disabled={isUpdating} + autoFocus + /> +
+ +
+ + setEditForm({ ...editForm, specialty: e.target.value })} + className={styles.input} + placeholder="e.g., Plumbing, Electrical, Roofing" + maxLength={100} + disabled={isUpdating} + /> +
+ +
+
+ + setEditForm({ ...editForm, phone: e.target.value })} + className={styles.input} + placeholder="e.g., +1 555-123-4567" + maxLength={50} + disabled={isUpdating} + /> +
+
+ + setEditForm({ ...editForm, email: e.target.value })} + className={styles.input} + placeholder="e.g., contact@vendor.com" + maxLength={255} + disabled={isUpdating} + /> +
+
+ +
+ + setEditForm({ ...editForm, address: e.target.value })} + className={styles.input} + placeholder="e.g., 123 Main St, Springfield, IL 62701" + maxLength={500} + disabled={isUpdating} + /> +
+ +
+ +