From a4b09eb0ad631bc60c7938416492654faa0b0c57 Mon Sep 17 00:00:00 2001 From: Adam Moussa <166072409+amoussa1229@users.noreply.github.com> Date: Mon, 13 Jul 2026 17:00:45 -0400 Subject: [PATCH 1/4] =?UTF-8?q?docs:=20SHOC-alignment=20Phase=201=20?= =?UTF-8?q?=E2=80=94=20ADRs,=20governance=20files,=20doc=20corrections=20(?= =?UTF-8?q?#220)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add ADR 0002 (SHOC merge boundary: separate backend services, shared conventions) and ADR 0003 (adopt SHOC design system + UI/UX layout) - Add CODEOWNERS (internal-dev) and PR template (Summary/Test plan/Jira/ docs-current checklist + contract-table convention) - Fix stale facts in README/CLAUDE.md: MUI v7→v9, RN 0.85→0.86, OpenSearch Serverless/oss-index-creator → Aurora pgvector/ aurora-pgvector-init (ADR 0001), test counts 149→186 (123 xUnit / 26 vitest / 37 pytest), deploy triggers are workflow_dispatch-only, reusable workflow refs float on @main, aws-cdk-lib version claim replaced with Dependabot-maintained note --- .github/CODEOWNERS | 2 + .github/pull_request_template.md | 21 +++++++ CLAUDE.md | 18 ++++-- README.md | 44 +++++++------- docs/adr/0002-shoc-merge-boundary.md | 62 ++++++++++++++++++++ docs/adr/0003-shoc-design-system-adoption.md | 49 ++++++++++++++++ 6 files changed, 167 insertions(+), 29 deletions(-) create mode 100644 .github/CODEOWNERS create mode 100644 .github/pull_request_template.md create mode 100644 docs/adr/0002-shoc-merge-boundary.md create mode 100644 docs/adr/0003-shoc-design-system-adoption.md diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS new file mode 100644 index 0000000..db61049 --- /dev/null +++ b/.github/CODEOWNERS @@ -0,0 +1,2 @@ +# Default: all changes require internal-dev review +* @Sea-Haven-Industries/internal-dev diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md new file mode 100644 index 0000000..1072bdc --- /dev/null +++ b/.github/pull_request_template.md @@ -0,0 +1,21 @@ +## Summary + + + +## Test plan + + + +## Jira / tracking + + + +## Checklist + +- [ ] Docs current — README / CLAUDE.md / ADRs / Confluence updated if this changes architecture, endpoints, or behavior +- [ ] If an endpoint shape changed: contract table included below and `shared/api-contracts` updated + + diff --git a/CLAUDE.md b/CLAUDE.md index fa96f5a..5d761bd 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -6,10 +6,10 @@ Proposal management platform for Sea Haven Industries. Dispatchers submit servic ## Architecture -- **api/**: .NET 8 API, EF Core, PostgreSQL, Cognito JWT auth -- **web/**: React 19 + MUI v7 SPA, Vite, CloudFront + S3 -- **mobile/**: React Native 0.85 iOS app, offline-capable, Hermes -- **lambdas/**: Python 3.12 Lambdas (ARM64): pdf-extract, pdf-generate, library-ingest, suggestions, oss-index-creator +- **api/**: .NET 8 API, EF Core, PostgreSQL (Aurora Serverless v2), Cognito JWT auth +- **web/**: React 19 + MUI v9 SPA, Vite, CloudFront + S3 +- **mobile/**: React Native 0.86 iOS app, offline-capable, Hermes +- **lambdas/**: Python 3.12 Lambdas (ARM64): pdf-extract, pdf-generate, library-ingest, suggestions, aurora-pgvector-init - **infra/**: CDK TypeScript (foundation-stack, compute-stack, frontend-stack) - **shared/**: Shared TypeScript API contracts - **scripts/**: Local dev helpers @@ -19,6 +19,12 @@ Proposal management platform for Sea Haven Industries. Dispatchers submit servic External: Cognito JWT via API Gateway (web + mobile client IDs, groups: dispatchers/admins/sysadmins) Internal: Lambdas → .NET Function URL with Secrets Manager API key via custom middleware +## Key Decisions (ADRs in docs/adr/) + +- **ADR 0001**: Bedrock KB vector store is Aurora PostgreSQL + pgvector (replaced OpenSearch Serverless; `oss-index-creator` Lambda replaced by `aurora-pgvector-init`) +- **ADR 0002**: SHOC merge boundary — backends stay separate services permanently (PostgreSQL + Cognito here; SQL Server + ASP.NET Identity in SHOC); consolidation converges on conventions/layers/service patterns, never on platform +- **ADR 0003**: SHOC dev's design system + UI/UX layout is canonical (Montserrat/DM Sans, primary #1c75bc, 244px sidebar, CSS-variable single-token-source consumed by MUI via getCssVar); the old Nunito/#0c4f6f canon and the interim "Sea Haven Ops" Inter/#2563EB theme are superseded + ## Request Flow 1. Dispatcher submits proposal (web/mobile) → InReview (no Draft stage) @@ -29,7 +35,7 @@ Internal: Lambdas → .NET Function URL with Secrets Manager API key via custom ## Infrastructure -AWS us-east-1, RDS PostgreSQL 15, S3, SQS+DLQ, Cognito+Google OAuth, OpenSearch Serverless, Bedrock KB, GitHub Actions OIDC, CloudFront+S3 OAC +AWS us-east-1, Aurora PostgreSQL 15 Serverless v2 (RDS Data API + pgvector), S3, SQS+DLQ, Cognito+Google OAuth, Bedrock KB (Aurora pgvector store — ADR 0001), GitHub Actions OIDC, CloudFront+S3 OAC ## Agent Delegation Rules @@ -58,7 +64,7 @@ AWS us-east-1, RDS PostgreSQL 15, S3, SQS+DLQ, Cognito+Google OAuth, OpenSearch ## Audit Status -AUDIT-REPORT.md completed 2026-05-27. 5 Critical, 36 High, 75+ Medium, 60+ Low findings. Phase 1-5 complete: all Critical/High fixed, 17 Medium fixed, CI runs 108 tests. +AUDIT-REPORT.md completed 2026-05-27. 5 Critical, 36 High, 75+ Medium, 60+ Low findings. Phase 1-5 complete: all Critical/High fixed, 17 Medium fixed, CI runs 186 tests (123 xUnit, 26 vitest, 37 pytest). ### Critical Findings (fix first) diff --git a/README.md b/README.md index 879b0cb..2c4dde6 100644 --- a/README.md +++ b/README.md @@ -13,9 +13,9 @@ Internal proposal management platform for Sea Haven Industries. Dispatchers subm Monorepo with five primary services: - **.NET 8 API** -- Clean Architecture REST API hosted on Lambda behind API Gateway (JWT-authorized) with Function URL (AWS_IAM) for internal access -- **React 19 Web** -- MUI v7 admin/dispatcher workspace served via CloudFront + S3 +- **React 19 Web** -- MUI v9 admin/dispatcher workspace served via CloudFront + S3 - **React Native Mobile** -- iOS-first field app for dispatchers (offline-capable) -- **Python Lambdas** -- PDF extraction, PDF generation, library ingestion, AI suggestions, AOSS index provisioning +- **Python Lambdas** -- PDF extraction, PDF generation, library ingestion, AI suggestions, Aurora pgvector bootstrap - **CDK Infrastructure** -- Three TypeScript stacks managing all AWS resources ## Repository Structure @@ -23,8 +23,8 @@ Monorepo with five primary services: ``` proposal-system/ ├── api/ .NET 8 Web API (Lambda-hosted, EF Core + PostgreSQL) -├── web/ React 19 + MUI v7 + Vite frontend -├── mobile/ React Native 0.85 iOS app +├── web/ React 19 + MUI v9 + Vite frontend +├── mobile/ React Native 0.86 iOS app ├── lambdas/ Python 3.12 processing functions (arm64) ├── infra/ CDK TypeScript (3 stacks) ├── shared/ TypeScript API contracts (shared between web + mobile) @@ -38,11 +38,11 @@ proposal-system/ | Component | Technologies | |---|---| | API | .NET 8, ASP.NET Core, EF Core + Npgsql, FluentValidation, Cognito JWT, Amazon.Lambda.AspNetCoreServer | -| Web | React 19, TypeScript, MUI v7, Vite, Redux Toolkit, TanStack Query, axios | -| Mobile | React Native CLI 0.85, React 19, React Native Paper, React Navigation, react-native-app-auth (PKCE), amazon-cognito-identity-js (SRP), Keychain, offline draft queue | +| Web | React 19, TypeScript, MUI v9, Vite, Redux Toolkit, TanStack Query, axios | +| Mobile | React Native CLI 0.86, React 19, React Native Paper, React Navigation, react-native-app-auth (PKCE), amazon-cognito-identity-js (SRP), Keychain, offline draft queue | | Lambdas | Python 3.12, arm64, pdfplumber, reportlab, httpx, boto3 | -| Infrastructure | CDK TypeScript (aws-cdk-lib 2.253.1) | -| AI/RAG | Bedrock Knowledge Base (Titan Embeddings v2), OpenSearch Serverless (VPC-only), Claude Sonnet via Bedrock cross-region inference | +| Infrastructure | CDK TypeScript (aws-cdk-lib pinned exact, kept current by Dependabot) | +| AI/RAG | Bedrock Knowledge Base (Titan Embeddings v2), Aurora PostgreSQL + pgvector vector store (see ADR 0001), Claude Sonnet via Bedrock cross-region inference | | Auth | Cognito User Pool + Google OAuth IdP (groups: dispatchers, admins, sysadmins) | ## AWS Resources @@ -51,8 +51,8 @@ All resources are in **us-east-1** (account 328440206208). | CDK Stack | Key Resources | |---|---| -| `proposal-system-foundation` | RDS PostgreSQL 15 (t4g.small), S3 buckets, SQS queue + DLQ, Cognito user pool, Secrets Manager | -| `proposal-system-compute` | API Gateway HTTP API (JWT authorizer + access logging), .NET 8 API Lambda + Function URL (AWS_IAM), Python Lambdas (pdf-extract, pdf-generate, library-ingest, suggestions, oss-index-creator), OpenSearch Serverless collection (VPC endpoint), Bedrock KB | +| `proposal-system-foundation` | Aurora PostgreSQL 15 Serverless v2 (RDS Data API, pgvector), S3 buckets, SQS queue + DLQ, Cognito user pool, Secrets Manager | +| `proposal-system-compute` | API Gateway HTTP API (JWT authorizer + access logging), .NET 8 API Lambda + Function URL (AWS_IAM), Python Lambdas (pdf-extract, pdf-generate, library-ingest, suggestions, aurora-pgvector-init bootstrap), Bedrock KB (Aurora pgvector store) | | `proposal-system-frontend` | CloudFront distribution (S3 OAC) | | Resource Type | Names | @@ -118,17 +118,17 @@ Seven parallel jobs calling org reusable workflows: | Job | Workflow | What it checks | |---|---|---| -| .NET Build & Test | `ci-dotnet.yaml` | Restore, build, test the API solution (104 xUnit tests) | +| .NET Build & Test | `ci-dotnet.yaml` | Restore, build, test the API solution (123 xUnit tests) | | Web Frontend Check | `ci-typescript-cdk.yaml` | TypeScript typecheck for web | -| Web Tests | `ci-typescript-cdk.yaml` | vitest suite (26 tests — auth, interceptors, components) | +| Web Tests | inline job | vitest suite (26 tests — auth, interceptors, components) | | Mobile Typecheck | `ci-typescript-cdk.yaml` | TypeScript typecheck for mobile | | Python Lint | `ci-python-sam.yaml` | ruff check + format on lambdas/ | -| Python Tests | `ci-python-sam.yaml` | pytest suite (19 tests — pdf-generate, suggestions handlers) | +| Python Tests | inline job | pytest suite (37 tests — pdf-generate, suggestions, library-ingest, internal API signing) | | CDK Synth | `ci-typescript-cdk.yaml` | Synthesize CDK stacks (includes .NET publish) | -### Deploy (on push to main) +### Deploy (manual) -Calls `cd-cdk.yaml` reusable workflow: +Auto-deploy on push to main is currently disabled (PR #167) — both deploy workflows run via `workflow_dispatch` from the Actions tab. `deploy.yaml` calls the `cd-cdk.yaml` reusable workflow: 1. Publishes .NET 8 API and Python Lambdas 2. Runs `cdk deploy --all` 3. Executes `scripts/post-deploy.sh` (builds web, syncs to S3, invalidates CloudFront) @@ -139,9 +139,7 @@ Deploy uses OIDC role `githubdeploy-proposal-system`. Concurrency group prevents Workflow: `deploy-mobile.yaml` -- builds and uploads to TestFlight via `cd-mobile-ios.yaml` reusable workflow on `macos-26`. -Triggers: -- **Automatic**: push to `main` with changes in `mobile/**` -- **Manual**: `workflow_dispatch` for on-demand builds +Trigger: **manual only** (`workflow_dispatch`) while the app is pre-V1, to save macOS runner cost. ## Mobile iOS @@ -186,13 +184,13 @@ Failed SQS messages are reported via `batchItemFailures` and retried up to 3 tim ## Testing -149 tests across three stacks, all run in CI on every PR: +186 tests across three stacks, all run in CI on every PR: | Suite | Framework | Count | Coverage | |---|---|---|---| -| .NET API | xUnit | 104 | State machine transitions, authorization attributes, middleware, validators, ProposalNumberGenerator, LineItemService state guards | +| .NET API | xUnit | 123 | State machine transitions, authorization attributes, middleware, validators, ProposalNumberGenerator, LineItemService state guards | | Web | vitest | 26 | ProtectedRoute, RoleGuard, API client interceptor (401 logout, token attachment) | -| Python Lambdas | pytest | 19 | pdf-generate and suggestions handler contracts | +| Python Lambdas | pytest | 37 | pdf-generate, suggestions, library-ingest handler contracts, internal API signing | ```bash cd api && dotnet test # .NET tests @@ -207,7 +205,7 @@ Hardening applied across all layers (see AUDIT-REPORT.md for full details): - **Auth:** Cognito JWT validation with audience check, startup fails if auth not configured, DevMode gated to `IsDevelopment()` - **API:** FluentValidation on all DTOs, generic error responses (no stack traces or config leaks), structured audit logging with before/after diffs - **Function URL:** AWS_IAM auth + internal API key (two-layer defense) -- **Infrastructure:** S3 `enforceSSL` + `BLOCK_ALL`, SQS managed encryption, OpenSearch VPC-only, Cognito optional TOTP MFA, API Gateway access logging +- **Infrastructure:** S3 `enforceSSL` + `BLOCK_ALL`, SQS managed encryption, Aurora in private subnets, Cognito optional TOTP MFA, API Gateway access logging - **Lambdas:** Prompt injection sanitization, PDF size limits, numeric validation on AI suggestions, S3 key sanitization, idempotent SQS processing -- **CI/CD:** OIDC (no long-lived credentials), SHA-pinned workflow refs, `--require-approval broadening` on local deploys +- **CI/CD:** OIDC (no long-lived credentials), org reusable workflows (`@main`), `--require-approval broadening` on local deploys - **Web:** sessionStorage for tokens (not localStorage), error boundaries, role guards on all admin routes, 401 interceptor clears auth state diff --git a/docs/adr/0002-shoc-merge-boundary.md b/docs/adr/0002-shoc-merge-boundary.md new file mode 100644 index 0000000..c603636 --- /dev/null +++ b/docs/adr/0002-shoc-merge-boundary.md @@ -0,0 +1,62 @@ +# ADR 0002 — SHOC merge boundary: separate backend services, shared conventions + +- **Status:** Accepted (2026-07-13) +- **Decision owner:** Adam Moussa +- **Scope:** `proposal-system` ↔ SHOC (`shoc-backend` / `shoc-frontend-new`) consolidation strategy + +## Context + +proposal-system was built as a standalone service deliberately mirroring SHOC's +architecture for a clean future merge. As of 2026-07-13 the two projects diverge on +platform fundamentals: + +| Axis | proposal-system | SHOC | +|---|---|---| +| Database | Aurora PostgreSQL (pgvector) via EF Core/Npgsql | SQL Server via EF Core | +| Identity | Cognito (hosted UI, groups, web + mobile clients) | ASP.NET Identity + custom JWT issuance | +| API layout | 4-project Clean Architecture (Api/Application/Infrastructure/Domain) | Single API project, services injecting DbContext | +| Error contract | RFC 7807 ProblemDetails, string enums | Typed-exception codes per controller, numeric enums | +| Hosting | Lambda behind API Gateway (CDK) | Elastic Beanstalk (external-dev account) | + +A full platform alignment (engine migration, auth migration, rehosting) would cost +weeks, carry data-migration risk, and deliver no user value. + +## Decision + +**The backends remain separate services permanently. Consolidation converges on +conventions, layers, and service patterns — never on platform.** + +Concretely: + +1. **No database engine migration** in either direction. PostgreSQL stays here; + SQL Server stays in SHOC. +2. **No identity migration now.** Cognito stays here. At consolidation time, identity + converges on Cognito (or a federation layer in front of both) — not on + ASP.NET Identity, which would regress to self-managed credentials and reintroduce + the remediated WEB-C1 token-theft class (SHOC currently stores JWTs in + localStorage). +3. **Conventions converge** (tracked by the 2026-07 SHOC-alignment plan): + - Frontend: SHOC's design system + UI/UX layout (ADR 0003), domain layering + (`src/domain//{api,schemas,mappers,types,use-cases}`), TanStack Query, + zod, react-hook-form. + - API contracts: shared TypeScript contracts package + zod schemas; ProblemDetails + with machine-readable business `code` extensions (SHOC's error-code vocabulary, + proposal-system's envelope). + - Mutation semantics: optimistic concurrency with SHOC's 409-plus-currentState + response shape; staged audit-trail entries persisted at one SaveChanges boundary. + - Process: org reusable CI callers, CODEOWNERS, PR template, conventional commits. +4. **Layering stays Clean Architecture here.** SHOC's flatter service style is not + adopted; if SHOC restructures at consolidation, this repo's 4-project layout is the + reference shape. + +## Consequences + +- The eventual "merge" is a monorepo consolidation of independently deployable + services sharing a frontend architecture and wire conventions — not a single + backend. +- A merged frontend can treat both APIs identically for errors (ProblemDetails + + `code`) and conflicts (409 + currentState) once alignment phases 3/6 land. +- Anything requiring one database across both domains (cross-domain reporting, + shared entities) must go through APIs, not shared tables. +- SHOC's localStorage JWT + CORS `*` are flagged to the SHOC team as findings; they + are not constraints on this repo. diff --git a/docs/adr/0003-shoc-design-system-adoption.md b/docs/adr/0003-shoc-design-system-adoption.md new file mode 100644 index 0000000..741dade --- /dev/null +++ b/docs/adr/0003-shoc-design-system-adoption.md @@ -0,0 +1,49 @@ +# ADR 0003 — Adopt the SHOC design system and UI/UX layout + +- **Status:** Accepted (2026-07-13) +- **Decision owner:** Adam Moussa +- **Scope:** `web/` theming, layout shell, and all future proposal-system UI work + +## Context + +Three design-token sets existed across Sea Haven web apps as of 2026-07-13: + +1. **Old canon** — Nunito, primary `#0c4f6f`, 220px sidebar, `#f4f6f7` background + (what SHOC's stale `docs/DESIGN_SYSTEM.md` still describes). +2. **SHOC dev's new system** (shoc-frontend-new PR #17) — Montserrat (display) / + DM Sans (body) / JetBrains Mono, primary `#1c75bc`, navy `#262262`, page background + `#f9fafb`, 244px sidebar (76px collapsed), 64px gradient topbar + (`#1b1f52 → #1c4f8f → #1c75bc`), defined in a single CSS-variable token file + (`src/styles/theme.css`) consumed by both MUI (`getCssVar` → `createTheme`) and + Tailwind v4. +3. **proposal-system's "Sea Haven Ops" theme** — Inter, accent `#2563EB`, + navy-900 AppBar, 216px drawer (`web/src/theme.ts`). + +## Decision + +**SHOC dev's new design system and UI/UX layout (set 2) is the canonical Sea Haven +standard.** proposal-system adopts it fully: + +- **Tokens:** mirror shoc-frontend-new dev's `theme.css` values and structure + (fonts, palette, radii, spacing, shadows). +- **Mechanism:** single CSS-variable token file is the one source of truth; + `web/src/theme.ts` becomes a thin `getCssVar` → MUI `createTheme` adapter matching + SHOC's `mui-theme.ts`. Token changes are values-only edits, never theme rewrites. +- **Layout shell:** SHOC's admin shell dimensions and treatment — 244px/76px-collapse + sidebar, 64px gradient topbar, avatar initials. +- **Component library:** MUI only. Tailwind (SHOC runs a MUI + Tailwind v4 hybrid off + the same CSS variables) is **not** adopted here pre-consolidation; the shared + CSS-variable source keeps that door open. + +The reference is shoc-frontend-new's **`dev` branch** at adoption time; subsequent +SHOC token changes should be ported as values-only updates. + +## Consequences + +- The "Sea Haven Ops" theme (Inter/#2563EB) is retired; alignment plan Phase 2 + implements the port. +- Visual verification for Phase 2 compares proposal-system screens side-by-side with + SHOC dev screens, not with the old canon. +- The superseded Nunito/`#0c4f6f` tokens must not be reintroduced anywhere. +- At monorepo consolidation, both frontends already share token structure, so a + single `theme.css` can serve both. From 8ce2a5cd8fbf9ae53cf5c38c1a053adb49f3a078 Mon Sep 17 00:00:00 2001 From: Adam Moussa <166072409+amoussa1229@users.noreply.github.com> Date: Mon, 13 Jul 2026 19:06:51 -0400 Subject: [PATCH 2/4] feat(web): adopt SHOC design system and shell layout (ADR 0003) (#221) Port shoc-frontend-new dev's design system with its CSS-variable single-token-source mechanism: - src/styles/theme.css: SHOC token file ported verbatim (Montserrat/ DM Sans/JetBrains Mono, primary #1c75bc, navy #262262, full radius/ shadow/sidebar/header token layers); fonts self-hosted via @fontsource - src/lib/theme/{css-vars,mui-theme}.ts: getCssVar -> createTheme adapter mirroring SHOC's mui-theme.ts (palette, typography, shadows tuple, component overrides; MUI v9 slot renames expressed as class selectors); theme.ts is now a re-export - Shell: SHOC composition (sidebar column + sticky gradient topbar + scrolling main); sidebar 244px/76px collapse with brand header row, grouped nav, SHOC active treatment (white card + 3px accent bar); topbar 100-degree gradient, surface hamburger, gradient avatar pill - Brand: SeahavenMark + BrandLockup ported (Tailwind re-expressed as sx; wordmark subtitle localized to PROPOSAL SYSTEM) - Login: SHOC auth-card treatment (centered 384px card on #f9fafb) - Old "Sea Haven Ops" Inter/#2563EB theme and Nunito remnants removed; remaining hardcoded hexes replaced with tokens; lucide-react for shell/nav icons per SHOC convention Verify: tsc clean, 26/26 vitest, vite build OK; Playwright screenshots pixel-sampled against the extracted SHOC spec (all hard values exact, no blocking deviations). --- web/index.html | 3 - web/package-lock.json | 40 ++ web/package.json | 4 + web/src/App.tsx | 36 +- web/src/components/Sidebar.tsx | 289 ++++++++------ web/src/components/Topbar.tsx | 129 ++++--- web/src/components/brand/BrandLockup.tsx | 68 ++++ web/src/components/brand/SeahavenMark.tsx | 21 ++ web/src/constants/index.ts | 2 +- web/src/index.css | 13 +- web/src/lib/theme/css-vars.ts | 32 ++ web/src/lib/theme/mui-theme.ts | 295 +++++++++++++++ web/src/main.tsx | 1 + .../pages/admin/dashboard/AdminDashboard.tsx | 2 +- .../pages/admin/workspace/AdminWorkspace.tsx | 2 +- web/src/pages/auth/LoginPage.tsx | 246 ++++++------ web/src/pages/dashboard/Dashboard.tsx | 4 +- web/src/styles/fonts.css | 12 + web/src/styles/globals.css | 5 + web/src/styles/motion.css | 6 + web/src/styles/theme.css | 188 +++++++++ web/src/styles/typography.css | 52 +++ web/src/theme.ts | 357 +----------------- 23 files changed, 1134 insertions(+), 673 deletions(-) create mode 100644 web/src/components/brand/BrandLockup.tsx create mode 100644 web/src/components/brand/SeahavenMark.tsx create mode 100644 web/src/lib/theme/css-vars.ts create mode 100644 web/src/lib/theme/mui-theme.ts create mode 100644 web/src/styles/fonts.css create mode 100644 web/src/styles/globals.css create mode 100644 web/src/styles/motion.css create mode 100644 web/src/styles/theme.css create mode 100644 web/src/styles/typography.css diff --git a/web/index.html b/web/index.html index 62fb501..7b7a1a9 100644 --- a/web/index.html +++ b/web/index.html @@ -4,9 +4,6 @@ Proposal System - Sea Haven Industries - - -
diff --git a/web/package-lock.json b/web/package-lock.json index f0b34a7..60df75c 100644 --- a/web/package-lock.json +++ b/web/package-lock.json @@ -10,11 +10,15 @@ "dependencies": { "@emotion/react": "^11.14.0", "@emotion/styled": "^11.14.0", + "@fontsource/dm-sans": "^5.2.8", + "@fontsource/jetbrains-mono": "^5.2.8", + "@fontsource/montserrat": "^5.2.8", "@mui/icons-material": "^9.2.0", "@mui/material": "^9.1.1", "@reduxjs/toolkit": "^2.11.2", "@tanstack/react-query": "^5.101.2", "axios": "^1.18.1", + "lucide-react": "^1.24.0", "react": "^19.2.7", "react-dom": "^19.2.7", "react-redux": "^9.2.0", @@ -582,6 +586,33 @@ } } }, + "node_modules/@fontsource/dm-sans": { + "version": "5.2.8", + "resolved": "https://registry.npmjs.org/@fontsource/dm-sans/-/dm-sans-5.2.8.tgz", + "integrity": "sha512-tlovG42m9ESG28WiHpLq3F5umAlm64rv0RkqTbYowRn70e9OlRr5a3yTJhrhrY+k5lftR/OFJjPzOLQzk8EfCA==", + "license": "OFL-1.1", + "funding": { + "url": "https://github.com/sponsors/ayuhito" + } + }, + "node_modules/@fontsource/jetbrains-mono": { + "version": "5.2.8", + "resolved": "https://registry.npmjs.org/@fontsource/jetbrains-mono/-/jetbrains-mono-5.2.8.tgz", + "integrity": "sha512-6w8/SG4kqvIMu7xd7wt6x3idn1Qux3p9N62s6G3rfldOUYHpWcc2FKrqf+Vo44jRvqWj2oAtTHrZXEP23oSKwQ==", + "license": "OFL-1.1", + "funding": { + "url": "https://github.com/sponsors/ayuhito" + } + }, + "node_modules/@fontsource/montserrat": { + "version": "5.2.8", + "resolved": "https://registry.npmjs.org/@fontsource/montserrat/-/montserrat-5.2.8.tgz", + "integrity": "sha512-xTjLxSbSfCycDB0pwmNsfNvdfWPaDaRQ2LC6yt/ZI7SdvXG52zHnzNYC/09mzuAuWNJyShkteutfCoDgym56hQ==", + "license": "OFL-1.1", + "funding": { + "url": "https://github.com/sponsors/ayuhito" + } + }, "node_modules/@jridgewell/gen-mapping": { "version": "0.3.13", "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", @@ -2943,6 +2974,15 @@ "node": "20 || >=22" } }, + "node_modules/lucide-react": { + "version": "1.24.0", + "resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-1.24.0.tgz", + "integrity": "sha512-YT6mBD8lGKkg4nM39enlm94/sfJIiW0YKUT60fBy4YK8tai31ylg1VhGNWxkpSKHo9UagfnZqwIff3HTDQwXeA==", + "license": "ISC", + "peerDependencies": { + "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, "node_modules/lz-string": { "version": "1.5.0", "resolved": "https://registry.npmjs.org/lz-string/-/lz-string-1.5.0.tgz", diff --git a/web/package.json b/web/package.json index 245c73e..42707cc 100644 --- a/web/package.json +++ b/web/package.json @@ -13,11 +13,15 @@ "dependencies": { "@emotion/react": "^11.14.0", "@emotion/styled": "^11.14.0", + "@fontsource/dm-sans": "^5.2.8", + "@fontsource/jetbrains-mono": "^5.2.8", + "@fontsource/montserrat": "^5.2.8", "@mui/icons-material": "^9.2.0", "@mui/material": "^9.1.1", "@reduxjs/toolkit": "^2.11.2", "@tanstack/react-query": "^5.101.2", "axios": "^1.18.1", + "lucide-react": "^1.24.0", "react": "^19.2.7", "react-dom": "^19.2.7", "react-redux": "^9.2.0", diff --git a/web/src/App.tsx b/web/src/App.tsx index 94a14ad..f247d00 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -1,5 +1,5 @@ import { Routes, Route, Navigate } from 'react-router-dom'; -import { Box, Button, Card, CardContent, Toolbar, Typography } from '@mui/material'; +import { Box, Button, Card, CardContent, Typography } from '@mui/material'; import PeopleIcon from '@mui/icons-material/People'; import ProtectedRoute, { RoleGuard } from './components/ProtectedRoute'; import Topbar from './components/Topbar'; @@ -25,20 +25,21 @@ export default function App() { path="/*" element={ - - + {/* SHOC shell composition (ADR 0003): sidebar column + [topbar / scrolling main] */} + - - - + + + + } /> } /> } /> @@ -55,7 +56,7 @@ export default function App() { - + User Management Users and roles are currently managed in AWS Cognito. Contact the system administrator to update access. @@ -75,8 +76,9 @@ export default function App() { } /> - } /> - + } /> + + diff --git a/web/src/components/Sidebar.tsx b/web/src/components/Sidebar.tsx index 617277b..191c619 100644 --- a/web/src/components/Sidebar.tsx +++ b/web/src/components/Sidebar.tsx @@ -1,48 +1,45 @@ +// App shell sidebar — SHOC design system port (ADR 0003, mirrors app-sidebar.tsx). +// Expanded 244px / collapsed 76px; brand header row; grouped nav with SHOC +// active treatment (white card bg + shadow + 3px left accent bar). import { useLocation, useNavigate } from 'react-router-dom'; import { useSelector, useDispatch } from 'react-redux'; +import { Drawer, Box, Tooltip, useMediaQuery, useTheme } from '@mui/material'; import { - Drawer, - List, - ListItemButton, - ListItemIcon, - ListItemText, - Toolbar, - Box, - Typography, - Tooltip, - useMediaQuery, - useTheme, -} from '@mui/material'; -import DashboardIcon from '@mui/icons-material/Dashboard'; -import DescriptionIcon from '@mui/icons-material/Description'; -import AddCircleIcon from '@mui/icons-material/AddCircle'; -import AdminPanelSettingsIcon from '@mui/icons-material/AdminPanelSettings'; -import AssignmentIcon from '@mui/icons-material/Assignment'; -import PeopleIcon from '@mui/icons-material/People'; -import BusinessIcon from '@mui/icons-material/Business'; -import MenuBookIcon from '@mui/icons-material/MenuBook'; + LayoutDashboard, + FileText, + CirclePlus, + ClipboardList, + FolderOpen, + Building2, + BookOpen, + Users, + type LucideIcon, +} from 'lucide-react'; import { selectSidebarOpen, setSidebarOpen } from '../app/slices/uiSlice'; import { selectUser } from '../app/slices/authSlice'; import type { RootState } from '../app/store'; import { DRAWER_WIDTH } from '../constants'; +import BrandLockup, { BrandMark } from './brand/BrandLockup'; -const COLLAPSED_WIDTH = 72; +const COLLAPSED_WIDTH = 76; // --spacing-sidebar-collapsed -const dispatcherNav = [ - { label: 'Dashboard', path: '/', icon: }, - { label: 'My Proposals', path: '/proposals', icon: }, - { label: 'New Proposal', path: '/proposals/new', icon: }, +type NavEntry = { label: string; path: string; icon: LucideIcon }; + +const dispatcherNav: NavEntry[] = [ + { label: 'Dashboard', path: '/', icon: LayoutDashboard }, + { label: 'My Proposals', path: '/proposals', icon: FileText }, + { label: 'New Proposal', path: '/proposals/new', icon: CirclePlus }, ]; -const adminNav = [ - { label: 'Admin Queue', path: '/admin', icon: }, - { label: 'All Proposals', path: '/admin/proposals', icon: }, - { label: 'Customers', path: '/admin/customers', icon: }, - { label: 'Pricing Library', path: '/admin/pricing-library', icon: }, +const adminNav: NavEntry[] = [ + { label: 'Admin Queue', path: '/admin', icon: ClipboardList }, + { label: 'All Proposals', path: '/admin/proposals', icon: FolderOpen }, + { label: 'Customers', path: '/admin/customers', icon: Building2 }, + { label: 'Pricing Library', path: '/admin/pricing-library', icon: BookOpen }, ]; -const sysadminNav = [ - { label: 'Users', path: '/admin/users', icon: }, +const sysadminNav: NavEntry[] = [ + { label: 'Users', path: '/admin/users', icon: Users }, ]; const ALL_NAV_PATHS = new Set( @@ -56,6 +53,88 @@ function isNavActive(pathname: string, itemPath: string): boolean { return pathname.startsWith(itemPath + '/'); } +function NavItem({ + item, + active, + collapsed, + onClick, +}: { + item: NavEntry; + active: boolean; + collapsed: boolean; + onClick: () => void; +}) { + const Icon = item.icon; + const row = ( + + {active && ( + + )} + + {!collapsed && ( + + {item.label} + + )} + + ); + + if (collapsed) { + return ( + + {row} + + ); + } + return row; +} + export default function Sidebar() { const navigate = useNavigate(); const location = useLocation(); @@ -65,14 +144,14 @@ export default function Sidebar() { const user = useSelector((state: RootState) => selectUser(state)); const isMobile = useMediaQuery(theme.breakpoints.down('md')); // <900px - const isCollapsed = useMediaQuery(theme.breakpoints.between('md', 'lg')); // 900–1200px const isAdmin = user?.role === 'Admin' || user?.role === 'SysAdmin'; const isSysAdmin = user?.role === 'SysAdmin'; - const navItems = dispatcherNav; - const drawerVariant = isMobile ? 'temporary' : 'persistent'; - const effectiveWidth = isCollapsed ? COLLAPSED_WIDTH : DRAWER_WIDTH; + // Desktop: hamburger toggles expanded (244) ↔ collapsed (76). + // Mobile: temporary drawer, hamburger opens/closes it. + const collapsed = !isMobile && !open; + const width = collapsed ? COLLAPSED_WIDTH : DRAWER_WIDTH; const handleNavClick = (path: string) => { navigate(path); @@ -81,107 +160,69 @@ export default function Sidebar() { } }; - const handleDrawerClose = () => { - dispatch(setSidebarOpen(false)); - }; + const groupLabel = (label: string) => ( + + {label} + + ); - const renderNavItem = (item: { label: string; path: string; icon: React.ReactNode }) => { - const button = ( - handleNavClick(item.path)} - sx={{ - py: 1, - justifyContent: isCollapsed ? 'center' : 'flex-start', - minHeight: 40, - }} - > - - {item.icon} - - {!isCollapsed && ( - - )} - - ); + const groupDivider = ( + + ); - if (isCollapsed) { - return ( - - {button} - - ); - } - - return button; - }; + const renderGroup = (label: string, items: NavEntry[], first: boolean) => ( + <> + {collapsed ? (first ? null : groupDivider) : groupLabel(label)} + {items.map((item) => ( + handleNavClick(item.path)} + /> + ))} + + ); return ( dispatch(setSidebarOpen(false))} sx={{ - width: open ? effectiveWidth : 0, + width: isMobile ? 0 : width, flexShrink: 0, - transition: 'width 250ms ease', '& .MuiDrawer-paper': { - width: effectiveWidth, + width: isMobile ? DRAWER_WIDTH : width, boxSizing: 'border-box', + backgroundColor: 'var(--color-sidebar-bg)', + borderRight: '1px solid var(--color-sidebar-border)', + borderRadius: 0, overflowX: 'hidden', + transition: 'width 200ms var(--ease-default, ease)', }, }} > - - - {isAdmin && ( - <> - {!isCollapsed && ( - - Admin - - )} - - {adminNav.map((item) => renderNavItem(item))} - {isSysAdmin && sysadminNav.map((item) => renderNavItem(item))} - - - )} - {!isCollapsed && ( - - Proposals - - )} - - {navItems.map((item) => renderNavItem(item))} - + {/* Brand header row — 64px, white, aligns with the topbar */} + + {collapsed ? : } + + + + {isAdmin && renderGroup('Admin', [...adminNav, ...(isSysAdmin ? sysadminNav : [])], true)} + {renderGroup('Proposals', dispatcherNav, !isAdmin)} ); diff --git a/web/src/components/Topbar.tsx b/web/src/components/Topbar.tsx index 9a04831..5ba17f6 100644 --- a/web/src/components/Topbar.tsx +++ b/web/src/components/Topbar.tsx @@ -1,7 +1,7 @@ +// App shell top bar — SHOC design system port (ADR 0003, mirrors app-topbar.tsx). import { useDispatch } from 'react-redux'; -import { AppBar, Toolbar, IconButton, Typography, Box, Avatar } from '@mui/material'; -import MenuIcon from '@mui/icons-material/Menu'; -import LogoutIcon from '@mui/icons-material/Logout'; +import { AppBar, Toolbar, IconButton, Box, Avatar, Typography } from '@mui/material'; +import { Menu as MenuIcon, LogOut } from 'lucide-react'; import { toggleSidebar } from '../app/slices/uiSlice'; import { useAuth } from '../hooks/useAuth'; @@ -11,70 +11,103 @@ export default function Topbar() { const initials = user?.displayName ? user.displayName.split(' ').map(n => n[0]).join('').toUpperCase().slice(0, 2) - : ''; + : 'U'; const isDevMode = !import.meta.env.VITE_COGNITO_DOMAIN; return ( - theme.zIndex.drawer + 1, height: 64 }}> - + + dispatch(toggleSidebar())} - sx={{ mr: 2 }} aria-label="Toggle sidebar menu" + sx={{ + width: 38, + height: 38, + borderRadius: 'var(--radius-md)', + backgroundColor: 'var(--color-header-surface)', + '&:hover': { backgroundColor: 'var(--color-header-surface-hover)' }, + }} > - + - - Proposal System - {isDevMode && ( - + DEV + + )} + + + + {user && ( + + - DEV + {initials} + + + + {user.displayName} + + + {user.role} + - )} - - {user && ( - - - - {initials} - - - - {user.displayName} - - - {user.role} - - - - - + )} diff --git a/web/src/components/brand/BrandLockup.tsx b/web/src/components/brand/BrandLockup.tsx new file mode 100644 index 0000000..4472ac9 --- /dev/null +++ b/web/src/components/brand/BrandLockup.tsx @@ -0,0 +1,68 @@ +// Sea Haven brand lockup — ported from shoc-frontend-new dev (brand-lockup.tsx), +// Tailwind classes re-expressed as sx (this app is MUI-only per ADR 0003). +// Wordmark subtitle localized to this app: "PROPOSAL SYSTEM". +import { Box } from '@mui/material'; +import SeahavenMark from './SeahavenMark'; + +type BrandLockupSize = 'default' | 'compact'; + +export function BrandMark({ size = 'default' }: { size?: BrandLockupSize }) { + const compact = size === 'compact'; + return ( + + + + ); +} + +export function BrandWordmark({ size = 'default' }: { size?: BrandLockupSize }) { + const compact = size === 'compact'; + return ( + + + SEAHAVEN + + + PROPOSAL SYSTEM + + + ); +} + +export default function BrandLockup({ size = 'default' }: { size?: BrandLockupSize }) { + return ( + + + + + ); +} diff --git a/web/src/components/brand/SeahavenMark.tsx b/web/src/components/brand/SeahavenMark.tsx new file mode 100644 index 0000000..f60a4b1 --- /dev/null +++ b/web/src/components/brand/SeahavenMark.tsx @@ -0,0 +1,21 @@ +// Sea Haven brand mark — ported from shoc-frontend-new dev (seahaven-mark.tsx). +export default function SeahavenMark({ size = 18 }: { size?: number }) { + return ( + + ); +} diff --git a/web/src/constants/index.ts b/web/src/constants/index.ts index 36ebcb3..89b2b60 100644 --- a/web/src/constants/index.ts +++ b/web/src/constants/index.ts @@ -38,7 +38,7 @@ export const STATUS_CHIP_STYLES: Record = { Standard: 'Standard', diff --git a/web/src/index.css b/web/src/index.css index 6ad3d12..9acafa1 100644 --- a/web/src/index.css +++ b/web/src/index.css @@ -1,5 +1,4 @@ -@import url("https://fonts.googleapis.com/css2?family=Nunito:ital,wght@0,200;0,300;0,400;0,600;0,700;1,400&display=swap"); - +/* App-level base styles. Fonts/tokens/typography live in src/styles/globals.css. */ * { box-sizing: border-box; } @@ -9,16 +8,12 @@ html, body, #root { } body { - margin: 0; - font-family: "Nunito", "Segoe UI", Roboto, Helvetica, Arial, sans-serif; - font-size: 13px; - background: #f4f6f7; - color: #333; - line-height: 1.5; + font-size: var(--text-base); + line-height: var(--leading-normal); } a { - color: #007bff; + color: var(--primary); text-decoration: none; } diff --git a/web/src/lib/theme/css-vars.ts b/web/src/lib/theme/css-vars.ts new file mode 100644 index 0000000..7f73b3d --- /dev/null +++ b/web/src/lib/theme/css-vars.ts @@ -0,0 +1,32 @@ +/** + * CSS custom-property bridge — mirrors shoc-frontend-new dev src/lib/theme/css-vars.ts. + * Reads live tokens from :root at theme-creation time; falls back to the static + * map when document is unavailable (tests, pre-paint). + */ +export function getCssVar(name: string, fallback = ''): string { + if (typeof document === 'undefined') return fallback; + const value = getComputedStyle(document.documentElement).getPropertyValue(name).trim(); + return value || fallback; +} + +export const CSS_VAR_FALLBACKS = { + primary: '#1c75bc', + primaryHover: '#155a92', + primaryForeground: '#ffffff', + destructive: '#dc2626', + success: '#00a79d', + warning: '#d97706', + background: '#ffffff', + foreground: '#262262', + border: '#dfe3ea', + muted: '#e6e7e8', + mutedForeground: '#58595b', + inputBackground: '#f9fafb', + ring: '#1c75bc', + bgPage: '#f9fafb', + radius: '8', + headerStart: '#1b1f52', + headerMid: '#1c4f8f', + headerEnd: '#1c75bc', + headerText: '#ffffff', +} as const; diff --git a/web/src/lib/theme/mui-theme.ts b/web/src/lib/theme/mui-theme.ts new file mode 100644 index 0000000..28be2c5 --- /dev/null +++ b/web/src/lib/theme/mui-theme.ts @@ -0,0 +1,295 @@ +import { createTheme } from '@mui/material/styles'; +import type { Shadows } from '@mui/material/styles'; +import { getCssVar, CSS_VAR_FALLBACKS } from './css-vars'; + +/** + * MUI adapter over the CSS-variable token file (src/styles/theme.css). + * Mirrors shoc-frontend-new dev src/lib/theme/mui-theme.ts (ADR 0003). + * Token changes are values-only edits in theme.css — never here. + */ + +declare module '@mui/material/styles' { + interface Palette { + header: Palette['primary']; + } + interface PaletteOptions { + header?: PaletteOptions['primary']; + } +} + +const v = (name: string, fallback: string) => getCssVar(name, fallback); + +const primary = v('--primary', CSS_VAR_FALLBACKS.primary); +const primaryHover = v('--primary-hover', CSS_VAR_FALLBACKS.primaryHover); +const primaryForeground = v('--primary-foreground', CSS_VAR_FALLBACKS.primaryForeground); +const destructive = v('--destructive', CSS_VAR_FALLBACKS.destructive); +const success = v('--success', CSS_VAR_FALLBACKS.success); +const warning = v('--warning', CSS_VAR_FALLBACKS.warning); +const background = v('--background', CSS_VAR_FALLBACKS.background); +const foreground = v('--foreground', CSS_VAR_FALLBACKS.foreground); +const border = v('--border', CSS_VAR_FALLBACKS.border); +const muted = v('--muted', CSS_VAR_FALLBACKS.muted); +const mutedForeground = v('--muted-foreground', CSS_VAR_FALLBACKS.mutedForeground); +const inputBackground = v('--input-background', CSS_VAR_FALLBACKS.inputBackground); +const ring = v('--ring', CSS_VAR_FALLBACKS.ring); +const bgPage = v('--color-bg-page', CSS_VAR_FALLBACKS.bgPage); +const radius = parseInt(v('--radius', `${CSS_VAR_FALLBACKS.radius}px`), 10); +const headerEnd = v('--color-header-bg-end', CSS_VAR_FALLBACKS.headerEnd); +const headerText = v('--color-header-text', CSS_VAR_FALLBACKS.headerText); + +const shadowSm = v('--shadow-sm', '0 1px 3px rgba(22, 30, 56, 0.06), 0 1px 2px rgba(22, 30, 56, 0.04)'); +const shadowMd = v('--shadow-md', '0 4px 6px -1px rgba(22, 30, 56, 0.08), 0 2px 4px -2px rgba(22, 30, 56, 0.06)'); +const shadowLg = v('--shadow-lg', '0 10px 15px -3px rgba(22, 30, 56, 0.1), 0 4px 6px -4px rgba(22, 30, 56, 0.08)'); + +const fontSans = '"DM Sans", ui-sans-serif, system-ui, sans-serif'; +const fontDisplay = '"Montserrat", ui-sans-serif, system-ui, sans-serif'; + +// MUI's 25-slot elevation tuple, fully overridden: 0=none, 1–2=sm, 3–5=md, 6+=lg. +const shadows = [ + 'none', + shadowSm, + shadowSm, + shadowMd, + shadowMd, + shadowMd, + ...Array(19).fill(shadowLg), +] as Shadows; + +export const muiTheme = createTheme({ + palette: { + mode: 'light', + primary: { + main: primary, + dark: primaryHover, + contrastText: primaryForeground, + }, + // Repurposed as the muted/neutral swatch (SHOC convention), not a brand color. + secondary: { + main: muted, + contrastText: foreground, + }, + error: { + main: destructive, + contrastText: '#ffffff', + }, + success: { + main: success, + contrastText: '#ffffff', + }, + warning: { + main: warning, + contrastText: '#ffffff', + }, + background: { + default: bgPage, + paper: background, + }, + text: { + primary: foreground, + secondary: mutedForeground, + }, + divider: border, + header: { + main: headerEnd, + contrastText: headerText, + }, + }, + + typography: { + fontFamily: fontSans, + fontSize: 14, + fontWeightRegular: 400, + fontWeightMedium: 500, + fontWeightBold: 600, + h1: { fontFamily: fontDisplay, fontWeight: 800, fontSize: '1.75rem', lineHeight: 1.25 }, + h2: { fontFamily: fontDisplay, fontWeight: 700, fontSize: '1.375rem', lineHeight: 1.25 }, + h3: { fontFamily: fontDisplay, fontWeight: 600, fontSize: '1.125rem', lineHeight: 1.25 }, + h4: { fontFamily: fontDisplay, fontWeight: 600, fontSize: '1rem', lineHeight: 1.25 }, + h5: { fontFamily: fontDisplay, fontWeight: 600, fontSize: '0.875rem', lineHeight: 1.25 }, + h6: { fontFamily: fontDisplay, fontWeight: 600, fontSize: '0.8125rem', lineHeight: 1.25 }, + subtitle1: { fontWeight: 600, fontSize: '1rem' }, + subtitle2: { fontWeight: 500, fontSize: '0.875rem', color: mutedForeground }, + body1: { fontSize: '0.875rem', lineHeight: 1.5 }, + body2: { fontSize: '0.8125rem', lineHeight: 1.5, color: mutedForeground }, + caption: { fontSize: '0.75rem', color: mutedForeground }, + button: { textTransform: 'none' as const, fontWeight: 600, fontSize: '0.875rem' }, + overline: { + fontSize: '0.6875rem', + fontWeight: 600, + letterSpacing: '0.08em', + textTransform: 'uppercase' as const, + color: mutedForeground, + }, + }, + + shape: { + borderRadius: radius, + }, + + shadows, + + components: { + MuiCssBaseline: { + styleOverrides: { + body: { + fontSize: 14, + backgroundColor: bgPage, + color: foreground, + }, + }, + }, + + MuiButton: { + defaultProps: { + disableElevation: true, + }, + styleOverrides: { + root: { + borderRadius: 'var(--radius-md)', + textTransform: 'none' as const, + fontWeight: 600, + fontSize: '0.875rem', + transition: 'background-color var(--duration-fast) var(--ease-default), box-shadow var(--duration-fast) var(--ease-default)', + '&:focus-visible': { + outline: `2px solid ${ring}`, + outlineOffset: 2, + }, + // MUI v9: containedPrimary slot removed — class selector instead + '&.MuiButton-containedPrimary:hover': { + backgroundColor: primaryHover, + }, + }, + outlined: { + borderColor: border, + '&:hover': { + borderColor: 'var(--border-hover)', + backgroundColor: 'var(--secondary)', + }, + }, + }, + }, + + MuiTextField: { + defaultProps: { + size: 'small' as const, + }, + }, + + MuiOutlinedInput: { + styleOverrides: { + root: { + backgroundColor: inputBackground, + borderRadius: 'var(--radius-md)', + '& .MuiOutlinedInput-notchedOutline': { + borderColor: border, + }, + '&:hover .MuiOutlinedInput-notchedOutline': { + borderColor: 'var(--border-hover)', + }, + '&.Mui-focused': { + boxShadow: `0 0 0 3px color-mix(in srgb, ${ring} 25%, transparent)`, + }, + '&.Mui-focused .MuiOutlinedInput-notchedOutline': { + borderColor: ring, + borderWidth: 2, + }, + }, + input: { + padding: '8.5px 13px', + fontSize: '0.875rem', + '&.MuiOutlinedInput-inputAdornedEnd': { + paddingRight: 40, + }, + }, + }, + }, + + MuiTableCell: { + styleOverrides: { + root: { + fontSize: '0.875rem', + borderColor: border, + padding: '10px 16px', + }, + head: { + fontWeight: 600, + backgroundColor: 'var(--color-bg-muted)', + color: foreground, + }, + }, + }, + + MuiTableRow: { + styleOverrides: { + root: { + '&:last-child .MuiTableCell-root': { + borderBottom: 0, + }, + }, + }, + }, + + MuiChip: { + styleOverrides: { + root: { + borderRadius: 'var(--radius-sm)', + fontWeight: 500, + fontSize: '0.75rem', + }, + outlined: { + borderColor: border, + }, + }, + }, + + MuiPaper: { + defaultProps: { + elevation: 0, + }, + styleOverrides: { + root: { + backgroundImage: 'none', + borderRadius: radius, + }, + outlined: { + borderColor: border, + }, + }, + }, + + MuiDialog: { + styleOverrides: { + paper: { + borderRadius: 'var(--radius-xl)', + boxShadow: shadowLg, + }, + }, + }, + + MuiAlert: { + styleOverrides: { + root: { + borderRadius: 'var(--radius-md)', + fontSize: '0.875rem', + // MUI v9: standard* slots removed — class selectors instead + '&.MuiAlert-standardSuccess': { + backgroundColor: 'var(--status-completed-bg)', + color: 'var(--status-completed-text)', + }, + '&.MuiAlert-standardError': { + backgroundColor: 'var(--doc-no-bg)', + color: 'var(--doc-no-text)', + }, + '&.MuiAlert-standardWarning': { + backgroundColor: 'var(--status-pending-bg)', + color: 'var(--status-pending-text)', + }, + '&.MuiAlert-standardInfo': { + backgroundColor: 'var(--color-primary-tint)', + color: 'var(--sidebar-accent-foreground)', + }, + }, + }, + }, + }, +}); diff --git a/web/src/main.tsx b/web/src/main.tsx index f56fab5..0d0ee59 100644 --- a/web/src/main.tsx +++ b/web/src/main.tsx @@ -6,6 +6,7 @@ import { QueryClientProvider } from '@tanstack/react-query'; import { ThemeProvider, CssBaseline } from '@mui/material'; import { ToastContainer } from 'react-toastify'; import 'react-toastify/dist/ReactToastify.css'; +import './styles/globals.css'; import './index.css'; import { store } from './app/store'; import { queryClient } from './lib/queryClient'; diff --git a/web/src/pages/admin/dashboard/AdminDashboard.tsx b/web/src/pages/admin/dashboard/AdminDashboard.tsx index bdf4578..b664728 100644 --- a/web/src/pages/admin/dashboard/AdminDashboard.tsx +++ b/web/src/pages/admin/dashboard/AdminDashboard.tsx @@ -56,7 +56,7 @@ function StatCard({ icon, label, value, color, subtitle }: { icon: React.ReactNo {label} - + {value} {subtitle && ( diff --git a/web/src/pages/admin/workspace/AdminWorkspace.tsx b/web/src/pages/admin/workspace/AdminWorkspace.tsx index ff32e7c..87729e8 100644 --- a/web/src/pages/admin/workspace/AdminWorkspace.tsx +++ b/web/src/pages/admin/workspace/AdminWorkspace.tsx @@ -310,7 +310,7 @@ export default function AdminWorkspace() { component="button" underline="hover" onClick={() => guardedNavigate('/admin')} - sx={{ fontSize: 14, color: '#2563EB', cursor: 'pointer' }} + sx={{ fontSize: 14, color: 'var(--primary)', cursor: 'pointer' }} > Admin Queue diff --git a/web/src/pages/auth/LoginPage.tsx b/web/src/pages/auth/LoginPage.tsx index 9f2e7a2..8694088 100644 --- a/web/src/pages/auth/LoginPage.tsx +++ b/web/src/pages/auth/LoginPage.tsx @@ -1,7 +1,9 @@ +// Login — SHOC auth-card treatment (ADR 0003, mirrors AuthPageShell/AuthCardHeader): +// centered 384px card on --color-bg-page, brand lockup, Montserrat heading. import { useEffect, useState } from 'react'; import { useNavigate } from 'react-router-dom'; import { useDispatch } from 'react-redux'; -import { Alert, Box, Button, Card, CardContent, Typography, Divider, Stack } from '@mui/material'; +import { Alert, Box, Button, Typography, Divider, Stack } from '@mui/material'; import GoogleIcon from '@mui/icons-material/Google'; import AdminPanelSettingsIcon from '@mui/icons-material/AdminPanelSettings'; import EngineeringIcon from '@mui/icons-material/Engineering'; @@ -9,6 +11,7 @@ import PersonIcon from '@mui/icons-material/Person'; import { useAuth } from '../../hooks/useAuth'; import { authApi } from '../../lib/api/auth'; import { setUser, setError } from '../../app/slices/authSlice'; +import BrandLockup from '../../components/brand/BrandLockup'; const COGNITO_DOMAIN = import.meta.env.VITE_COGNITO_DOMAIN || ''; const CLIENT_ID = import.meta.env.VITE_COGNITO_CLIENT_ID || ''; @@ -24,6 +27,12 @@ function buildLoginUrl(): string { return `https://${COGNITO_DOMAIN}/oauth2/authorize?${params.toString()}`; } +const devButtonSx = { + py: 1.5, + height: 56, + justifyContent: 'flex-start', +} as const; + export default function LoginPage() { const navigate = useNavigate(); const dispatch = useDispatch(); @@ -62,126 +71,135 @@ export default function LoginPage() { - - - + + + + + + Welcome back + + + Sign in to your account + + + {error && ( + + {error} + + )} + + {isDevMode ? ( + <> + + + + + + + + Development sign-in — only shown when Cognito is not configured. + + + ) : ( + - - - - - - Dev login is only shown when Cognito is not configured. - - - ) : ( - <> - - Sign in with your Sea Haven Google account to continue. - - - - )} - - - - Sea Haven Industries © {new Date().getFullYear()} + Sign in with Google + + )} + + + Having trouble? Contact your administrator. diff --git a/web/src/pages/dashboard/Dashboard.tsx b/web/src/pages/dashboard/Dashboard.tsx index 3728186..56931a9 100644 --- a/web/src/pages/dashboard/Dashboard.tsx +++ b/web/src/pages/dashboard/Dashboard.tsx @@ -34,11 +34,11 @@ function KpiCard({ icon, label, value, subtitle }: { icon: React.ReactNode; labe return ( - {icon} + {icon} {label} - + {value} {subtitle && ( diff --git a/web/src/styles/fonts.css b/web/src/styles/fonts.css new file mode 100644 index 0000000..bf759c9 --- /dev/null +++ b/web/src/styles/fonts.css @@ -0,0 +1,12 @@ +/* Self-hosted fonts (@fontsource) — same weights as shoc-frontend-new dev. */ +@import "@fontsource/montserrat/600.css"; +@import "@fontsource/montserrat/700.css"; +@import "@fontsource/montserrat/800.css"; + +@import "@fontsource/dm-sans/400.css"; +@import "@fontsource/dm-sans/500.css"; +@import "@fontsource/dm-sans/600.css"; + +@import "@fontsource/jetbrains-mono/400.css"; +@import "@fontsource/jetbrains-mono/500.css"; +@import "@fontsource/jetbrains-mono/600.css"; diff --git a/web/src/styles/globals.css b/web/src/styles/globals.css new file mode 100644 index 0000000..db4a57b --- /dev/null +++ b/web/src/styles/globals.css @@ -0,0 +1,5 @@ +/* Global stylesheet — import order mirrors shoc-frontend-new dev (minus Tailwind). */ +@import "./fonts.css"; +@import "./theme.css"; +@import "./typography.css"; +@import "./motion.css"; diff --git a/web/src/styles/motion.css b/web/src/styles/motion.css new file mode 100644 index 0000000..afba9f4 --- /dev/null +++ b/web/src/styles/motion.css @@ -0,0 +1,6 @@ +/* Motion tokens — mirrors shoc-frontend-new dev src/styles/motion.css. */ +:root { + --duration-fast: 150ms; + --duration-normal: 250ms; + --ease-default: cubic-bezier(0.4, 0, 0.2, 1); +} diff --git a/web/src/styles/theme.css b/web/src/styles/theme.css new file mode 100644 index 0000000..bb1ce7d --- /dev/null +++ b/web/src/styles/theme.css @@ -0,0 +1,188 @@ +/* + * Sea Haven design tokens — single source of truth (ADR 0003). + * Ported verbatim from shoc-frontend-new dev src/styles/theme.css (values-only + * changes belong here; never rewrite the MUI adapter to change a color). + * The SHOC @theme inline Tailwind block is intentionally omitted — this app is MUI-only. + */ +:root { + /* Body typography reference — do not set on html (rem uses 16px root) */ + --font-size: 14px; + + /* ── TYPOGRAPHY ─────────────────────────────────────────────────── */ + --font-family-display: "Montserrat", ui-sans-serif, system-ui, sans-serif; + --font-family-sans: "DM Sans", ui-sans-serif, system-ui, sans-serif; + --font-family-mono: "JetBrains Mono", ui-monospace, monospace; + --font-display: var(--font-family-display); + --font-sans: var(--font-family-sans); + --font-mono: var(--font-family-mono); + --font-heading: var(--font-family-display); + + /* ── TYPOGRAPHY SCALE ───────────────────────────────────────────── */ + --text-xs: 11px; + --text-sm: 12px; + --text-base-sm: 13px; + --text-base: 14px; + --text-md: 16px; + --text-lg: 18px; + --text-xl: 22px; + --text-2xl: 28px; + --leading-tight: 1.25; + --leading-normal: 1.5; + --leading-relaxed: 1.625; + --tracking-tight: -0.01em; + --tracking-normal: 0; + --tracking-wide: 0.05em; + --tracking-wider: 0.08em; + + /* ── LAYOUT SPACING ─────────────────────────────────────────────── */ + --spacing-sidebar: 244px; + --spacing-topbar: 64px; + --spacing-sidebar-collapsed: 76px; + + /* ── RADIUS SCALE (scale resolves to 4 / 6 / 8 / 12) ────────────── */ + --radius: 8px; + --radius-md: 6px; + --radius-sm: 4px; + --radius-xl: 12px; + + /* ── ELEVATION ──────────────────────────────────────────────────── */ + --shadow-sm: 0 1px 3px rgba(22, 30, 56, 0.06), 0 1px 2px rgba(22, 30, 56, 0.04); + --shadow-md: 0 4px 6px -1px rgba(22, 30, 56, 0.08), 0 2px 4px -2px rgba(22, 30, 56, 0.06); + --shadow-lg: 0 10px 15px -3px rgba(22, 30, 56, 0.1), 0 4px 6px -4px rgba(22, 30, 56, 0.08); + --shadow-brand-mark: 0 2px 6px -1px rgba(28, 117, 188, 0.45); + + /* ── SHADCN BASE (Seahaven DS) ──────────────────────────────────── */ + --background: #ffffff; + --foreground: #262262; + --placeholder-foreground: color-mix(in srgb, var(--foreground) 50%, transparent); + --card: #ffffff; + --card-foreground: #262262; + --popover: #ffffff; + --popover-foreground: #262262; + --primary: #1c75bc; + --primary-hover: #155a92; + --primary-foreground: #ffffff; + --secondary: #f1f4f9; + --secondary-foreground: #262262; + --muted: #e6e7e8; + --muted-foreground: #58595b; + --accent: #27aae1; + --accent-foreground: #ffffff; + --success: #00a79d; + --warning: #d97706; + --destructive: #dc2626; + --destructive-foreground: #ffffff; + --border: #dfe3ea; + --border-hover: #c8d0e0; + --input: transparent; + --input-background: #f9fafb; + --switch-background: #c8d0e0; + --ring: #1c75bc; + + --chart-1: #1c75bc; + --chart-2: #00a79d; + --chart-3: #d97706; + --chart-4: #7c3aed; + --chart-5: #dc2626; + + /* ── SIDEBAR (light surface — Seahaven DS) ──────────────────────── */ + --sidebar: #f6f8fb; + --sidebar-bg: #f6f8fb; + --sidebar-foreground: #262262; + --sidebar-primary: #1c75bc; + --sidebar-primary-foreground: #ffffff; + --sidebar-accent: #e6f4fb; + --sidebar-accent-foreground: #155a92; + --sidebar-border: #dfe3ea; + --sidebar-ring: #1c75bc; + + /* ── SEAHAVEN SEMANTIC ALIASES (consumed across the app) ────────── */ + --color-primary: var(--primary); + --color-primary-hover: var(--primary-hover); + --color-primary-foreground: #ffffff; + --color-primary-tint: #e6f4fb; + --color-accent: var(--accent); + + --color-secondary: var(--secondary); + --color-secondary-foreground: var(--foreground); + --color-ring: var(--ring); + + --color-bg-page: #f9fafb; + --color-bg-surface: var(--card); + --color-bg-muted: #f1f4f9; + --color-border: var(--border); + --color-border-hover: #c8d0e0; + --color-text-primary: var(--foreground); + --color-text-secondary: #58595b; + --color-text-muted: #6b7a96; + --color-text-inverse: #ffffff; + + /* ── SIDEBAR ALIASES (light) ────────────────────────────────────── */ + --color-sidebar-bg: var(--sidebar-bg); + --color-sidebar-text: #262262; + --color-sidebar-text-hover: #155a92; + --color-sidebar-active-bg: var(--sidebar-accent); + --color-sidebar-active-text: var(--sidebar-accent-foreground); + --color-sidebar-hover-bg: #eef2f7; + --color-sidebar-label: #6b7a96; + --color-sidebar-border: var(--sidebar-border); + --color-sidebar-badge-bg: #e6f4fb; + --color-sidebar-badge-text: #155a92; + --color-sidebar-item-active-bg: var(--card); + --color-sidebar-item-active-shadow: 0 1px 3px rgba(38, 34, 98, 0.08); + + /* ── SEMANTIC (universal meaning) ───────────────────────────────── */ + --color-destructive: var(--destructive); + --color-destructive-fg: #ffffff; + --color-warning: var(--warning); + --color-success: var(--success); + + /* ── STATUS TOKENS (immutable — mirror SHOC; consumed by MuiAlert + mapping and proposal status badges) ──────────────────────────── */ + --status-incomplete-bg: #f3f4f6; + --status-incomplete-text: #374151; + --status-pending-bg: #fff7ed; + --status-pending-text: #92400e; + --status-scheduled-bg: #eff6ff; + --status-scheduled-text: #1e40af; + --status-inprogress-bg: #eff6ff; + --status-inprogress-text: #1d4ed8; + --status-completed-bg: #f0fdf4; + --status-completed-text: #166534; + --status-canceled-bg: #f9fafb; + --status-canceled-text: #6b7280; + --status-pastdue-bg: #fff1f2; + --status-pastdue-text: #9f1239; + + /* ── COMPLETION DOCUMENT (immutable — mirror SHOC) ──────────────── */ + --doc-yes-bg: #f0fdf4; + --doc-yes-text: #166534; + --doc-no-bg: #fff1f2; + --doc-no-text: #be123c; + --doc-nn-bg: #f3f4f6; + --doc-nn-text: #6b7280; + + /* ── SLA (immutable — mirror SHOC) ──────────────────────────────── */ + --sla-safe: #16a34a; + --sla-warning: #d97706; + --sla-critical: #dc2626; + + /* ── HEADER (app shell top bar) ─────────────────────────────────── */ + --color-header-bg-start: #1b1f52; + --color-header-bg-mid: #1c4f8f; + --color-header-bg-end: var(--primary); + --color-header-gradient: linear-gradient( + 100deg, + var(--color-header-bg-start) 0%, + var(--color-header-bg-mid) 58%, + var(--color-header-bg-end) 100% + ); + --color-header-shadow: 0 1px 0 rgba(38, 34, 98, 0.18), 0 6px 18px -10px rgba(28, 117, 188, 0.5); + --color-header-surface: rgba(255, 255, 255, 0.08); + --color-header-surface-hover: rgba(255, 255, 255, 0.18); + --color-header-chip-hover: rgba(255, 255, 255, 0.12); + --color-header-icon: #eaf3fb; + --color-header-text: #ffffff; + --color-header-text-muted: #bdd8f0; + --color-header-avatar-ring: rgba(255, 255, 255, 0.25); +} diff --git a/web/src/styles/typography.css b/web/src/styles/typography.css new file mode 100644 index 0000000..c9fb518 --- /dev/null +++ b/web/src/styles/typography.css @@ -0,0 +1,52 @@ +/* Base element typography — mirrors shoc-frontend-new dev src/styles/typography.css. */ +html { + font-size: 16px; + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; +} + +body { + margin: 0; + font-family: var(--font-sans); + color: var(--foreground); + background-color: var(--color-bg-page); +} + +h1, h2, h3, h4, h5, h6 { + font-family: var(--font-heading); + color: var(--foreground); + margin: 0; +} + +h1 { font-size: var(--text-2xl); font-weight: 800; } +h2 { font-size: var(--text-xl); font-weight: 700; } +h3 { font-size: var(--text-lg); font-weight: 600; } +h4 { font-size: var(--text-md); font-weight: 600; } +h5 { font-size: var(--text-base); font-weight: 600; } +h6 { font-size: var(--text-base-sm); font-weight: 600; } + +.text-caption { + font-family: var(--font-sans); + font-size: var(--text-sm); + line-height: var(--leading-normal); + color: var(--muted-foreground); +} + +.text-label { + font-family: var(--font-sans); + font-size: var(--text-xs); + font-weight: 600; + line-height: 1.5; + letter-spacing: var(--tracking-wider); + text-transform: uppercase; + color: var(--color-sidebar-label); +} + +.text-nav-group { + font-family: var(--font-mono); + font-size: 10px; + font-weight: 600; + letter-spacing: 0.1em; + text-transform: uppercase; + color: var(--muted-foreground); +} diff --git a/web/src/theme.ts b/web/src/theme.ts index 353e284..abba78e 100644 --- a/web/src/theme.ts +++ b/web/src/theme.ts @@ -1,353 +1,4 @@ -import { createTheme } from '@mui/material/styles'; - -// Sea Haven Ops design system — flat, dense, border-driven enterprise UI. -// Neutral navy/slate/gray structure; action-blue #2563EB is the only brand accent. -// Tokens mirror the Sea Haven Ops design bundle (tokens/colors.css, spacing.css, typography.css). -export const theme = createTheme({ - palette: { - primary: { - main: '#2563EB', // action blue — primary buttons, links, selected - light: '#3B82F6', - dark: '#1D4ED8', // contained-button hover/pressed - contrastText: '#FFFFFF', - }, - secondary: { - main: '#4B5563', // slate-600 - light: '#6B7280', - dark: '#374151', - contrastText: '#FFFFFF', - }, - success: { - main: '#16A34A', - light: '#DCFCE7', - dark: '#166534', - contrastText: '#FFFFFF', - }, - warning: { - main: '#D97706', // orange — Urgent - light: '#FFEDD5', - dark: '#9A3412', - contrastText: '#FFFFFF', - }, - error: { - main: '#DC2626', // red — Emergency / overdue / destructive - light: '#FEE2E2', - dark: '#991B1B', - contrastText: '#FFFFFF', - }, - info: { - main: '#2563EB', - light: '#DBEAFE', - dark: '#1E40AF', - contrastText: '#FFFFFF', - }, - background: { - default: '#F3F4F6', // gray-100 page background - paper: '#FFFFFF', - }, - text: { - primary: '#111827', // navy-900 - secondary: '#4B5563', // slate-600 - disabled: '#9CA3AF', // gray-400 - }, - divider: '#E5E7EB', // gray-200 - }, - - typography: { - fontFamily: '"Inter", "Segoe UI", Roboto, Helvetica, Arial, sans-serif', - fontSize: 14, - h4: { - fontWeight: 700, - fontSize: '24px', - lineHeight: 1.25, - letterSpacing: '-0.02em', - }, - h5: { - fontWeight: 600, - fontSize: '18px', - lineHeight: 1.3, - }, - h6: { - fontWeight: 600, - fontSize: '16px', - lineHeight: 1.4, - }, - subtitle1: { - fontWeight: 600, - fontSize: '14px', - lineHeight: 1.4, - }, - body1: { - fontSize: '14px', - lineHeight: 1.5, - }, - body2: { - fontSize: '13px', - lineHeight: 1.45, - }, - caption: { - fontSize: '12px', - lineHeight: 1.35, - color: '#6B7280', - }, - button: { - fontSize: '14px', - fontWeight: 600, - textTransform: 'none' as const, - }, - overline: { - fontSize: '11px', - lineHeight: '16px', - fontWeight: 700, - letterSpacing: '0.08em', - textTransform: 'uppercase' as const, - color: '#6B7280', - }, - }, - - shape: { - borderRadius: 6, - }, - - components: { - MuiCssBaseline: { - styleOverrides: { - body: { - fontFeatureSettings: '"tnum"', - backgroundColor: '#F3F4F6', - }, - }, - }, - - MuiAppBar: { - defaultProps: { - elevation: 0, - }, - styleOverrides: { - root: { - backgroundColor: '#111827', // navy-900 - boxShadow: 'none', - borderRadius: 0, - borderBottom: '1px solid rgba(255,255,255,0.08)', - }, - }, - }, - - MuiDrawer: { - styleOverrides: { - paper: { - backgroundColor: '#FFFFFF', - borderRight: '1px solid #E5E7EB', // border-subtle - width: 216, - }, - }, - }, - - MuiCard: { - defaultProps: { - elevation: 0, - }, - styleOverrides: { - // Cards are border-driven — no shadow (overlays only). - root: { - border: '1px solid #E5E7EB', - boxShadow: 'none', - borderRadius: 8, - }, - }, - }, - - MuiPaper: { - defaultProps: { - elevation: 0, - }, - styleOverrides: { - root: { - backgroundImage: 'none', - borderRadius: 6, - }, - }, - }, - - MuiButton: { - styleOverrides: { - root: { - borderRadius: 6, - boxShadow: 'none', - textTransform: 'none' as const, - fontWeight: 600, - height: 36, - paddingLeft: 14, - paddingRight: 14, - }, - contained: { - boxShadow: 'none', - '&:hover': { - boxShadow: 'none', - }, - }, - outlined: { - borderColor: '#D1D5DB', // border-default - color: '#111827', - '&:hover': { - borderColor: '#9CA3AF', // border-strong - backgroundColor: '#F9FAFB', // gray-50 - }, - }, - }, - }, - - MuiTextField: { - defaultProps: { - size: 'small' as const, - }, - styleOverrides: { - root: { - '& .MuiOutlinedInput-root': { - borderRadius: 6, - }, - }, - }, - }, - - MuiOutlinedInput: { - styleOverrides: { - root: { - borderRadius: 6, - minHeight: 38, - '& .MuiOutlinedInput-notchedOutline': { - borderColor: '#D1D5DB', - }, - '&:hover .MuiOutlinedInput-notchedOutline': { - borderColor: '#9CA3AF', - }, - '&.Mui-focused .MuiOutlinedInput-notchedOutline': { - borderColor: '#2563EB', - borderWidth: 2, - }, - }, - input: { - fontSize: 14, - padding: '9px 12px', - }, - }, - }, - - MuiInputLabel: { - styleOverrides: { - root: { - fontSize: 13, - color: '#6B7280', - '&.Mui-focused': { - color: '#2563EB', - }, - }, - }, - }, - - MuiTableHead: { - styleOverrides: { - root: { - '& .MuiTableCell-head': { - color: '#374151', // slate-700 - fontWeight: 700, - fontSize: 13, - paddingTop: 12, - paddingBottom: 12, - borderBottom: '1px solid #E5E7EB', - backgroundColor: '#F9FAFB', // gray-50 - whiteSpace: 'nowrap', - }, - }, - }, - }, - - MuiTableCell: { - styleOverrides: { - root: { - color: '#111827', - fontSize: 14, - padding: '12px 14px', - borderBottom: '1px solid #E5E7EB', - }, - }, - }, - - MuiTableRow: { - styleOverrides: { - root: { - '&:hover': { - backgroundColor: '#F9FAFB', - }, - }, - }, - }, - - MuiChip: { - styleOverrides: { - root: { - height: 24, - borderRadius: 6, - fontSize: 12, - fontWeight: 700, - }, - sizeSmall: { - height: 22, - fontSize: 12, - }, - label: { - paddingLeft: 8, - paddingRight: 8, - }, - icon: { - fontSize: 14, - marginLeft: 6, - }, - }, - }, - - MuiListItemButton: { - styleOverrides: { - root: { - borderRadius: 6, - marginLeft: 8, - marginRight: 8, - marginBottom: 4, - color: '#4B5563', - '&.Mui-selected': { - backgroundColor: '#EFF6FF', // blue-50 - borderLeft: '3px solid #2563EB', - color: '#111827', - '& .MuiListItemIcon-root': { - color: '#2563EB', - }, - '&:hover': { - backgroundColor: '#DBEAFE', // blue-100 - }, - }, - '&:hover': { - backgroundColor: '#F3F4F6', // gray-100 - }, - }, - }, - }, - - MuiListItemIcon: { - styleOverrides: { - root: { - color: '#9CA3AF', // gray-400 — inactive nav icon - minWidth: 36, - }, - }, - }, - - MuiTooltip: { - styleOverrides: { - tooltip: { - fontSize: 12, - backgroundColor: '#1F2937', // navy-800 - }, - }, - }, - }, -}); +// Sea Haven design system (ADR 0003) — SHOC dev token port. +// Tokens live in src/styles/theme.css (single source of truth); +// src/lib/theme/mui-theme.ts is the getCssVar → createTheme adapter. +export { muiTheme as theme } from './lib/theme/mui-theme'; From dfaee830f711714a6e4442ecf4b013d7d51e50b1 Mon Sep 17 00:00:00 2001 From: Adam Moussa <166072409+amoussa1229@users.noreply.github.com> Date: Mon, 13 Jul 2026 19:28:33 -0400 Subject: [PATCH 3/4] feat(contracts): shared api-contracts adoption, zod schemas, ProblemDetails codes (#222) * feat(web): adopt SHOC design system and shell layout (ADR 0003) Port shoc-frontend-new dev's design system with its CSS-variable single-token-source mechanism: - src/styles/theme.css: SHOC token file ported verbatim (Montserrat/ DM Sans/JetBrains Mono, primary #1c75bc, navy #262262, full radius/ shadow/sidebar/header token layers); fonts self-hosted via @fontsource - src/lib/theme/{css-vars,mui-theme}.ts: getCssVar -> createTheme adapter mirroring SHOC's mui-theme.ts (palette, typography, shadows tuple, component overrides; MUI v9 slot renames expressed as class selectors); theme.ts is now a re-export - Shell: SHOC composition (sidebar column + sticky gradient topbar + scrolling main); sidebar 244px/76px collapse with brand header row, grouped nav, SHOC active treatment (white card + 3px accent bar); topbar 100-degree gradient, surface hamburger, gradient avatar pill - Brand: SeahavenMark + BrandLockup ported (Tailwind re-expressed as sx; wordmark subtitle localized to PROPOSAL SYSTEM) - Login: SHOC auth-card treatment (centered 384px card on #f9fafb) - Old "Sea Haven Ops" Inter/#2563EB theme and Nunito remnants removed; remaining hardcoded hexes replaced with tokens; lucide-react for shell/nav icons per SHOC convention Verify: tsc clean, 26/26 vitest, vite build OK; Playwright screenshots pixel-sampled against the extracted SHOC spec (all hard values exact, no blocking deviations). * feat(contracts): adopt shared api-contracts in web, add zod schemas and ProblemDetails codes Closes WEB-M5 (web hand-duplicated wire types, standing drift risk): - shared/api-contracts: rewritten as the authoritative superset of the .NET DTOs (ProposalListItem/ProposalDetail with poNumber and submittedByName, line item requests, customers, pricing library, dashboard, audit, sites, auth, presigned upload, ApiProblem); stale Proposal/UpdateLineItemsRequest shapes removed - shared/api-contracts/src/schemas.ts: zod runtime schemas coupled to every wire type via `satisfies z.ZodType` (schema/type drift is now a compile error); separate entrypoint so type-only consumers (mobile) never pull zod - web: imports @proposal-system/api-contracts (file: dep + tsconfig paths + vite preserveSymlinks); all 7 lib/api modules re-export shared types so page imports stay stable; enum unions tightened (PricingLibraryPage form state now ServiceCategory-typed) - fix(web): customer create/update sent a singular `address` field the API silently dropped (contract is addresses: string[], CustomerDtos.cs) - addresses now round-trip, extra addresses preserved on edit - api: ProblemDetails responses carry a machine-readable top-level `code` (SHOC error-code vocabulary): ValidationFailed, InvalidStateTransition, NotFound, Unauthorized, InternalError; new BusinessRuleException(code, message) maps to 422 with its code; GlobalExceptionHandlerTests cover the full mapping (wire contract) Cross-checked .NET DTOs vs TS types vs zod schemas with the orchestrator scanner (Gemini): core domains consistent; internal-only DTOs (FileDtos vendor/lambda surface, SimilarProposalDtos, UserDtos admin surface) intentionally uncovered. Verify: dotnet 166/166, web tsc + vitest 26/26 + build, mobile tsc, shared tsc all green. * fix(web): install shared api-contracts deps via postinstall Web Frontend Check failed on PR #222: tsc compiles shared/api-contracts/src/schemas.ts through the tsconfig path alias, and module resolution for its zod import walks up from shared/, never reaching web/node_modules. CI only ran npm ci in web/, so the shared package's deps were absent. A postinstall hook installs them wherever web's deps are installed (CI typecheck, web-test, deploy bundling). Passed locally only because a stray repo-root node_modules/zod satisfied the lookup. --- .../Middleware/GlobalExceptionHandler.cs | 59 ++-- .../Common/BusinessRuleException.cs | 17 ++ .../Middleware/GlobalExceptionHandlerTests.cs | 96 ++++++ shared/api-contracts/package-lock.json | 12 + shared/api-contracts/package.json | 3 + shared/api-contracts/src/index.ts | 209 +++++++++++-- shared/api-contracts/src/schemas.ts | 276 ++++++++++++++++++ web/package-lock.json | 27 +- web/package.json | 5 +- web/src/lib/api/admin.ts | 29 +- web/src/lib/api/auth.ts | 17 +- web/src/lib/api/customers.ts | 49 ++-- web/src/lib/api/lineItems.ts | 49 +--- web/src/lib/api/pricingLibrary.ts | 56 +--- web/src/lib/api/proposals.ts | 113 ++----- web/src/lib/api/sites.ts | 11 +- .../customers/CustomerManagementPage.tsx | 5 +- .../pricing-library/PricingLibraryPage.tsx | 4 +- web/tsconfig.json | 8 +- web/vite.config.ts | 6 + 20 files changed, 744 insertions(+), 307 deletions(-) create mode 100644 api/src/ProposalSystem.Application/Common/BusinessRuleException.cs create mode 100644 api/tests/ProposalSystem.Tests/Middleware/GlobalExceptionHandlerTests.cs create mode 100644 shared/api-contracts/src/schemas.ts diff --git a/api/src/ProposalSystem.Api/Middleware/GlobalExceptionHandler.cs b/api/src/ProposalSystem.Api/Middleware/GlobalExceptionHandler.cs index 08ee88b..af6206f 100644 --- a/api/src/ProposalSystem.Api/Middleware/GlobalExceptionHandler.cs +++ b/api/src/ProposalSystem.Api/Middleware/GlobalExceptionHandler.cs @@ -2,6 +2,7 @@ using System.Net; using System.Text.Json; using FluentValidation; using Microsoft.AspNetCore.Mvc; +using ProposalSystem.Application.Common; namespace ProposalSystem.Api.Middleware; @@ -26,54 +27,52 @@ public class GlobalExceptionHandler : IMiddleware } } + private static ProblemDetails MakeProblem(int status, string title, string detail, string code) + { + var problem = new ProblemDetails + { + Status = status, + Title = title, + Detail = detail, + }; + problem.Extensions["code"] = code; // serialized top-level via [JsonExtensionData] + return problem; + } + private async Task HandleExceptionAsync(HttpContext context, Exception exception) { + // Every response carries a machine-readable "code" extension (SHOC + // error-code vocabulary convention) so clients branch on codes, not + // on human-readable text. var (statusCode, problemDetails) = exception switch { ValidationException validationEx => ( HttpStatusCode.BadRequest, - new ProblemDetails - { - Status = 400, - Title = "Validation Error", - Detail = string.Join("; ", validationEx.Errors.Select(e => e.ErrorMessage)), - } + MakeProblem(400, "Validation Error", + string.Join("; ", validationEx.Errors.Select(e => e.ErrorMessage)), + "ValidationFailed") + ), + BusinessRuleException businessEx => ( + HttpStatusCode.UnprocessableEntity, + MakeProblem(422, "Business Rule Violation", businessEx.Message, businessEx.Code) ), KeyNotFoundException => ( HttpStatusCode.NotFound, - new ProblemDetails - { - Status = 404, - Title = "Not Found", - Detail = "The requested resource was not found", - } + MakeProblem(404, "Not Found", "The requested resource was not found", "NotFound") ), UnauthorizedAccessException => ( HttpStatusCode.Unauthorized, - new ProblemDetails - { - Status = 401, - Title = "Unauthorized", - Detail = "Authentication required", - } + MakeProblem(401, "Unauthorized", "Authentication required", "Unauthorized") ), InvalidOperationException => ( HttpStatusCode.BadRequest, - new ProblemDetails - { - Status = 400, - Title = "Invalid Operation", - Detail = "The requested operation is not valid for the current state", - } + MakeProblem(400, "Invalid Operation", + "The requested operation is not valid for the current state", + "InvalidStateTransition") ), _ => ( HttpStatusCode.InternalServerError, - new ProblemDetails - { - Status = 500, - Title = "Internal Server Error", - Detail = "An unexpected error occurred", - } + MakeProblem(500, "Internal Server Error", "An unexpected error occurred", "InternalError") ), }; diff --git a/api/src/ProposalSystem.Application/Common/BusinessRuleException.cs b/api/src/ProposalSystem.Application/Common/BusinessRuleException.cs new file mode 100644 index 0000000..af1853a --- /dev/null +++ b/api/src/ProposalSystem.Application/Common/BusinessRuleException.cs @@ -0,0 +1,17 @@ +namespace ProposalSystem.Application.Common; + +/// +/// Business-rule violation carrying a machine-readable code, surfaced as +/// 422 ProblemDetails with a top-level "code" extension (SHOC error-code +/// vocabulary convention — e.g. "CancelNotAllowed"). Clients branch on the +/// code, never on the human-readable message. +/// +public class BusinessRuleException : Exception +{ + public string Code { get; } + + public BusinessRuleException(string code, string message) : base(message) + { + Code = code; + } +} diff --git a/api/tests/ProposalSystem.Tests/Middleware/GlobalExceptionHandlerTests.cs b/api/tests/ProposalSystem.Tests/Middleware/GlobalExceptionHandlerTests.cs new file mode 100644 index 0000000..78c5c04 --- /dev/null +++ b/api/tests/ProposalSystem.Tests/Middleware/GlobalExceptionHandlerTests.cs @@ -0,0 +1,96 @@ +using System.Text.Json; +using FluentAssertions; +using FluentValidation; +using FluentValidation.Results; +using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.Logging; +using NSubstitute; +using ProposalSystem.Api.Middleware; +using ProposalSystem.Application.Common; +using Xunit; + +namespace ProposalSystem.Tests.Middleware; + +/// +/// GlobalExceptionHandler tests — verifies the RFC 7807 ProblemDetails +/// mapping and the machine-readable "code" extension (SHOC error-code +/// vocabulary convention). Clients branch on code, so the code values are +/// wire contract: changing one is a breaking API change. +/// +public class GlobalExceptionHandlerTests +{ + private static async Task<(int Status, JsonElement Body)> InvokeWith(Exception exception) + { + var logger = Substitute.For>(); + var handler = new GlobalExceptionHandler(logger); + var context = new DefaultHttpContext(); + context.Response.Body = new MemoryStream(); + + await handler.InvokeAsync(context, _ => throw exception); + + context.Response.Body.Seek(0, SeekOrigin.Begin); + using var reader = new StreamReader(context.Response.Body); + var body = JsonDocument.Parse(await reader.ReadToEndAsync()).RootElement.Clone(); + return (context.Response.StatusCode, body); + } + + [Fact(DisplayName = "BusinessRuleException maps to 422 with its business code")] + public async Task BusinessRuleException_Maps422WithCode() + { + var (status, body) = await InvokeWith( + new BusinessRuleException("CancelNotAllowed", "Sent proposals cannot be canceled")); + + status.Should().Be(422); + body.GetProperty("code").GetString().Should().Be("CancelNotAllowed"); + body.GetProperty("title").GetString().Should().Be("Business Rule Violation"); + body.GetProperty("detail").GetString().Should().Be("Sent proposals cannot be canceled"); + } + + [Fact(DisplayName = "InvalidOperationException maps to 400 InvalidStateTransition")] + public async Task InvalidOperationException_Maps400InvalidStateTransition() + { + var (status, body) = await InvokeWith(new InvalidOperationException("bad transition")); + + status.Should().Be(400); + body.GetProperty("code").GetString().Should().Be("InvalidStateTransition"); + // Detail must stay generic — no internal exception text on the wire. + body.GetProperty("detail").GetString().Should().NotContain("bad transition"); + } + + [Fact(DisplayName = "ValidationException maps to 400 ValidationFailed")] + public async Task ValidationException_Maps400ValidationFailed() + { + var failures = new[] { new ValidationFailure("Name", "Name is required") }; + var (status, body) = await InvokeWith(new ValidationException(failures)); + + status.Should().Be(400); + body.GetProperty("code").GetString().Should().Be("ValidationFailed"); + body.GetProperty("detail").GetString().Should().Contain("Name is required"); + } + + [Theory(DisplayName = "Standard exceptions map to their status and code")] + [InlineData(typeof(KeyNotFoundException), 404, "NotFound")] + [InlineData(typeof(UnauthorizedAccessException), 401, "Unauthorized")] + [InlineData(typeof(ApplicationException), 500, "InternalError")] + public async Task StandardExceptions_MapToStatusAndCode(Type exceptionType, int expectedStatus, string expectedCode) + { + var exception = (Exception)Activator.CreateInstance(exceptionType)!; + var (status, body) = await InvokeWith(exception); + + status.Should().Be(expectedStatus); + body.GetProperty("code").GetString().Should().Be(expectedCode); + } + + [Fact(DisplayName = "Responses use application/problem+json")] + public async Task Responses_UseProblemJsonContentType() + { + var logger = Substitute.For>(); + var handler = new GlobalExceptionHandler(logger); + var context = new DefaultHttpContext(); + context.Response.Body = new MemoryStream(); + + await handler.InvokeAsync(context, _ => throw new KeyNotFoundException()); + + context.Response.ContentType.Should().Be("application/problem+json"); + } +} diff --git a/shared/api-contracts/package-lock.json b/shared/api-contracts/package-lock.json index 21217e1..3468ed6 100644 --- a/shared/api-contracts/package-lock.json +++ b/shared/api-contracts/package-lock.json @@ -7,6 +7,9 @@ "": { "name": "@proposal-system/api-contracts", "version": "0.1.0", + "dependencies": { + "zod": "^4.4.3" + }, "devDependencies": { "typescript": "~7.0.2" } @@ -385,6 +388,15 @@ "@typescript/typescript-win32-arm64": "7.0.2", "@typescript/typescript-win32-x64": "7.0.2" } + }, + "node_modules/zod": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/zod/-/zod-4.4.3.tgz", + "integrity": "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } } } } diff --git a/shared/api-contracts/package.json b/shared/api-contracts/package.json index 85c2a65..59f22a7 100644 --- a/shared/api-contracts/package.json +++ b/shared/api-contracts/package.json @@ -9,5 +9,8 @@ }, "devDependencies": { "typescript": "~7.0.2" + }, + "dependencies": { + "zod": "^4.4.3" } } diff --git a/shared/api-contracts/src/index.ts b/shared/api-contracts/src/index.ts index 6a14010..f189eb2 100644 --- a/shared/api-contracts/src/index.ts +++ b/shared/api-contracts/src/index.ts @@ -1,15 +1,46 @@ +// @proposal-system/api-contracts — single source of truth for API wire types. +// Fix: WEB-M5 — web and mobile both import these; shapes mirror the .NET DTOs +// in api/src/ProposalSystem.Application/DTOs/ (JsonStringEnumConverter → string +// enums, Guid → string, DateTime → ISO string, decimal → number). +// Runtime validation schemas live in ./schemas (separate entrypoint so +// consumers that only need types never pull zod). + +// ── Enums (string over the wire) ────────────────────────────────────────── export type ProposalStatus = 'Draft' | 'InReview' | 'Approved' | 'Sent' | 'Revised'; -// Fix: WEB-M4 — align with API enum (ProposalSystem.Domain.Entities.ServiceCategory includes 'Other') export type ServiceCategory = 'HVAC' | 'Plumbing' | 'Electrical' | 'General' | 'Renovation' | 'Other'; export type Priority = 'Standard' | 'Urgent' | 'Emergency'; export type LineItemSource = 'AI' | 'Vendor' | 'Manual' | 'Historical'; export type PricingMode = 'UnitPrice' | 'TotalPrice' | 'Both'; export type UserRole = 'Dispatcher' | 'Admin' | 'SysAdmin'; -export interface Proposal { +// ── Shared envelopes ────────────────────────────────────────────────────── +export interface PagedResponse { + items: T[]; + totalCount: number; + page: number; + pageSize: number; +} + +// ── Proposals (ProposalDtos.cs) ─────────────────────────────────────────── +export interface ProposalListItem { + id: string; + proposalNumber: string; + customerName: string; + workOrderNumber: string; + serviceCategory: ServiceCategory; + priority: Priority; + status: ProposalStatus; + totalBidAmount: number; + submittedAt: string; + submittedByName: string | null; + assignedAdminName: string | null; +} + +export interface ProposalDetail { id: string; proposalNumber: string; workOrderNumber: string; + poNumber: string | null; customerName: string; customerAddress: string; scopeOfWork: string; @@ -21,6 +52,7 @@ export interface Proposal { vendorTotalCost: number | null; notes: string; submittedById: string; + submittedByName: string | null; submittedAt: string; assignedAdminId: string | null; approvedById: string | null; @@ -32,6 +64,45 @@ export interface Proposal { updatedAt: string; } +export interface CreateProposalRequest { + workOrderNumber: string; + poNumber?: string; + customerName: string; + customerAddress: string; + scopeOfWork: string; + serviceCategory: ServiceCategory; + priority: Priority; + notes?: string; +} + +export interface UpdateProposalRequest { + refinedScope?: string; + notes?: string; + poNumber?: string; + workOrderNumber?: string; + assignedAdminId?: string; +} + +export interface ProposalFilters { + search?: string; + page?: number; + pageSize?: number; + status?: string; + serviceCategory?: string; + priority?: string; + fromDate?: string; + toDate?: string; + mine?: boolean; +} + +export interface ProposalStats { + totalCount: number; + inReviewCount: number; + approvedCount: number; + sentCount: number; +} + +// ── Line items (LineItemDtos.cs) ────────────────────────────────────────── export interface LineItem { id: string; proposalId: string; @@ -47,20 +118,34 @@ export interface LineItem { updatedAt: string; } -export interface CreateProposalRequest { - workOrderNumber: string; - customerName: string; - customerAddress: string; - scopeOfWork: string; - serviceCategory: ServiceCategory; - priority: Priority; - notes?: string; +export interface CreateLineItemRequest { + description: string; + quantity: number; + unit: string; + unitPrice: number | null; + totalPrice: number; + pricingMode: PricingMode; + sortOrder: number; + source: LineItemSource; } -export interface UpdateLineItemsRequest { - lineItems: Omit[]; +export interface UpdateLineItemEntry { + id: string | null; + description: string; + quantity: number; + unit: string; + unitPrice: number | null; + totalPrice: number; + pricingMode: PricingMode; + sortOrder: number; + source: LineItemSource; } +export interface BulkUpdateLineItemsRequest { + lineItems: UpdateLineItemEntry[]; +} + +// ── Customers (CustomerDtos.cs) ─────────────────────────────────────────── export interface Customer { id: string; name: string; @@ -69,6 +154,12 @@ export interface Customer { createdAt: string; } +export interface CustomerListParams { + page?: number; + pageSize?: number; + search?: string; +} + export interface CreateCustomerRequest { name: string; addresses?: string[]; @@ -81,14 +172,7 @@ export interface UpdateCustomerRequest { contactEmail?: string | null; } -export interface PagedResponse { - items: T[]; - totalCount: number; - page: number; - pageSize: number; -} - -// PR5: Pricing Library types +// ── Pricing library (PricingLibraryDtos.cs) ─────────────────────────────── export interface PricingLibraryItem { id: string; title: string; @@ -102,12 +186,17 @@ export interface PricingLibraryItem { updatedAt: string; } +export interface PricingLibraryListParams { + page?: number; + pageSize?: number; +} + export interface CreatePricingLibraryItemRequest { title: string; description?: string; serviceCategory: ServiceCategory; unit?: string; - unitPrice?: number; + unitPrice?: number | null; keywords?: string; source?: string; } @@ -117,6 +206,82 @@ export interface UpdatePricingLibraryItemRequest { description?: string; serviceCategory?: ServiceCategory; unit?: string; - unitPrice?: number; + unitPrice?: number | null; keywords?: string; } + +// ── Admin (DashboardDtos.cs, AuditDtos.cs) ──────────────────────────────── +export interface DashboardStats { + pendingCount: number; + approvedThisWeek: number; + avgTurnaroundHours: number; + totalProposals: number; +} + +export interface AuditEntry { + id: string; + proposalId: string | null; + userId: string; + userName: string | null; + action: string; + details: string | null; + timestamp: string; + ipAddress: string | null; +} + +// ── Sites (SiteDtos.cs) ─────────────────────────────────────────────────── +export interface Site { + siteCode: string; + fullAddress: string | null; + address: string | null; + city: string | null; + state: string | null; + zip: string | null; +} + +// ── Auth / users (UserDtos.cs, AuthController) ──────────────────────────── +export interface AuthUser { + id: string; + email: string; + displayName: string; + role: UserRole; + token: string; +} + +export interface UserProfile { + id: string; + email: string; + displayName: string; + role: UserRole; +} + +// ── Files / PDFs (FileDtos.cs) ──────────────────────────────────────────── +export interface PdfVersion { + revision: number; + generatedAt: string; +} + +export interface PresignedUpload { + uploadUrl: string; + s3Key: string; + expiresAt: string; + vendorProposalId: string; +} + +// ── Errors (GlobalExceptionHandler.cs) ──────────────────────────────────── +// RFC 7807 ProblemDetails + machine-readable business code (SHOC error-code +// vocabulary convention). Branch on `code`, never on title/detail text. +export type ApiProblemCode = + | 'ValidationFailed' + | 'InvalidStateTransition' + | 'NotFound' + | 'Unauthorized' + | 'InternalError' + | (string & {}); // business codes are open-ended (e.g. future CancelNotAllowed) + +export interface ApiProblem { + status: number; + title: string; + detail: string; + code: ApiProblemCode; +} diff --git a/shared/api-contracts/src/schemas.ts b/shared/api-contracts/src/schemas.ts new file mode 100644 index 0000000..6929c86 --- /dev/null +++ b/shared/api-contracts/src/schemas.ts @@ -0,0 +1,276 @@ +// Runtime validation schemas coupled to the wire types in ./index. +// Separate entrypoint by design: consumers that only need types (mobile) +// never pull zod. Each schema is compile-time-checked against its type via +// `satisfies z.ZodType` — if a DTO and its schema drift, tsc fails here. +import { z } from 'zod'; +import type { + ProposalStatus, + ServiceCategory, + Priority, + LineItemSource, + PricingMode, + UserRole, + ProposalListItem, + ProposalDetail, + CreateProposalRequest, + UpdateProposalRequest, + LineItem, + CreateLineItemRequest, + UpdateLineItemEntry, + BulkUpdateLineItemsRequest, + Customer, + CreateCustomerRequest, + UpdateCustomerRequest, + PricingLibraryItem, + CreatePricingLibraryItemRequest, + UpdatePricingLibraryItemRequest, + DashboardStats, + AuditEntry, + Site, + AuthUser, + UserProfile, + ProposalStats, + PdfVersion, + PresignedUpload, + ApiProblem, +} from './index'; + +// ── Enums ───────────────────────────────────────────────────────────────── +export const proposalStatusSchema = z.enum(['Draft', 'InReview', 'Approved', 'Sent', 'Revised']) satisfies z.ZodType; +export const serviceCategorySchema = z.enum(['HVAC', 'Plumbing', 'Electrical', 'General', 'Renovation', 'Other']) satisfies z.ZodType; +export const prioritySchema = z.enum(['Standard', 'Urgent', 'Emergency']) satisfies z.ZodType; +export const lineItemSourceSchema = z.enum(['AI', 'Vendor', 'Manual', 'Historical']) satisfies z.ZodType; +export const pricingModeSchema = z.enum(['UnitPrice', 'TotalPrice', 'Both']) satisfies z.ZodType; +export const userRoleSchema = z.enum(['Dispatcher', 'Admin', 'SysAdmin']) satisfies z.ZodType; + +// ── Proposals ───────────────────────────────────────────────────────────── +export const proposalListItemSchema = z.object({ + id: z.string(), + proposalNumber: z.string(), + customerName: z.string(), + workOrderNumber: z.string(), + serviceCategory: serviceCategorySchema, + priority: prioritySchema, + status: proposalStatusSchema, + totalBidAmount: z.number(), + submittedAt: z.string(), + submittedByName: z.string().nullable(), + assignedAdminName: z.string().nullable(), +}) satisfies z.ZodType; + +export const proposalDetailSchema = z.object({ + id: z.string(), + proposalNumber: z.string(), + workOrderNumber: z.string(), + poNumber: z.string().nullable(), + customerName: z.string(), + customerAddress: z.string(), + scopeOfWork: z.string(), + refinedScope: z.string().nullable(), + serviceCategory: serviceCategorySchema, + priority: prioritySchema, + status: proposalStatusSchema, + totalBidAmount: z.number(), + vendorTotalCost: z.number().nullable(), + notes: z.string(), + submittedById: z.string(), + submittedByName: z.string().nullable(), + submittedAt: z.string(), + assignedAdminId: z.string().nullable(), + approvedById: z.string().nullable(), + approvedAt: z.string().nullable(), + sentAt: z.string().nullable(), + currentRevision: z.number(), + parentProposalId: z.string().nullable(), + createdAt: z.string(), + updatedAt: z.string(), +}) satisfies z.ZodType; + +export const createProposalRequestSchema = z.object({ + workOrderNumber: z.string().min(1), + poNumber: z.string().optional(), + customerName: z.string().min(1), + customerAddress: z.string().min(1), + scopeOfWork: z.string().min(1), + serviceCategory: serviceCategorySchema, + priority: prioritySchema, + notes: z.string().optional(), +}) satisfies z.ZodType; + +export const updateProposalRequestSchema = z.object({ + refinedScope: z.string().optional(), + notes: z.string().optional(), + poNumber: z.string().optional(), + workOrderNumber: z.string().optional(), + assignedAdminId: z.string().optional(), +}) satisfies z.ZodType; + +export const proposalStatsSchema = z.object({ + totalCount: z.number(), + inReviewCount: z.number(), + approvedCount: z.number(), + sentCount: z.number(), +}) satisfies z.ZodType; + +// ── Line items ──────────────────────────────────────────────────────────── +export const lineItemSchema = z.object({ + id: z.string(), + proposalId: z.string(), + description: z.string(), + quantity: z.number(), + unit: z.string(), + unitPrice: z.number().nullable(), + totalPrice: z.number(), + pricingMode: pricingModeSchema, + sortOrder: z.number(), + source: lineItemSourceSchema, + createdAt: z.string(), + updatedAt: z.string(), +}) satisfies z.ZodType; + +export const createLineItemRequestSchema = z.object({ + description: z.string().min(1), + quantity: z.number(), + unit: z.string(), + unitPrice: z.number().nullable(), + totalPrice: z.number(), + pricingMode: pricingModeSchema, + sortOrder: z.number(), + source: lineItemSourceSchema, +}) satisfies z.ZodType; + +export const updateLineItemEntrySchema = z.object({ + id: z.string().nullable(), + description: z.string().min(1), + quantity: z.number(), + unit: z.string(), + unitPrice: z.number().nullable(), + totalPrice: z.number(), + pricingMode: pricingModeSchema, + sortOrder: z.number(), + source: lineItemSourceSchema, +}) satisfies z.ZodType; + +export const bulkUpdateLineItemsRequestSchema = z.object({ + lineItems: z.array(updateLineItemEntrySchema), +}) satisfies z.ZodType; + +// ── Customers ───────────────────────────────────────────────────────────── +export const customerSchema = z.object({ + id: z.string(), + name: z.string(), + addresses: z.array(z.string()), + contactEmail: z.string().nullable(), + createdAt: z.string(), +}) satisfies z.ZodType; + +export const createCustomerRequestSchema = z.object({ + name: z.string().min(1), + addresses: z.array(z.string()).optional(), + contactEmail: z.string().optional(), +}) satisfies z.ZodType; + +export const updateCustomerRequestSchema = z.object({ + name: z.string().optional(), + addresses: z.array(z.string()).optional(), + contactEmail: z.string().nullable().optional(), +}) satisfies z.ZodType; + +// ── Pricing library ─────────────────────────────────────────────────────── +export const pricingLibraryItemSchema = z.object({ + id: z.string(), + title: z.string(), + description: z.string().nullable(), + serviceCategory: serviceCategorySchema, + unit: z.string().nullable(), + unitPrice: z.number().nullable(), + keywords: z.string().nullable(), + source: z.string(), + createdAt: z.string(), + updatedAt: z.string(), +}) satisfies z.ZodType; + +export const createPricingLibraryItemRequestSchema = z.object({ + title: z.string().min(1), + description: z.string().optional(), + serviceCategory: serviceCategorySchema, + unit: z.string().optional(), + unitPrice: z.number().nullable().optional(), + keywords: z.string().optional(), + source: z.string().optional(), +}) satisfies z.ZodType; + +export const updatePricingLibraryItemRequestSchema = z.object({ + title: z.string().optional(), + description: z.string().optional(), + serviceCategory: serviceCategorySchema.optional(), + unit: z.string().optional(), + unitPrice: z.number().nullable().optional(), + keywords: z.string().optional(), +}) satisfies z.ZodType; + +// ── Admin ───────────────────────────────────────────────────────────────── +export const dashboardStatsSchema = z.object({ + pendingCount: z.number(), + approvedThisWeek: z.number(), + avgTurnaroundHours: z.number(), + totalProposals: z.number(), +}) satisfies z.ZodType; + +export const auditEntrySchema = z.object({ + id: z.string(), + proposalId: z.string().nullable(), + userId: z.string(), + userName: z.string().nullable(), + action: z.string(), + details: z.string().nullable(), + timestamp: z.string(), + ipAddress: z.string().nullable(), +}) satisfies z.ZodType; + +// ── Sites ───────────────────────────────────────────────────────────────── +export const siteSchema = z.object({ + siteCode: z.string(), + fullAddress: z.string().nullable(), + address: z.string().nullable(), + city: z.string().nullable(), + state: z.string().nullable(), + zip: z.string().nullable(), +}) satisfies z.ZodType; + +// ── Auth / users ────────────────────────────────────────────────────────── +export const authUserSchema = z.object({ + id: z.string(), + email: z.string(), + displayName: z.string(), + role: userRoleSchema, + token: z.string(), +}) satisfies z.ZodType; + +export const userProfileSchema = z.object({ + id: z.string(), + email: z.string(), + displayName: z.string(), + role: userRoleSchema, +}) satisfies z.ZodType; + +// ── Files / PDFs ────────────────────────────────────────────────────────── +export const pdfVersionSchema = z.object({ + revision: z.number(), + generatedAt: z.string(), +}) satisfies z.ZodType; + +export const presignedUploadSchema = z.object({ + uploadUrl: z.string(), + s3Key: z.string(), + expiresAt: z.string(), + vendorProposalId: z.string(), +}) satisfies z.ZodType; + +// ── Errors ──────────────────────────────────────────────────────────────── +export const apiProblemSchema = z.object({ + status: z.number(), + title: z.string(), + detail: z.string(), + code: z.string(), +}) satisfies z.ZodType; diff --git a/web/package-lock.json b/web/package-lock.json index 60df75c..5d716c3 100644 --- a/web/package-lock.json +++ b/web/package-lock.json @@ -15,6 +15,7 @@ "@fontsource/montserrat": "^5.2.8", "@mui/icons-material": "^9.2.0", "@mui/material": "^9.1.1", + "@proposal-system/api-contracts": "file:../shared/api-contracts", "@reduxjs/toolkit": "^2.11.2", "@tanstack/react-query": "^5.101.2", "axios": "^1.18.1", @@ -23,7 +24,8 @@ "react-dom": "^19.2.7", "react-redux": "^9.2.0", "react-router-dom": "^7.18.1", - "react-toastify": "^11.0.5" + "react-toastify": "^11.0.5", + "zod": "^4.4.3" }, "devDependencies": { "@testing-library/jest-dom": "^6.9.1", @@ -38,6 +40,16 @@ "vitest": "^4.1.10" } }, + "../shared/api-contracts": { + "name": "@proposal-system/api-contracts", + "version": "0.1.0", + "dependencies": { + "zod": "^4.4.3" + }, + "devDependencies": { + "typescript": "~7.0.2" + } + }, "node_modules/@adobe/css-tools": { "version": "4.5.0", "resolved": "https://registry.npmjs.org/@adobe/css-tools/-/css-tools-4.5.0.tgz", @@ -920,6 +932,10 @@ "url": "https://opencollective.com/popperjs" } }, + "node_modules/@proposal-system/api-contracts": { + "resolved": "../shared/api-contracts", + "link": true + }, "node_modules/@reduxjs/toolkit": { "version": "2.12.0", "resolved": "https://registry.npmjs.org/@reduxjs/toolkit/-/toolkit-2.12.0.tgz", @@ -4002,6 +4018,15 @@ "integrity": "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==", "dev": true, "license": "MIT" + }, + "node_modules/zod": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/zod/-/zod-4.4.3.tgz", + "integrity": "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } } } } diff --git a/web/package.json b/web/package.json index 42707cc..c9b4793 100644 --- a/web/package.json +++ b/web/package.json @@ -4,6 +4,7 @@ "private": true, "type": "module", "scripts": { + "postinstall": "npm ci --prefix ../shared/api-contracts", "dev": "vite", "build": "tsc -b && vite build", "preview": "vite preview", @@ -18,6 +19,7 @@ "@fontsource/montserrat": "^5.2.8", "@mui/icons-material": "^9.2.0", "@mui/material": "^9.1.1", + "@proposal-system/api-contracts": "file:../shared/api-contracts", "@reduxjs/toolkit": "^2.11.2", "@tanstack/react-query": "^5.101.2", "axios": "^1.18.1", @@ -26,7 +28,8 @@ "react-dom": "^19.2.7", "react-redux": "^9.2.0", "react-router-dom": "^7.18.1", - "react-toastify": "^11.0.5" + "react-toastify": "^11.0.5", + "zod": "^4.4.3" }, "devDependencies": { "@testing-library/jest-dom": "^6.9.1", diff --git a/web/src/lib/api/admin.ts b/web/src/lib/api/admin.ts index 47df6b8..918524c 100644 --- a/web/src/lib/api/admin.ts +++ b/web/src/lib/api/admin.ts @@ -1,31 +1,8 @@ import apiClient from './client'; -import { type ProposalDetail } from './proposals'; +// Fix: WEB-M5 — types from the shared contracts package. +import type { ProposalDetail, UpdateProposalRequest, DashboardStats, AuditEntry } from '@proposal-system/api-contracts'; -export interface DashboardStats { - pendingCount: number; - approvedThisWeek: number; - avgTurnaroundHours: number; - totalProposals: number; -} - -export interface UpdateProposalRequest { - refinedScope?: string; - notes?: string; - poNumber?: string; - workOrderNumber?: string; - assignedAdminId?: string; -} - -export interface AuditEntry { - id: string; - proposalId: string | null; - userId: string; - userName: string; - action: string; - details: string | null; - timestamp: string; - ipAddress: string | null; -} +export type { DashboardStats, UpdateProposalRequest, AuditEntry } from '@proposal-system/api-contracts'; export const adminApi = { getDashboard: async (): Promise => { diff --git a/web/src/lib/api/auth.ts b/web/src/lib/api/auth.ts index b964dc2..673cc77 100644 --- a/web/src/lib/api/auth.ts +++ b/web/src/lib/api/auth.ts @@ -1,19 +1,8 @@ import apiClient from './client'; +// Fix: WEB-M5 — types from the shared contracts package. +import type { AuthUser, UserProfile } from '@proposal-system/api-contracts'; -export interface AuthUser { - id: string; - email: string; - displayName: string; - role: 'Dispatcher' | 'Admin' | 'SysAdmin'; - token: string; -} - -export interface UserProfile { - id: string; - email: string; - displayName: string; - role: 'Dispatcher' | 'Admin' | 'SysAdmin'; -} +export type { AuthUser, UserProfile } from '@proposal-system/api-contracts'; export const authApi = { exchangeCode: async (code: string, redirectUri: string): Promise => { diff --git a/web/src/lib/api/customers.ts b/web/src/lib/api/customers.ts index ddef320..61ee7ee 100644 --- a/web/src/lib/api/customers.ts +++ b/web/src/lib/api/customers.ts @@ -1,38 +1,23 @@ import apiClient from './client'; +// Fix: WEB-M5 — types from the shared contracts package. Note: the API takes +// addresses: string[] (CustomerDtos.cs); the old local CreateCustomerRequest +// sent a singular `address` field the API silently dropped. +import type { + Customer, + CustomerListParams, + CreateCustomerRequest, + UpdateCustomerRequest, + PagedResponse, +} from '@proposal-system/api-contracts'; -export interface Customer { - id: string; - name: string; - addresses: string[]; - /** Contact email for proposal delivery. Null when not set. */ - contactEmail?: string | null; - createdAt: string; -} +export type { + Customer, + CustomerListParams, + CreateCustomerRequest, + UpdateCustomerRequest, +} from '@proposal-system/api-contracts'; -export interface CustomerListParams { - page?: number; - pageSize?: number; - search?: string; -} - -export interface PagedCustomerResponse { - items: Customer[]; - totalCount: number; - page: number; - pageSize: number; -} - -export interface CreateCustomerRequest { - name: string; - address: string; - contactEmail?: string; -} - -export interface UpdateCustomerRequest { - name: string; - address: string; - contactEmail?: string; -} +export type PagedCustomerResponse = PagedResponse; export const customersApi = { /** GET /api/customers?query= — search autocomplete (unchanged). */ diff --git a/web/src/lib/api/lineItems.ts b/web/src/lib/api/lineItems.ts index a3f3fc4..95f4eb8 100644 --- a/web/src/lib/api/lineItems.ts +++ b/web/src/lib/api/lineItems.ts @@ -1,45 +1,14 @@ import apiClient from './client'; +// Fix: WEB-M5 — types from the shared contracts package. +import type { LineItem, CreateLineItemRequest, UpdateLineItemEntry } from '@proposal-system/api-contracts'; -export type PricingMode = 'UnitPrice' | 'TotalPrice' | 'Both'; -export type LineItemSource = 'AI' | 'Vendor' | 'Manual' | 'Historical'; - -export interface LineItem { - id: string; - proposalId: string; - description: string; - quantity: number; - unit: string; - unitPrice: number | null; - totalPrice: number; - pricingMode: PricingMode; - sortOrder: number; - source: LineItemSource; - createdAt: string; - updatedAt: string; -} - -export interface CreateLineItemRequest { - description: string; - quantity: number; - unit: string; - unitPrice: number | null; - totalPrice: number; - pricingMode: PricingMode; - sortOrder: number; - source: LineItemSource; -} - -export interface UpdateLineItemEntry { - id: string | null; - description: string; - quantity: number; - unit: string; - unitPrice: number | null; - totalPrice: number; - pricingMode: PricingMode; - sortOrder: number; - source: LineItemSource; -} +export type { + PricingMode, + LineItemSource, + LineItem, + CreateLineItemRequest, + UpdateLineItemEntry, +} from '@proposal-system/api-contracts'; export const lineItemsApi = { getAll: async (proposalId: string): Promise => { diff --git a/web/src/lib/api/pricingLibrary.ts b/web/src/lib/api/pricingLibrary.ts index d477779..a0b4bb0 100644 --- a/web/src/lib/api/pricingLibrary.ts +++ b/web/src/lib/api/pricingLibrary.ts @@ -1,47 +1,21 @@ import apiClient from './client'; +// Fix: WEB-M5 — types from the shared contracts package. +import type { + PricingLibraryItem, + PricingLibraryListParams, + CreatePricingLibraryItemRequest, + UpdatePricingLibraryItemRequest, + PagedResponse, +} from '@proposal-system/api-contracts'; -export interface PricingLibraryItem { - id: string; - title: string; - description?: string | null; - serviceCategory: string; - unit?: string | null; - unitPrice?: number | null; - keywords?: string | null; - source: string; - createdAt: string; - updatedAt: string; -} +export type { + PricingLibraryItem, + PricingLibraryListParams, + CreatePricingLibraryItemRequest, + UpdatePricingLibraryItemRequest, +} from '@proposal-system/api-contracts'; -export interface PricingLibraryListParams { - page?: number; - pageSize?: number; -} - -export interface PagedPricingLibraryResponse { - items: PricingLibraryItem[]; - totalCount: number; - page: number; - pageSize: number; -} - -export interface CreatePricingLibraryItemRequest { - title: string; - serviceCategory: string; - description?: string; - unit?: string; - unitPrice?: number | null; - keywords?: string; -} - -export interface UpdatePricingLibraryItemRequest { - title: string; - serviceCategory: string; - description?: string; - unit?: string; - unitPrice?: number | null; - keywords?: string; -} +export type PagedPricingLibraryResponse = PagedResponse; export const pricingLibraryApi = { /** GET /api/pricing-library/list?page=&pageSize= — paginated list (admin/sysadmin). */ diff --git a/web/src/lib/api/proposals.ts b/web/src/lib/api/proposals.ts index cac276c..024d1d8 100644 --- a/web/src/lib/api/proposals.ts +++ b/web/src/lib/api/proposals.ts @@ -1,80 +1,29 @@ import apiClient from './client'; +// Fix: WEB-M5 — types now come from the shared contracts package (single source +// of truth aligned with the .NET DTOs); re-exported so page imports stay stable. +import type { + CreateProposalRequest, + ProposalDetail, + ProposalListItem, + ProposalFilters, + ProposalStats, + PagedResponse, + PdfVersion, + PresignedUpload, +} from '@proposal-system/api-contracts'; -// Fix: WEB-M5 — align with shared contract types (shared/api-contracts/src/index.ts) -export type ServiceCategory = 'HVAC' | 'Plumbing' | 'Electrical' | 'General' | 'Renovation' | 'Other'; -export type Priority = 'Standard' | 'Urgent' | 'Emergency'; - -export interface CreateProposalRequest { - workOrderNumber: string; - poNumber?: string; - customerName: string; - customerAddress: string; - scopeOfWork: string; - serviceCategory: ServiceCategory; - priority: Priority; - notes?: string; -} - -export interface ProposalListItem { - id: string; - proposalNumber: string; - customerName: string; - workOrderNumber: string; - serviceCategory: string; - priority: string; - status: string; - totalBidAmount: number; - submittedAt: string; - submittedByName: string | null; - assignedAdminName: string | null; -} - -export interface ProposalDetail { - id: string; - proposalNumber: string; - workOrderNumber: string; - poNumber: string | null; - customerName: string; - customerAddress: string; - scopeOfWork: string; - refinedScope: string | null; - serviceCategory: string; - priority: string; - status: string; - totalBidAmount: number; - vendorTotalCost: number | null; - notes: string; - submittedById: string; - submittedByName: string | null; - submittedAt: string; - assignedAdminId: string | null; - approvedById: string | null; - approvedAt: string | null; - sentAt: string | null; - currentRevision: number; - parentProposalId: string | null; - createdAt: string; - updatedAt: string; -} - -export interface PagedResponse { - items: T[]; - totalCount: number; - page: number; - pageSize: number; -} - -export interface ProposalFilters { - search?: string; - page?: number; - pageSize?: number; - status?: string; - serviceCategory?: string; - priority?: string; - fromDate?: string; - toDate?: string; - mine?: boolean; -} +export type { + ServiceCategory, + Priority, + CreateProposalRequest, + ProposalDetail, + ProposalListItem, + ProposalFilters, + ProposalStats, + PagedResponse, + PdfVersion, + PresignedUpload, +} from '@proposal-system/api-contracts'; export const proposalsApi = { create: async (data: CreateProposalRequest): Promise => { @@ -103,7 +52,7 @@ export const proposalsApi = { return res.data; }, - uploadAttachment: async (proposalId: string, fileName: string, vendorName?: string): Promise<{ uploadUrl: string; s3Key: string; vendorProposalId: string }> => { + uploadAttachment: async (proposalId: string, fileName: string, vendorName?: string): Promise => { const params = new URLSearchParams({ fileName }); if (vendorName) params.append('vendorName', vendorName); const res = await apiClient.post(`/proposals/${proposalId}/attachments?${params.toString()}`); @@ -146,15 +95,3 @@ export const proposalsApi = { return { downloadUrl }; }, }; - -export interface ProposalStats { - totalCount: number; - inReviewCount: number; - approvedCount: number; - sentCount: number; -} - -export interface PdfVersion { - revision: number; - generatedAt: string; -} diff --git a/web/src/lib/api/sites.ts b/web/src/lib/api/sites.ts index e65bb19..eb0b8a8 100644 --- a/web/src/lib/api/sites.ts +++ b/web/src/lib/api/sites.ts @@ -1,13 +1,8 @@ import apiClient from './client'; +// Fix: WEB-M5 — types from the shared contracts package. +import type { Site } from '@proposal-system/api-contracts'; -export interface Site { - siteCode: string; - fullAddress: string | null; - address: string | null; - city: string | null; - state: string | null; - zip: string | null; -} +export type { Site } from '@proposal-system/api-contracts'; export const sitesApi = { search: async (query: string): Promise => { diff --git a/web/src/pages/admin/customers/CustomerManagementPage.tsx b/web/src/pages/admin/customers/CustomerManagementPage.tsx index f5ec909..bd6a515 100644 --- a/web/src/pages/admin/customers/CustomerManagementPage.tsx +++ b/web/src/pages/admin/customers/CustomerManagementPage.tsx @@ -176,9 +176,12 @@ export default function CustomerManagementPage() { const handleSubmit = () => { if (!validateForm()) return; + // Fix: WEB-M5 — the API contract is addresses: string[] (CustomerDtos.cs); + // the old singular `address` field was silently dropped by model binding. + // The form edits the primary address; any additional addresses are preserved. const payload = { name: form.name.trim(), - address: form.address.trim(), + addresses: [form.address.trim(), ...(editingCustomer?.addresses.slice(1) ?? [])], ...(form.contactEmail.trim() ? { contactEmail: form.contactEmail.trim() } : {}), }; diff --git a/web/src/pages/admin/pricing-library/PricingLibraryPage.tsx b/web/src/pages/admin/pricing-library/PricingLibraryPage.tsx index 099c2a7..ea200c6 100644 --- a/web/src/pages/admin/pricing-library/PricingLibraryPage.tsx +++ b/web/src/pages/admin/pricing-library/PricingLibraryPage.tsx @@ -37,6 +37,7 @@ import { type CreatePricingLibraryItemRequest, type UpdatePricingLibraryItemRequest, } from '../../../lib/api/pricingLibrary'; +import type { ServiceCategory } from '@proposal-system/api-contracts'; import { queryClient } from '../../../lib/queryClient'; import { QUERY_KEYS } from '../../../constants/queryKeys'; import { DEFAULT_PAGE, DEFAULT_PAGE_SIZE, SERVICE_CATEGORIES } from '../../../constants'; @@ -56,7 +57,7 @@ const CATEGORY_CHIP_STYLES: Record { if (!validateForm()) return; + if (!form.serviceCategory) return; // validateForm guarantees this; narrows '' out of the union const payload = { title: form.title.trim(), diff --git a/web/tsconfig.json b/web/tsconfig.json index 1bc6e12..0651de3 100644 --- a/web/tsconfig.json +++ b/web/tsconfig.json @@ -15,7 +15,11 @@ "noUnusedLocals": true, "noUnusedParameters": true, "noFallthroughCasesInSwitch": true, - "noUncheckedIndexedAccess": true + "noUncheckedIndexedAccess": true, + "paths": { + "@proposal-system/api-contracts": ["../shared/api-contracts/src"], + "@proposal-system/api-contracts/schemas": ["../shared/api-contracts/src/schemas"] + } }, - "include": ["src"] + "include": ["src", "../shared/api-contracts/src"] } diff --git a/web/vite.config.ts b/web/vite.config.ts index 4953e6a..782b661 100644 --- a/web/vite.config.ts +++ b/web/vite.config.ts @@ -3,6 +3,12 @@ import react from '@vitejs/plugin-react'; export default defineConfig({ plugins: [react()], + resolve: { + // @proposal-system/api-contracts is a file: symlink; preserve it so its + // 'zod' import resolves from web/node_modules in CI (shared has no + // installed node_modules there). + preserveSymlinks: true, + }, server: { port: 5173, proxy: { From efcb13838ef33f221499de618756f11a34ceb886 Mon Sep 17 00:00:00 2001 From: Adam Moussa <166072409+amoussa1229@users.noreply.github.com> Date: Mon, 13 Jul 2026 20:11:11 -0400 Subject: [PATCH 4/4] refactor(web): domain-layer restructure (SHOC layering) + react-hook-form (#223) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(web): adopt SHOC design system and shell layout (ADR 0003) Port shoc-frontend-new dev's design system with its CSS-variable single-token-source mechanism: - src/styles/theme.css: SHOC token file ported verbatim (Montserrat/ DM Sans/JetBrains Mono, primary #1c75bc, navy #262262, full radius/ shadow/sidebar/header token layers); fonts self-hosted via @fontsource - src/lib/theme/{css-vars,mui-theme}.ts: getCssVar -> createTheme adapter mirroring SHOC's mui-theme.ts (palette, typography, shadows tuple, component overrides; MUI v9 slot renames expressed as class selectors); theme.ts is now a re-export - Shell: SHOC composition (sidebar column + sticky gradient topbar + scrolling main); sidebar 244px/76px collapse with brand header row, grouped nav, SHOC active treatment (white card + 3px accent bar); topbar 100-degree gradient, surface hamburger, gradient avatar pill - Brand: SeahavenMark + BrandLockup ported (Tailwind re-expressed as sx; wordmark subtitle localized to PROPOSAL SYSTEM) - Login: SHOC auth-card treatment (centered 384px card on #f9fafb) - Old "Sea Haven Ops" Inter/#2563EB theme and Nunito remnants removed; remaining hardcoded hexes replaced with tokens; lucide-react for shell/nav icons per SHOC convention Verify: tsc clean, 26/26 vitest, vite build OK; Playwright screenshots pixel-sampled against the extracted SHOC spec (all hard values exact, no blocking deviations). * feat(contracts): adopt shared api-contracts in web, add zod schemas and ProblemDetails codes Closes WEB-M5 (web hand-duplicated wire types, standing drift risk): - shared/api-contracts: rewritten as the authoritative superset of the .NET DTOs (ProposalListItem/ProposalDetail with poNumber and submittedByName, line item requests, customers, pricing library, dashboard, audit, sites, auth, presigned upload, ApiProblem); stale Proposal/UpdateLineItemsRequest shapes removed - shared/api-contracts/src/schemas.ts: zod runtime schemas coupled to every wire type via `satisfies z.ZodType` (schema/type drift is now a compile error); separate entrypoint so type-only consumers (mobile) never pull zod - web: imports @proposal-system/api-contracts (file: dep + tsconfig paths + vite preserveSymlinks); all 7 lib/api modules re-export shared types so page imports stay stable; enum unions tightened (PricingLibraryPage form state now ServiceCategory-typed) - fix(web): customer create/update sent a singular `address` field the API silently dropped (contract is addresses: string[], CustomerDtos.cs) - addresses now round-trip, extra addresses preserved on edit - api: ProblemDetails responses carry a machine-readable top-level `code` (SHOC error-code vocabulary): ValidationFailed, InvalidStateTransition, NotFound, Unauthorized, InternalError; new BusinessRuleException(code, message) maps to 422 with its code; GlobalExceptionHandlerTests cover the full mapping (wire contract) Cross-checked .NET DTOs vs TS types vs zod schemas with the orchestrator scanner (Gemini): core domains consistent; internal-only DTOs (FileDtos vendor/lambda surface, SimilarProposalDtos, UserDtos admin surface) intentionally uncovered. Verify: dotnet 166/166, web tsc + vitest 26/26 + build, mobile tsc, shared tsc all green. * feat(web): domain-layer conventions doc + react-hook-form deps (Phase 4 prep) * feat(web): scaffold domain module layer (proposals, lineItems, customers, pricingLibrary, admin, sites) Additive-only: pages still use lib/api/* and constants/queryKeys.ts until the page-migration agents run. Each domain ships api.ts (HTTP moved from lib/api), types.ts (contract re-exports + view types), schemas.ts (contract schema re-exports + form schemas with toRequest mappers), and use-cases.ts (TanStack Query v5 hooks + hierarchical query keys, mirroring current page invalidations and toast-on-error behavior). Adds an explicit vite/vitest alias for the @proposal-system/api-contracts/schemas subpath (package has no exports map) plus a schema/mapper smoke test suite. * refactor(web): proposal pages on domain layer, proposal form on react-hook-form * refactor(web): admin workspace on domain layer * refactor(web): customer management on domain layer + react-hook-form * refactor(web): pricing library on domain layer + react-hook-form * refactor(web): dashboards on domain layer * test(web): domain use-case hook coverage * refactor(web): finish domain-layer integration — migrate straggler components, delete legacy api modules - SimilarProposalsPanel -> useSimilarProposals (domain/admin); inline SimilarProposal type replaced by domain/admin/types (identical shape); query key joins the admin hierarchical key space - LineItemEditor type imports -> domain/lineItems/types - Delete now-orphaned lib/api/{proposals,lineItems,customers, pricingLibrary,admin,sites}.ts, constants/queryKeys.ts, hooks/usePaginatedList.ts (lib/api/client.ts + auth.ts stay per domain README rule 5) Verify: tsc clean, vitest 52/52, build OK, Playwright smoke of the authed shell renders on domain hooks. * fix(web): apply Phase 4 code-review findings (10 correctness + 4 cleanup) Correctness: - State-transition mutations now invalidate every cached view via invalidateProposalViews (detail + line items + lists + stats + admin dashboard) — approving no longer leaves a stale queue for the 5-minute staleTime - Presigned S3 PUT moved to proposals/api.ts with res.ok check — a rejected upload is no longer confirmed as uploaded - toCustomerRequest always sends contactEmail ('' clears); API create path normalizes empty->null to match the update path — customer emails can now be cleared from the UI - Shared Number-based numeric form fields (domain/shared/formFields): '12abc' no longer silently coerces to 12 in the pricing library - Customer create/update invalidate customersKeys.all so cached search autocompletes see new customers - AdminWorkspace clears dirty right after a successful implicit save, before approve — no false unsaved-changes prompt when approve fails - ProposalFormPage submit gate and missing-fields caption derive from ONE checks list (missing customer is now listed) - Empty states gated on !err in ProposalListPage/AdminDashboard — no contradictory error + 'no proposals' UI - VendorDataPanel migrated to useVendorProposals (kills the divergent ['vendorProposals', id] cache key and the inline apiClient query) - useCustomerList/usePricingLibraryList get keepPreviousData — no TablePagination out-of-range flash on page change Cleanup: - Dead speculative hooks removed (useCreate/BulkUpdate/DeleteLineItem, useUpdateProposal, useProposalHistory/Audit, lineItemRowFormSchema, toUpdateLineItemEntry); tests moved to the live save path (useSaveProposalWorkspace) - Shared useDebouncedValue hook replaces 4 drifted inline debounce copies (one leaked its timer on unmount, two hardcoded 300ms); DEBOUNCE_AUTOCOMPLETE=300 named - Fix: WEB-H5 / WEB-H6 finding-ID markers restored at the relocated onError handlers (CLAUDE.md traceability) - shared/api-contracts gains an exports map; /schemas resolver alias deduplicated from 3 copies to the tsconfig paths mapping Verify: tsc clean, vitest 51/51 (tests updated to pin the new invalidation/mapper behavior + new '12abc' rejection test), vite build OK, dotnet 166/166. --- .../Services/CustomerService.cs | 4 +- shared/api-contracts/package.json | 10 + web/package-lock.json | 30 ++ web/package.json | 2 + web/src/components/admin/LineItemEditor.tsx | 2 +- .../admin/SimilarProposalsPanel.tsx | 24 +- web/src/components/admin/VendorDataPanel.tsx | 28 +- web/src/constants/index.ts | 1 + web/src/constants/queryKeys.ts | 11 - web/src/domain/README.md | 47 +++ .../domain/__tests__/admin.use-cases.test.tsx | 99 +++++ .../__tests__/customers.use-cases.test.tsx | 99 +++++ web/src/domain/__tests__/hookTestUtils.tsx | 18 + .../__tests__/lineItems.use-cases.test.tsx | 100 +++++ .../__tests__/proposals.use-cases.test.tsx | 126 ++++++ web/src/domain/__tests__/schemas.test.ts | 137 +++++++ .../{lib/api/admin.ts => domain/admin/api.ts} | 11 +- web/src/domain/admin/schemas.ts | 7 + web/src/domain/admin/types.ts | 19 + web/src/domain/admin/use-cases.ts | 163 ++++++++ .../customers.ts => domain/customers/api.ts} | 19 +- web/src/domain/customers/schemas.ts | 50 +++ web/src/domain/customers/types.ts | 11 + web/src/domain/customers/use-cases.ts | 68 ++++ .../lineItems.ts => domain/lineItems/api.ts} | 14 +- web/src/domain/lineItems/schemas.ts | 11 + web/src/domain/lineItems/types.ts | 9 + web/src/domain/lineItems/use-cases.ts | 19 + .../pricingLibrary/api.ts} | 17 +- web/src/domain/pricingLibrary/schemas.ts | 49 +++ web/src/domain/pricingLibrary/types.ts | 11 + web/src/domain/pricingLibrary/use-cases.ts | 69 ++++ .../proposals.ts => domain/proposals/api.ts} | 45 ++- web/src/domain/proposals/schemas.ts | 68 ++++ web/src/domain/proposals/types.ts | 32 ++ web/src/domain/proposals/use-cases.ts | 95 +++++ web/src/domain/shared/formFields.ts | 23 ++ web/src/domain/sites/api.ts | 10 + web/src/domain/sites/schemas.ts | 3 + web/src/domain/sites/types.ts | 2 + web/src/domain/sites/use-cases.ts | 18 + web/src/hooks/useDebouncedValue.ts | 32 ++ web/src/hooks/usePaginatedList.ts | 76 ---- web/src/lib/api/sites.ts | 12 - .../customers/CustomerManagementPage.tsx | 252 +++++------- .../pages/admin/dashboard/AdminDashboard.tsx | 64 +-- .../pricing-library/PricingLibraryPage.tsx | 364 ++++++++---------- .../pages/admin/workspace/AdminWorkspace.tsx | 245 +++++------- web/src/pages/dashboard/Dashboard.tsx | 14 +- .../proposals/detail/ProposalDetailPage.tsx | 9 +- .../pages/proposals/form/ProposalFormPage.tsx | 342 +++++++--------- .../pages/proposals/list/ProposalListPage.tsx | 42 +- web/tsconfig.json | 2 +- web/vite.config.ts | 3 +- web/vitest.config.ts | 6 + 55 files changed, 2079 insertions(+), 965 deletions(-) delete mode 100644 web/src/constants/queryKeys.ts create mode 100644 web/src/domain/README.md create mode 100644 web/src/domain/__tests__/admin.use-cases.test.tsx create mode 100644 web/src/domain/__tests__/customers.use-cases.test.tsx create mode 100644 web/src/domain/__tests__/hookTestUtils.tsx create mode 100644 web/src/domain/__tests__/lineItems.use-cases.test.tsx create mode 100644 web/src/domain/__tests__/proposals.use-cases.test.tsx create mode 100644 web/src/domain/__tests__/schemas.test.ts rename web/src/{lib/api/admin.ts => domain/admin/api.ts} (82%) create mode 100644 web/src/domain/admin/schemas.ts create mode 100644 web/src/domain/admin/types.ts create mode 100644 web/src/domain/admin/use-cases.ts rename web/src/{lib/api/customers.ts => domain/customers/api.ts} (73%) create mode 100644 web/src/domain/customers/schemas.ts create mode 100644 web/src/domain/customers/types.ts create mode 100644 web/src/domain/customers/use-cases.ts rename web/src/{lib/api/lineItems.ts => domain/lineItems/api.ts} (74%) create mode 100644 web/src/domain/lineItems/schemas.ts create mode 100644 web/src/domain/lineItems/types.ts create mode 100644 web/src/domain/lineItems/use-cases.ts rename web/src/{lib/api/pricingLibrary.ts => domain/pricingLibrary/api.ts} (77%) create mode 100644 web/src/domain/pricingLibrary/schemas.ts create mode 100644 web/src/domain/pricingLibrary/types.ts create mode 100644 web/src/domain/pricingLibrary/use-cases.ts rename web/src/{lib/api/proposals.ts => domain/proposals/api.ts} (72%) create mode 100644 web/src/domain/proposals/schemas.ts create mode 100644 web/src/domain/proposals/types.ts create mode 100644 web/src/domain/proposals/use-cases.ts create mode 100644 web/src/domain/shared/formFields.ts create mode 100644 web/src/domain/sites/api.ts create mode 100644 web/src/domain/sites/schemas.ts create mode 100644 web/src/domain/sites/types.ts create mode 100644 web/src/domain/sites/use-cases.ts create mode 100644 web/src/hooks/useDebouncedValue.ts delete mode 100644 web/src/hooks/usePaginatedList.ts delete mode 100644 web/src/lib/api/sites.ts diff --git a/api/src/ProposalSystem.Infrastructure/Services/CustomerService.cs b/api/src/ProposalSystem.Infrastructure/Services/CustomerService.cs index a617206..451688e 100644 --- a/api/src/ProposalSystem.Infrastructure/Services/CustomerService.cs +++ b/api/src/ProposalSystem.Infrastructure/Services/CustomerService.cs @@ -65,7 +65,9 @@ public class CustomerService : ICustomerService Id = Guid.NewGuid(), Name = request.Name, Addresses = request.Addresses != null ? JsonSerializer.Serialize(request.Addresses) : null, - ContactEmail = request.ContactEmail, + // Normalize empty to null, matching the update path — clients may + // send contactEmail: "" (the web form always includes the field). + ContactEmail = string.IsNullOrEmpty(request.ContactEmail) ? null : request.ContactEmail, CreatedAt = now, UpdatedAt = now, }; diff --git a/shared/api-contracts/package.json b/shared/api-contracts/package.json index 59f22a7..7bebfa2 100644 --- a/shared/api-contracts/package.json +++ b/shared/api-contracts/package.json @@ -4,6 +4,16 @@ "private": true, "main": "./src/index.ts", "types": "./src/index.ts", + "exports": { + ".": { + "types": "./src/index.ts", + "default": "./src/index.ts" + }, + "./schemas": { + "types": "./src/schemas.ts", + "default": "./src/schemas.ts" + } + }, "scripts": { "typecheck": "tsc --noEmit" }, diff --git a/web/package-lock.json b/web/package-lock.json index 5d716c3..686754c 100644 --- a/web/package-lock.json +++ b/web/package-lock.json @@ -13,6 +13,7 @@ "@fontsource/dm-sans": "^5.2.8", "@fontsource/jetbrains-mono": "^5.2.8", "@fontsource/montserrat": "^5.2.8", + "@hookform/resolvers": "^5.4.0", "@mui/icons-material": "^9.2.0", "@mui/material": "^9.1.1", "@proposal-system/api-contracts": "file:../shared/api-contracts", @@ -22,6 +23,7 @@ "lucide-react": "^1.24.0", "react": "^19.2.7", "react-dom": "^19.2.7", + "react-hook-form": "^7.81.0", "react-redux": "^9.2.0", "react-router-dom": "^7.18.1", "react-toastify": "^11.0.5", @@ -625,6 +627,18 @@ "url": "https://github.com/sponsors/ayuhito" } }, + "node_modules/@hookform/resolvers": { + "version": "5.4.0", + "resolved": "https://registry.npmjs.org/@hookform/resolvers/-/resolvers-5.4.0.tgz", + "integrity": "sha512-EIsqr/t/qbinPIhGjMdtvutIN1Kk4uwbROE9/UQ93CAVGR7GkA7Y92+fX80OzXi/OB67jVFYwKGO1WzkxmkFZw==", + "license": "MIT", + "dependencies": { + "@standard-schema/utils": "^0.3.0" + }, + "peerDependencies": { + "react-hook-form": "^7.55.0" + } + }, "node_modules/@jridgewell/gen-mapping": { "version": "0.3.13", "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", @@ -3309,6 +3323,22 @@ "react": "^19.2.7" } }, + "node_modules/react-hook-form": { + "version": "7.81.0", + "resolved": "https://registry.npmjs.org/react-hook-form/-/react-hook-form-7.81.0.tgz", + "integrity": "sha512-ocbmr2p5KBMoAfj4WCUvped33lVi1Kd5DuDUvQDnB6VEAacOjPI/jMbtDdbhco4y9ct4xUuCmMY0b/C9L0QHjw==", + "license": "MIT", + "engines": { + "node": ">=18.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/react-hook-form" + }, + "peerDependencies": { + "react": "^16.8.0 || ^17 || ^18 || ^19" + } + }, "node_modules/react-is": { "version": "19.2.7", "resolved": "https://registry.npmjs.org/react-is/-/react-is-19.2.7.tgz", diff --git a/web/package.json b/web/package.json index c9b4793..f7371f8 100644 --- a/web/package.json +++ b/web/package.json @@ -17,6 +17,7 @@ "@fontsource/dm-sans": "^5.2.8", "@fontsource/jetbrains-mono": "^5.2.8", "@fontsource/montserrat": "^5.2.8", + "@hookform/resolvers": "^5.4.0", "@mui/icons-material": "^9.2.0", "@mui/material": "^9.1.1", "@proposal-system/api-contracts": "file:../shared/api-contracts", @@ -26,6 +27,7 @@ "lucide-react": "^1.24.0", "react": "^19.2.7", "react-dom": "^19.2.7", + "react-hook-form": "^7.81.0", "react-redux": "^9.2.0", "react-router-dom": "^7.18.1", "react-toastify": "^11.0.5", diff --git a/web/src/components/admin/LineItemEditor.tsx b/web/src/components/admin/LineItemEditor.tsx index 42e0674..0f7b7fb 100644 --- a/web/src/components/admin/LineItemEditor.tsx +++ b/web/src/components/admin/LineItemEditor.tsx @@ -19,7 +19,7 @@ import ContentCopyIcon from '@mui/icons-material/ContentCopy'; import DeleteIcon from '@mui/icons-material/Delete'; import ArrowUpwardIcon from '@mui/icons-material/ArrowUpward'; import ArrowDownwardIcon from '@mui/icons-material/ArrowDownward'; -import type { PricingMode, LineItemSource } from '../../lib/api/lineItems'; +import type { PricingMode, LineItemSource } from '../../domain/lineItems/types'; export interface EditableLineItem { id: string | null; diff --git a/web/src/components/admin/SimilarProposalsPanel.tsx b/web/src/components/admin/SimilarProposalsPanel.tsx index 6dfad4c..fec0149 100644 --- a/web/src/components/admin/SimilarProposalsPanel.tsx +++ b/web/src/components/admin/SimilarProposalsPanel.tsx @@ -1,4 +1,3 @@ -import { useQuery } from '@tanstack/react-query'; import { Box, Typography, @@ -11,25 +10,10 @@ import { } from '@mui/material'; import ExpandMoreIcon from '@mui/icons-material/ExpandMore'; import ContentCopyIcon from '@mui/icons-material/ContentCopy'; -import { adminApi } from '../../lib/api/admin'; +import { useSimilarProposals } from '../../domain/admin/use-cases'; import { formatCurrency } from '../../lib/format'; import type { EditableLineItem } from './LineItemEditor'; -interface SimilarProposal { - proposalNumber: string; - customerName: string; - serviceCategory: string; - similarityScore: number; - totalBidAmount: number; - lineItems: Array<{ - description: string; - quantity: number; - unit: string; - unitPrice: number | null; - totalPrice: number; - }>; -} - interface SimilarProposalsPanelProps { proposalId: string; onPullLineItem: (item: Omit) => void; @@ -37,11 +21,7 @@ interface SimilarProposalsPanelProps { } export default function SimilarProposalsPanel({ proposalId, onPullLineItem, disabled }: SimilarProposalsPanelProps) { - const { data: similar, isLoading } = useQuery({ - queryKey: ['similarProposals', proposalId], - queryFn: () => adminApi.getSimilar(proposalId) as Promise, - enabled: !!proposalId, - }); + const { data: similar, isLoading } = useSimilarProposals(proposalId); if (isLoading) { return ( diff --git a/web/src/components/admin/VendorDataPanel.tsx b/web/src/components/admin/VendorDataPanel.tsx index f6b662c..a3e956d 100644 --- a/web/src/components/admin/VendorDataPanel.tsx +++ b/web/src/components/admin/VendorDataPanel.tsx @@ -1,37 +1,13 @@ -import { useQuery } from '@tanstack/react-query'; import { Box, Typography, Skeleton, Chip } from '@mui/material'; -import apiClient from '../../lib/api/client'; +import { useVendorProposals } from '../../domain/proposals/use-cases'; import { formatCurrency } from '../../lib/format'; -interface VendorProposal { - id: string; - vendorName: string; - fileName: string; - totalVendorCost: number; - processingStatus: string; - extractedData: { - lineItems?: Array<{ - description: string; - quantity?: number; - unitPrice?: number; - total?: number; - }>; - } | null; -} - interface VendorDataPanelProps { proposalId: string; } export default function VendorDataPanel({ proposalId }: VendorDataPanelProps) { - const { data: vendors, isLoading } = useQuery({ - queryKey: ['vendorProposals', proposalId], - queryFn: async () => { - const res = await apiClient.get(`/proposals/${proposalId}/vendors`); - return res.data; - }, - enabled: !!proposalId, - }); + const { data: vendors, isLoading } = useVendorProposals(proposalId); if (isLoading) { return ; diff --git a/web/src/constants/index.ts b/web/src/constants/index.ts index 89b2b60..aed79d1 100644 --- a/web/src/constants/index.ts +++ b/web/src/constants/index.ts @@ -12,6 +12,7 @@ export const DEFAULT_PAGE_SIZE = 12; export const DEFAULT_PAGE = 1; export const DEBOUNCE_SEARCH = 600; +export const DEBOUNCE_AUTOCOMPLETE = 300; // typeahead fields respond faster than list search export const STORAGE_KEY_TOKEN = 'proposal_system_token'; export const STORAGE_KEY_SIDEBAR = 'sidebarOpen'; diff --git a/web/src/constants/queryKeys.ts b/web/src/constants/queryKeys.ts deleted file mode 100644 index 943ec10..0000000 --- a/web/src/constants/queryKeys.ts +++ /dev/null @@ -1,11 +0,0 @@ -export const QUERY_KEYS = { - proposals: 'proposals', - proposal: 'proposal', - proposalLineItems: 'proposalLineItems', - customers: 'customers', - customerList: 'customerList', - users: 'users', - dashboard: 'dashboard', - auditTrail: 'auditTrail', - pricingLibraryList: 'pricingLibraryList', -} as const; diff --git a/web/src/domain/README.md b/web/src/domain/README.md new file mode 100644 index 0000000..79266db --- /dev/null +++ b/web/src/domain/README.md @@ -0,0 +1,47 @@ +# Frontend domain layer — conventions + +SHOC-alignment Phase 4 (mirrors shoc-frontend-new dev's `src/domain//` layering). +Every agent/contributor working in this tree builds to THIS spec. + +## Structure + +``` +src/domain// +├── api.ts # HTTP calls only: axios via lib/api/client, path constants, no hooks +├── types.ts # re-exports from @proposal-system/api-contracts + view-only types +├── schemas.ts # re-exports from @proposal-system/api-contracts/schemas + form schemas +└── use-cases.ts # TanStack Query hooks + this domain's query keys (the ONLY public surface) +``` + +Domains: `proposals`, `lineItems`, `customers`, `pricingLibrary`, `admin`, `sites`. +(`auth` arrives with the separate auth-storage PR — do NOT create it here.) + +## Rules + +1. **Pages import ONLY from `domain//use-cases` (and `types`)** — never from + `lib/api/*`, never `apiClient` directly, never `useQuery`/`useMutation` inline + in a page. Pages are thin callers. +2. **Query keys live in the domain**: each `use-cases.ts` exports + `export const Keys = { all: [''] as const, detail: (id: string) => ['', id] as const, ... }` + (TanStack hierarchical-key convention). `src/constants/queryKeys.ts` is + deleted at integration — do not add imports of it. +3. **Invalidation** uses the domain key objects across domains where needed + (e.g. approving a proposal invalidates `proposalsKeys.all`). +4. **Forms**: react-hook-form + `zodResolver` (from `@hookform/resolvers/zod`). + Form schemas live in the domain's `schemas.ts`, derived from the shared + contract schemas (`@proposal-system/api-contracts/schemas`) via + `.pick/.extend/.omit` — never hand-written duplicates. Form field state maps + to the request type at submit (a `toCreateRequest(formValues)` mapper in + `schemas.ts` when non-trivial). +5. **`lib/api/client.ts` stays** — the single axios instance (interceptors, + 401 handling). Domain `api.ts` files import it. The old `lib/api/.ts` + modules are deleted once no page imports them (integration step) — + `lib/api/auth.ts` stays until the auth PR. +6. **Mutations**: toast on error stays in the hook (matching current UX), + success invalidation in the hook; page-specific side effects (navigate, + dialog close) via the mutation's callbacks at the call site. +7. **No new state managers, no context** — server state = TanStack Query, + existing Redux auth/ui slices untouched (auth refactor is a separate PR). +8. **Styling**: tokens only (`var(--...)`, theme) — no hardcoded hexes. +9. Verify before returning/committing: `npx tsc --noEmit`, `npm test -- --run`, + `npm run build` — all green, no skipped tests, no `@ts-ignore`. diff --git a/web/src/domain/__tests__/admin.use-cases.test.tsx b/web/src/domain/__tests__/admin.use-cases.test.tsx new file mode 100644 index 0000000..fc734f6 --- /dev/null +++ b/web/src/domain/__tests__/admin.use-cases.test.tsx @@ -0,0 +1,99 @@ +// Admin domain use-case hooks: state transitions must cross-domain +// invalidate the proposals/lineItems keys (domain README rule 3). The domain +// api modules are mocked — no axios traffic. +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { renderHook, waitFor } from '@testing-library/react'; +import { toast } from 'react-toastify'; +import { createQueryHarness } from './hookTestUtils'; +import { useApproveProposal } from '../admin/use-cases'; +import { proposalsKeys } from '../proposals/use-cases'; +import { lineItemsKeys } from '../lineItems/use-cases'; +import { adminApi } from '../admin/api'; + +vi.mock('../admin/api', () => ({ + adminApi: { + getDashboard: vi.fn(), + updateProposal: vi.fn(), + approveProposal: vi.fn(), + sendProposal: vi.fn(), + reviseProposal: vi.fn(), + returnToReview: vi.fn(), + generateSuggestions: vi.fn(), + getSimilar: vi.fn(), + getHistory: vi.fn(), + getAudit: vi.fn(), + getPdf: vi.fn(), + }, +})); + +// useSaveProposalWorkspace pulls lineItemsApi directly; mock it so no test +// path can reach axios. +vi.mock('../lineItems/api', () => ({ + lineItemsApi: { + getAll: vi.fn(), + create: vi.fn(), + bulkUpdate: vi.fn(), + delete: vi.fn(), + }, +})); + +vi.mock('react-toastify', () => ({ + toast: { success: vi.fn(), error: vi.fn(), warning: vi.fn(), info: vi.fn() }, +})); + +const PROPOSAL_ID = 'p-7'; + +beforeEach(() => { + vi.clearAllMocks(); +}); + +describe('useApproveProposal', () => { + it('invalidates the proposal detail and its line items keys and toasts on success', async () => { + vi.mocked(adminApi.approveProposal).mockResolvedValue(undefined); + const { queryClient, wrapper } = createQueryHarness(); + const invalidateSpy = vi.spyOn(queryClient, 'invalidateQueries'); + + const { result } = renderHook(() => useApproveProposal(PROPOSAL_ID), { wrapper }); + result.current.mutate(); + + await waitFor(() => expect(result.current.isSuccess).toBe(true)); + expect(adminApi.approveProposal).toHaveBeenCalledWith(PROPOSAL_ID); + // Approval changes proposal status AND locks/reprices line items — both + // domains' keys must be refetched (cross-domain invalidation, rule 3). + expect(invalidateSpy).toHaveBeenCalledWith({ + queryKey: proposalsKeys.detail(PROPOSAL_ID), + }); + expect(invalidateSpy).toHaveBeenCalledWith({ + queryKey: lineItemsKeys.byProposal(PROPOSAL_ID), + }); + // ...AND every cached view of the queue: lists/stats/KPIs sit under the + // global 5-minute staleTime, so without these the admin returns to a + // stale queue after approving (code-review finding, Phase 4). + expect(invalidateSpy).toHaveBeenCalledWith({ + queryKey: proposalsKeys.lists(), + }); + expect(invalidateSpy).toHaveBeenCalledWith({ + queryKey: proposalsKeys.stats(), + }); + expect(invalidateSpy).toHaveBeenCalledWith({ + queryKey: ['admin', 'dashboard'], + }); + expect(toast.success).toHaveBeenCalledWith('Proposal approved'); + }); + + it('toasts the failure and skips invalidation when approval is rejected', async () => { + vi.mocked(adminApi.approveProposal).mockRejectedValue( + new Error('proposal is not in review') + ); + const { queryClient, wrapper } = createQueryHarness(); + const invalidateSpy = vi.spyOn(queryClient, 'invalidateQueries'); + + const { result } = renderHook(() => useApproveProposal(PROPOSAL_ID), { wrapper }); + result.current.mutate(); + + await waitFor(() => expect(result.current.isError).toBe(true)); + expect(toast.error).toHaveBeenCalledWith('Approval failed: proposal is not in review'); + expect(invalidateSpy).not.toHaveBeenCalled(); + expect(toast.success).not.toHaveBeenCalled(); + }); +}); diff --git a/web/src/domain/__tests__/customers.use-cases.test.tsx b/web/src/domain/__tests__/customers.use-cases.test.tsx new file mode 100644 index 0000000..059be69 --- /dev/null +++ b/web/src/domain/__tests__/customers.use-cases.test.tsx @@ -0,0 +1,99 @@ +// Customers domain use-case hooks: create/update must invalidate the list +// whole domain key family (customersKeys.all — lists AND cached search +// autocompletes; code-review finding, Phase 4) and toast. The domain api module is +// mocked — no axios traffic. +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { renderHook, waitFor } from '@testing-library/react'; +import { toast } from 'react-toastify'; +import { createQueryHarness } from './hookTestUtils'; +import { + customersKeys, + useCreateCustomer, + useUpdateCustomer, +} from '../customers/use-cases'; +import { customersApi } from '../customers/api'; +import type { Customer } from '../customers/types'; + +vi.mock('../customers/api', () => ({ + customersApi: { + search: vi.fn(), + list: vi.fn(), + create: vi.fn(), + update: vi.fn(), + }, +})); + +vi.mock('react-toastify', () => ({ + toast: { success: vi.fn(), error: vi.fn(), warning: vi.fn(), info: vi.fn() }, +})); + +const customer = { id: 'c-1', name: 'Acme Corp' } as unknown as Customer; + +beforeEach(() => { + vi.clearAllMocks(); +}); + +describe('customersKeys', () => { + it('list keys nest under lists() so one invalidation hits every page/filter', () => { + expect(customersKeys.all).toEqual(['customers']); + expect(customersKeys.lists()).toEqual(['customers', 'list']); + expect(customersKeys.list({ page: 3 })).toEqual(['customers', 'list', { page: 3 }]); + expect(customersKeys.search('acme')).toEqual(['customers', 'search', 'acme']); + }); +}); + +describe('useCreateCustomer', () => { + it('invalidates the whole customers domain (lists + search caches) and toasts on success', async () => { + vi.mocked(customersApi.create).mockResolvedValue(customer); + const { queryClient, wrapper } = createQueryHarness(); + const invalidateSpy = vi.spyOn(queryClient, 'invalidateQueries'); + + const { result } = renderHook(() => useCreateCustomer(), { wrapper }); + result.current.mutate({ name: 'Acme Corp' } as never); + + await waitFor(() => expect(result.current.isSuccess).toBe(true)); + expect(invalidateSpy).toHaveBeenCalledWith({ queryKey: customersKeys.all }); + expect(toast.success).toHaveBeenCalledWith('Customer created'); + }); + + it('toasts the failure and skips invalidation', async () => { + vi.mocked(customersApi.create).mockRejectedValue(new Error('duplicate name')); + const { queryClient, wrapper } = createQueryHarness(); + const invalidateSpy = vi.spyOn(queryClient, 'invalidateQueries'); + + const { result } = renderHook(() => useCreateCustomer(), { wrapper }); + result.current.mutate({ name: 'Acme Corp' } as never); + + await waitFor(() => expect(result.current.isError).toBe(true)); + expect(toast.error).toHaveBeenCalledWith('Failed to create customer: duplicate name'); + expect(invalidateSpy).not.toHaveBeenCalled(); + }); +}); + +describe('useUpdateCustomer', () => { + it('passes id + payload through and invalidates the whole customers domain on success', async () => { + vi.mocked(customersApi.update).mockResolvedValue(customer); + const { queryClient, wrapper } = createQueryHarness(); + const invalidateSpy = vi.spyOn(queryClient, 'invalidateQueries'); + + const { result } = renderHook(() => useUpdateCustomer(), { wrapper }); + const data = { name: 'Acme Corp (Renamed)' } as never; + result.current.mutate({ id: 'c-1', data }); + + await waitFor(() => expect(result.current.isSuccess).toBe(true)); + expect(customersApi.update).toHaveBeenCalledWith('c-1', data); + expect(invalidateSpy).toHaveBeenCalledWith({ queryKey: customersKeys.all }); + expect(toast.success).toHaveBeenCalledWith('Customer updated'); + }); + + it('toasts the failure on update error', async () => { + vi.mocked(customersApi.update).mockRejectedValue(new Error('not found')); + const { wrapper } = createQueryHarness(); + + const { result } = renderHook(() => useUpdateCustomer(), { wrapper }); + result.current.mutate({ id: 'c-404', data: { name: 'x' } as never }); + + await waitFor(() => expect(result.current.isError).toBe(true)); + expect(toast.error).toHaveBeenCalledWith('Failed to update customer: not found'); + }); +}); diff --git a/web/src/domain/__tests__/hookTestUtils.tsx b/web/src/domain/__tests__/hookTestUtils.tsx new file mode 100644 index 0000000..09a4044 --- /dev/null +++ b/web/src/domain/__tests__/hookTestUtils.tsx @@ -0,0 +1,18 @@ +// Shared harness for domain use-case hook tests: a fresh QueryClient per +// test (no retries, so error paths settle immediately) plus the provider +// wrapper renderHook needs. Not a test file — no .test suffix. +import type { ReactNode } from 'react'; +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; + +export function createQueryHarness() { + const queryClient = new QueryClient({ + defaultOptions: { + queries: { retry: false }, + mutations: { retry: false }, + }, + }); + const wrapper = ({ children }: { children: ReactNode }) => ( + {children} + ); + return { queryClient, wrapper }; +} diff --git a/web/src/domain/__tests__/lineItems.use-cases.test.tsx b/web/src/domain/__tests__/lineItems.use-cases.test.tsx new file mode 100644 index 0000000..9cde471 --- /dev/null +++ b/web/src/domain/__tests__/lineItems.use-cases.test.tsx @@ -0,0 +1,100 @@ +// Line-item persistence flows through useSaveProposalWorkspace (admin +// domain) — the live workspace save path: update proposal, bulk-replace line +// items, broad-invalidate every cached view. The domain api modules are +// mocked — no axios traffic. +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { renderHook, waitFor } from '@testing-library/react'; +import { toast } from 'react-toastify'; +import { createQueryHarness } from './hookTestUtils'; +import { lineItemsKeys } from '../lineItems/use-cases'; +import { useSaveProposalWorkspace } from '../admin/use-cases'; +import { proposalsKeys } from '../proposals/use-cases'; +import { adminApi } from '../admin/api'; +import { lineItemsApi } from '../lineItems/api'; +import type { UpdateLineItemEntry } from '../lineItems/types'; + +vi.mock('../admin/api', () => ({ + adminApi: { + getDashboard: vi.fn(), + updateProposal: vi.fn(), + approveProposal: vi.fn(), + sendProposal: vi.fn(), + reviseProposal: vi.fn(), + returnToReview: vi.fn(), + generateSuggestions: vi.fn(), + getSimilar: vi.fn(), + getHistory: vi.fn(), + getAudit: vi.fn(), + getPdf: vi.fn(), + }, +})); + +vi.mock('../lineItems/api', () => ({ + lineItemsApi: { + getAll: vi.fn(), + create: vi.fn(), + bulkUpdate: vi.fn(), + delete: vi.fn(), + }, +})); + +vi.mock('react-toastify', () => ({ + toast: { success: vi.fn(), error: vi.fn(), warning: vi.fn(), info: vi.fn() }, +})); + +const PROPOSAL_ID = 'p-42'; +const entries = [ + { description: 'Labor', quantity: 2, unitPrice: 150 }, +] as unknown as UpdateLineItemEntry[]; + +beforeEach(() => { + vi.clearAllMocks(); +}); + +describe('lineItemsKeys', () => { + it('scopes the by-proposal key under ["lineItems"]', () => { + expect(lineItemsKeys.all).toEqual(['lineItems']); + expect(lineItemsKeys.byProposal(PROPOSAL_ID)).toEqual(['lineItems', PROPOSAL_ID]); + }); +}); + +describe('useSaveProposalWorkspace', () => { + it('persists scope + entries and invalidates line items, detail, and list/stats views', async () => { + vi.mocked(adminApi.updateProposal).mockResolvedValue(undefined); + vi.mocked(lineItemsApi.bulkUpdate).mockResolvedValue([]); + const { queryClient, wrapper } = createQueryHarness(); + const invalidateSpy = vi.spyOn(queryClient, 'invalidateQueries'); + + const { result } = renderHook(() => useSaveProposalWorkspace(PROPOSAL_ID), { wrapper }); + result.current.mutate({ refinedScope: 'refined', lineItems: entries }); + + await waitFor(() => expect(result.current.isSuccess).toBe(true)); + expect(adminApi.updateProposal).toHaveBeenCalledWith(PROPOSAL_ID, { refinedScope: 'refined' }); + expect(lineItemsApi.bulkUpdate).toHaveBeenCalledWith(PROPOSAL_ID, entries); + expect(invalidateSpy).toHaveBeenCalledWith({ + queryKey: lineItemsKeys.byProposal(PROPOSAL_ID), + }); + // Totals live on the proposal detail AND the cached queue/list views. + expect(invalidateSpy).toHaveBeenCalledWith({ + queryKey: proposalsKeys.detail(PROPOSAL_ID), + }); + expect(invalidateSpy).toHaveBeenCalledWith({ + queryKey: proposalsKeys.lists(), + }); + expect(toast.success).toHaveBeenCalledWith('Changes saved'); + }); + + it('toasts a save failure and skips invalidation', async () => { + vi.mocked(adminApi.updateProposal).mockRejectedValue(new Error('409 conflict')); + const { queryClient, wrapper } = createQueryHarness(); + const invalidateSpy = vi.spyOn(queryClient, 'invalidateQueries'); + + const { result } = renderHook(() => useSaveProposalWorkspace(PROPOSAL_ID), { wrapper }); + result.current.mutate({ refinedScope: 'refined', lineItems: entries }); + + await waitFor(() => expect(result.current.isError).toBe(true)); + expect(toast.error).toHaveBeenCalledWith('Save failed: 409 conflict'); + expect(lineItemsApi.bulkUpdate).not.toHaveBeenCalled(); + expect(invalidateSpy).not.toHaveBeenCalled(); + }); +}); diff --git a/web/src/domain/__tests__/proposals.use-cases.test.tsx b/web/src/domain/__tests__/proposals.use-cases.test.tsx new file mode 100644 index 0000000..cc57edf --- /dev/null +++ b/web/src/domain/__tests__/proposals.use-cases.test.tsx @@ -0,0 +1,126 @@ +// Proposals domain use-case hooks: query key shape, list caching under the +// domain key, and create-mutation invalidation/toast behavior. The domain +// api module is mocked — no axios traffic. +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { renderHook, waitFor } from '@testing-library/react'; +import { toast } from 'react-toastify'; +import { createQueryHarness } from './hookTestUtils'; +import { + proposalsKeys, + useProposals, + useCreateProposal, +} from '../proposals/use-cases'; +import { proposalsApi } from '../proposals/api'; +import type { ProposalDetail, ProposalFilters, ProposalListItem, PagedResponse } from '../proposals/types'; + +vi.mock('../proposals/api', () => ({ + proposalsApi: { + create: vi.fn(), + getAll: vi.fn(), + getById: vi.fn(), + uploadAttachment: vi.fn(), + confirmUpload: vi.fn(), + getStats: vi.fn(), + getVendors: vi.fn(), + }, +})); + +vi.mock('react-toastify', () => ({ + toast: { success: vi.fn(), error: vi.fn(), warning: vi.fn(), info: vi.fn() }, +})); + +const proposal = { + id: 'p-1', + proposalNumber: 'PRO-0001', +} as unknown as ProposalDetail; + +const pagedList: PagedResponse = { + items: [], + page: 2, + pageSize: 25, + totalCount: 0, +} as unknown as PagedResponse; + +beforeEach(() => { + vi.clearAllMocks(); +}); + +describe('proposalsKeys', () => { + it('follows the hierarchical TanStack key convention rooted at ["proposals"]', () => { + expect(proposalsKeys.all).toEqual(['proposals']); + expect(proposalsKeys.lists()).toEqual(['proposals', 'list']); + expect(proposalsKeys.list({ page: 2, status: 'InReview' })).toEqual([ + 'proposals', + 'list', + { page: 2, status: 'InReview' }, + ]); + expect(proposalsKeys.detail('p-1')).toEqual(['proposals', 'p-1']); + expect(proposalsKeys.stats()).toEqual(['proposals', 'stats']); + expect(proposalsKeys.vendors('p-1')).toEqual(['proposals', 'p-1', 'vendors']); + }); +}); + +describe('useProposals', () => { + it('fetches with the given filters and caches under proposalsKeys.list(filters)', async () => { + const filters: ProposalFilters = { page: 2, pageSize: 25 }; + vi.mocked(proposalsApi.getAll).mockResolvedValue(pagedList); + const { queryClient, wrapper } = createQueryHarness(); + + const { result } = renderHook(() => useProposals(filters), { wrapper }); + + await waitFor(() => expect(result.current.isSuccess).toBe(true)); + expect(proposalsApi.getAll).toHaveBeenCalledWith(filters); + // The data must live under the domain's list key — pages and cross-domain + // invalidation both depend on this exact shape. + expect(queryClient.getQueryData(proposalsKeys.list(filters))).toBe(pagedList); + }); +}); + +describe('useCreateProposal', () => { + it('invalidates proposalsKeys.all and toasts on success', async () => { + vi.mocked(proposalsApi.create).mockResolvedValue(proposal); + const { queryClient, wrapper } = createQueryHarness(); + const invalidateSpy = vi.spyOn(queryClient, 'invalidateQueries'); + + const { result } = renderHook(() => useCreateProposal(), { wrapper }); + result.current.mutate({ request: {} as never }); + + await waitFor(() => expect(result.current.isSuccess).toBe(true)); + expect(invalidateSpy).toHaveBeenCalledWith({ queryKey: proposalsKeys.all }); + expect(toast.success).toHaveBeenCalledWith('Proposal PRO-0001 submitted'); + expect(toast.error).not.toHaveBeenCalled(); + }); + + it('toasts the error message and does not invalidate on failure', async () => { + vi.mocked(proposalsApi.create).mockRejectedValue(new Error('boom')); + const { queryClient, wrapper } = createQueryHarness(); + const invalidateSpy = vi.spyOn(queryClient, 'invalidateQueries'); + + const { result } = renderHook(() => useCreateProposal(), { wrapper }); + result.current.mutate({ request: {} as never }); + + await waitFor(() => expect(result.current.isError).toBe(true)); + expect(toast.error).toHaveBeenCalledWith('boom'); + expect(invalidateSpy).not.toHaveBeenCalled(); + }); + + it('still succeeds (warning toast, invalidation) when the vendor PDF upload fails', async () => { + vi.mocked(proposalsApi.create).mockResolvedValue(proposal); + vi.mocked(proposalsApi.uploadAttachment).mockRejectedValue(new Error('presign failed')); + const { queryClient, wrapper } = createQueryHarness(); + const invalidateSpy = vi.spyOn(queryClient, 'invalidateQueries'); + + const { result } = renderHook(() => useCreateProposal(), { wrapper }); + result.current.mutate({ + request: {} as never, + vendorFile: new File(['pdf'], 'vendor.pdf', { type: 'application/pdf' }), + }); + + await waitFor(() => expect(result.current.isSuccess).toBe(true)); + // A failed attachment must not fail the proposal creation. + expect(proposalsApi.confirmUpload).not.toHaveBeenCalled(); + expect(toast.warning).toHaveBeenCalledWith(expect.stringContaining('PRO-0001')); + expect(invalidateSpy).toHaveBeenCalledWith({ queryKey: proposalsKeys.all }); + expect(toast.success).toHaveBeenCalledWith('Proposal PRO-0001 submitted'); + }); +}); diff --git a/web/src/domain/__tests__/schemas.test.ts b/web/src/domain/__tests__/schemas.test.ts new file mode 100644 index 0000000..6497fe1 --- /dev/null +++ b/web/src/domain/__tests__/schemas.test.ts @@ -0,0 +1,137 @@ +// Domain-layer form schemas: derived from the shared contract schemas, with +// string-typed numeric inputs and toXxxRequest() mappers to the wire types. +// Also serves as the resolution smoke test for the +// '@proposal-system/api-contracts/schemas' subpath entrypoint. +import { describe, it, expect } from 'vitest'; +import { + createProposalFormSchema, + toCreateProposalRequest, + MIN_SCOPE_LENGTH, +} from '../proposals/schemas'; +import { customerFormSchema, toCustomerRequest } from '../customers/schemas'; +import { + pricingLibraryItemFormSchema, + toPricingLibraryItemRequest, +} from '../pricingLibrary/schemas'; + +describe('createProposalFormSchema', () => { + const valid = { + workOrderNumber: 'WO-1001', + poNumber: '', + customerName: 'Acme Corp', + customerAddress: '1 Main St, Islip, NY 11751', + scopeOfWork: 'Replace rooftop HVAC unit and rebalance airflow', + serviceCategory: 'HVAC' as const, + priority: 'Standard' as const, + notes: '', + customCategory: '', + }; + + it('accepts a complete form', () => { + expect(createProposalFormSchema.safeParse(valid).success).toBe(true); + }); + + it('rejects scope of work shorter than the minimum', () => { + const result = createProposalFormSchema.safeParse({ + ...valid, + scopeOfWork: 'x'.repeat(MIN_SCOPE_LENGTH - 1), + }); + expect(result.success).toBe(false); + }); + + it('requires customCategory when serviceCategory is Other', () => { + expect( + createProposalFormSchema.safeParse({ ...valid, serviceCategory: 'Other' }).success + ).toBe(false); + expect( + createProposalFormSchema.safeParse({ + ...valid, + serviceCategory: 'Other', + customCategory: 'Roofing', + }).success + ).toBe(true); + }); + + it('maps to the wire request, prefixing notes with the custom category', () => { + const request = toCreateProposalRequest({ + ...valid, + serviceCategory: 'Other', + customCategory: 'Roofing', + notes: 'existing note', + }); + expect(request.notes).toBe('[Category: Roofing]\nexisting note'); + expect(request.poNumber).toBeUndefined(); + expect(toCreateProposalRequest(valid).notes).toBeUndefined(); + }); +}); + +describe('customerFormSchema', () => { + const valid = { name: 'Acme Corp', address: '1 Main St', contactEmail: '' }; + + it('accepts a form with empty email', () => { + expect(customerFormSchema.safeParse(valid).success).toBe(true); + }); + + it('rejects a malformed email but accepts a valid one', () => { + expect(customerFormSchema.safeParse({ ...valid, contactEmail: 'nope' }).success).toBe(false); + expect( + customerFormSchema.safeParse({ ...valid, contactEmail: 'a@b.com' }).success + ).toBe(true); + }); + + it('maps to addresses[] preserving extra existing addresses', () => { + const request = toCustomerRequest( + { name: ' Acme ', address: ' 2 New St ', contactEmail: '' }, + ['1 Old St', '3 Extra Ave'] + ); + // contactEmail is ALWAYS sent — empty string clears the stored email + // (code-review finding, Phase 4: omission made emails impossible to clear). + expect(request).toEqual({ name: 'Acme', addresses: ['2 New St', '3 Extra Ave'], contactEmail: '' }); + }); +}); + +describe('pricingLibraryItemFormSchema', () => { + const valid = { + title: 'Condenser coil cleaning', + serviceCategory: 'HVAC' as const, + unit: '', + unitPrice: '', + keywords: '', + description: '', + }; + + it('accepts empty unitPrice and rejects negatives/non-numbers', () => { + expect(pricingLibraryItemFormSchema.safeParse(valid).success).toBe(true); + expect( + pricingLibraryItemFormSchema.safeParse({ ...valid, unitPrice: '-4' }).success + ).toBe(false); + expect( + pricingLibraryItemFormSchema.safeParse({ ...valid, unitPrice: 'abc' }).success + ).toBe(false); + }); + + it('maps empty unitPrice to null and drops empty optional strings', () => { + const request = toPricingLibraryItemRequest(valid); + expect(request).toEqual({ title: 'Condenser coil cleaning', serviceCategory: 'HVAC', unitPrice: null }); + const priced = toPricingLibraryItemRequest({ ...valid, unitPrice: '12.50', unit: 'each' }); + expect(priced.unitPrice).toBe(12.5); + expect(priced.unit).toBe('each'); + }); +}); + +describe('shared numeric form fields', () => { + it('rejects trailing garbage that parseFloat would silently coerce', () => { + // Code-review finding (Phase 4): '12abc' passed the old parseFloat-based + // pricing refine as 12; the shared Number-based field must reject it. + expect( + pricingLibraryItemFormSchema.safeParse({ + title: 'Coil cleaning', + serviceCategory: 'HVAC', + unit: '', + unitPrice: '12abc', + keywords: '', + description: '', + }).success + ).toBe(false); + }); +}); diff --git a/web/src/lib/api/admin.ts b/web/src/domain/admin/api.ts similarity index 82% rename from web/src/lib/api/admin.ts rename to web/src/domain/admin/api.ts index 918524c..472f339 100644 --- a/web/src/lib/api/admin.ts +++ b/web/src/domain/admin/api.ts @@ -1,8 +1,7 @@ -import apiClient from './client'; -// Fix: WEB-M5 — types from the shared contracts package. -import type { ProposalDetail, UpdateProposalRequest, DashboardStats, AuditEntry } from '@proposal-system/api-contracts'; - -export type { DashboardStats, UpdateProposalRequest, AuditEntry } from '@proposal-system/api-contracts'; +// Admin domain — HTTP calls only (no hooks). Moved from lib/api/admin.ts. +import apiClient from '../../lib/api/client'; +import type { ProposalDetail } from '../proposals/types'; +import type { DashboardStats, UpdateProposalRequest, AuditEntry, SimilarProposal } from './types'; export const adminApi = { getDashboard: async (): Promise => { @@ -44,7 +43,7 @@ export const adminApi = { await apiClient.post(`/proposals/${id}/generate-suggestions`); }, - getSimilar: async (id: string): Promise => { + getSimilar: async (id: string): Promise => { const res = await apiClient.get(`/proposals/${id}/similar`); return res.data; }, diff --git a/web/src/domain/admin/schemas.ts b/web/src/domain/admin/schemas.ts new file mode 100644 index 0000000..e789fa1 --- /dev/null +++ b/web/src/domain/admin/schemas.ts @@ -0,0 +1,7 @@ +// Admin domain — contract schema re-exports. No admin-specific forms yet +// (the workspace edits proposals/line items via their own domain schemas). +export { + dashboardStatsSchema, + updateProposalRequestSchema, + auditEntrySchema, +} from '@proposal-system/api-contracts/schemas'; diff --git a/web/src/domain/admin/types.ts b/web/src/domain/admin/types.ts new file mode 100644 index 0000000..5fa57ae --- /dev/null +++ b/web/src/domain/admin/types.ts @@ -0,0 +1,19 @@ +// Admin domain — contract type re-exports + view-only types. +export type { DashboardStats, UpdateProposalRequest, AuditEntry } from '@proposal-system/api-contracts'; + +/** View-only shape of GET /proposals/{id}/similar (RAG similarity results — + * no contract DTO yet; previously declared inline in SimilarProposalsPanel). */ +export interface SimilarProposal { + proposalNumber: string; + customerName: string; + serviceCategory: string; + similarityScore: number; + totalBidAmount: number; + lineItems: Array<{ + description: string; + quantity: number; + unit: string; + unitPrice: number | null; + totalPrice: number; + }>; +} diff --git a/web/src/domain/admin/use-cases.ts b/web/src/domain/admin/use-cases.ts new file mode 100644 index 0000000..9c0f8d7 --- /dev/null +++ b/web/src/domain/admin/use-cases.ts @@ -0,0 +1,163 @@ +// Admin domain — TanStack Query hooks + query keys (the only public surface). +// State-transition mutations invalidate the proposals/lineItems domain keys +// (rule 3 — cross-domain invalidation via the sibling key objects) and carry +// the same toasts the AdminWorkspace page shows today. Page-specific side +// effects (dialog close, dirty reset, window.open, navigate) stay at the +// call site via the mutation's callbacks. +import { useMutation, useQuery, useQueryClient, type QueryClient } from '@tanstack/react-query'; +import { toast } from 'react-toastify'; +import { adminApi } from './api'; +import { lineItemsApi } from '../lineItems/api'; +import { proposalsKeys } from '../proposals/use-cases'; +import { lineItemsKeys } from '../lineItems/use-cases'; +import type { UpdateLineItemEntry } from '../lineItems/types'; + +export const adminKeys = { + all: ['admin'] as const, + dashboard: () => [...adminKeys.all, 'dashboard'] as const, + similar: (proposalId: string) => [...adminKeys.all, 'similar', proposalId] as const, +}; + +/** + * Every state transition must refresh every view of the proposal: the + * workspace (detail + line items) AND the cached queue/list/stats/KPI + * queries — lists sit under the global 5-minute staleTime, so without the + * broad invalidation an admin returning to the queue after approving sees + * stale statuses. + */ +function invalidateProposalViews(queryClient: QueryClient, proposalId: string) { + queryClient.invalidateQueries({ queryKey: proposalsKeys.detail(proposalId) }); + queryClient.invalidateQueries({ queryKey: lineItemsKeys.byProposal(proposalId) }); + queryClient.invalidateQueries({ queryKey: proposalsKeys.lists() }); + queryClient.invalidateQueries({ queryKey: proposalsKeys.stats() }); + queryClient.invalidateQueries({ queryKey: adminKeys.dashboard() }); +} + +/** Admin dashboard KPIs (AdminDashboard). */ +export function useAdminDashboard() { + return useQuery({ + queryKey: adminKeys.dashboard(), + queryFn: adminApi.getDashboard, + }); +} + +/** RAG similarity results (AdminWorkspace, SimilarProposalsPanel). */ +export function useSimilarProposals(proposalId: string | undefined) { + return useQuery({ + queryKey: adminKeys.similar(proposalId ?? ''), + queryFn: () => adminApi.getSimilar(proposalId!), + enabled: !!proposalId, + }); +} + +export interface SaveWorkspaceVariables { + refinedScope: string; + lineItems: UpdateLineItemEntry[]; +} + +/** + * The workspace "Save" action: persist the refined scope, then replace the + * proposal's line items (mirrors AdminWorkspace saveMutation exactly). + */ +export function useSaveProposalWorkspace(proposalId: string) { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: async ({ refinedScope, lineItems }: SaveWorkspaceVariables) => { + await adminApi.updateProposal(proposalId, { refinedScope }); + await lineItemsApi.bulkUpdate(proposalId, lineItems); + }, + onSuccess: () => { + invalidateProposalViews(queryClient, proposalId); + toast.success('Changes saved'); + }, + onError: (error: Error) => { + toast.error(`Save failed: ${error.message}`); + }, + }); +} + +export function useApproveProposal(proposalId: string) { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: () => adminApi.approveProposal(proposalId), + onSuccess: () => { + invalidateProposalViews(queryClient, proposalId); + toast.success('Proposal approved'); + }, + onError: (error: Error) => { + toast.error(`Approval failed: ${error.message}`); + }, + }); +} + +export function useSendProposal(proposalId: string) { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: () => adminApi.sendProposal(proposalId), + onSuccess: () => { + invalidateProposalViews(queryClient, proposalId); + toast.success('Proposal marked as sent'); + }, + // Fix: WEB-H5 — mutation must surface failures to the user (relocated + // from AdminWorkspace during the domain-layer refactor) + onError: (error: Error) => { + toast.error(`Send failed: ${error.message}`); + }, + }); +} + +export function useReviseProposal(proposalId: string) { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: () => adminApi.reviseProposal(proposalId), + onSuccess: () => { + invalidateProposalViews(queryClient, proposalId); + toast.success('Revision created'); + }, + // Fix: WEB-H6 — mutation must surface failures to the user (relocated + // from AdminWorkspace during the domain-layer refactor) + onError: (error: Error) => { + toast.error(`Revision failed: ${error.message}`); + }, + }); +} + +export function useReturnToReview(proposalId: string) { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: () => adminApi.returnToReview(proposalId), + onSuccess: () => { + invalidateProposalViews(queryClient, proposalId); + toast.success('Proposal returned to review'); + }, + onError: (error: Error) => { + toast.error(`Return to review failed: ${error.message}`); + }, + }); +} + +export function useGenerateSuggestions(proposalId: string) { + return useMutation({ + mutationFn: () => adminApi.generateSuggestions(proposalId), + onSuccess: () => { + toast.info('AI suggestion generation started'); + }, + onError: (error: Error) => { + toast.error(`Regeneration failed: ${error.message}`); + }, + }); +} + +/** + * Fetch (or queue generation of) the branded PDF. Resolves null while + * generation is queued (202). Success handling — open the download and + * toast — is page behavior and belongs at the call site. + */ +export function useProposalPdf(proposalId: string) { + return useMutation({ + mutationFn: (regenerate?: boolean) => adminApi.getPdf(proposalId, regenerate ?? false), + onError: () => { + toast.error('Failed to generate PDF'); + }, + }); +} diff --git a/web/src/lib/api/customers.ts b/web/src/domain/customers/api.ts similarity index 73% rename from web/src/lib/api/customers.ts rename to web/src/domain/customers/api.ts index 61ee7ee..695872b 100644 --- a/web/src/lib/api/customers.ts +++ b/web/src/domain/customers/api.ts @@ -1,23 +1,12 @@ -import apiClient from './client'; -// Fix: WEB-M5 — types from the shared contracts package. Note: the API takes -// addresses: string[] (CustomerDtos.cs); the old local CreateCustomerRequest -// sent a singular `address` field the API silently dropped. +// Customers domain — HTTP calls only (no hooks). Moved from lib/api/customers.ts. +import apiClient from '../../lib/api/client'; import type { Customer, CustomerListParams, CreateCustomerRequest, UpdateCustomerRequest, - PagedResponse, -} from '@proposal-system/api-contracts'; - -export type { - Customer, - CustomerListParams, - CreateCustomerRequest, - UpdateCustomerRequest, -} from '@proposal-system/api-contracts'; - -export type PagedCustomerResponse = PagedResponse; + PagedCustomerResponse, +} from './types'; export const customersApi = { /** GET /api/customers?query= — search autocomplete (unchanged). */ diff --git a/web/src/domain/customers/schemas.ts b/web/src/domain/customers/schemas.ts new file mode 100644 index 0000000..e7c2ab0 --- /dev/null +++ b/web/src/domain/customers/schemas.ts @@ -0,0 +1,50 @@ +// Customers domain — contract schema re-exports + customer form schema. +import { z } from 'zod'; +import { createCustomerRequestSchema } from '@proposal-system/api-contracts/schemas'; +import type { CreateCustomerRequest, UpdateCustomerRequest } from './types'; + +export { + customerSchema, + createCustomerRequestSchema, + updateCustomerRequestSchema, +} from '@proposal-system/api-contracts/schemas'; + +/** Simple email format check (client-side only) — mirrors CustomerManagementPage. */ +const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; + +/** + * Customer create/edit form. Derived from createCustomerRequestSchema — the + * form edits a single primary address string (the wire type takes + * addresses: string[]); toCustomerRequest() maps to the request shape. + */ +export const customerFormSchema = createCustomerRequestSchema + .pick({ name: true }) + .extend({ + name: z.string().trim().min(1, 'Name is required'), + address: z.string().trim().min(1, 'Address is required'), + contactEmail: z + .string() + .refine((v) => v.trim() === '' || EMAIL_RE.test(v.trim()), { + message: 'Enter a valid email address', + }), + }); + +export type CustomerFormValues = z.infer; + +/** + * Map validated form values to the create/update request. The form edits the + * primary address; any additional existing addresses are preserved. + * contactEmail is always sent: the API clears the stored email on empty + * string (CustomerService normalizes '' → null), so omitting the key when the + * admin blanks the field would make the email impossible to clear. + */ +export function toCustomerRequest( + values: CustomerFormValues, + existingAddresses: string[] = [] +): CreateCustomerRequest & UpdateCustomerRequest { + return { + name: values.name.trim(), + addresses: [values.address.trim(), ...existingAddresses.slice(1)], + contactEmail: values.contactEmail.trim(), + }; +} diff --git a/web/src/domain/customers/types.ts b/web/src/domain/customers/types.ts new file mode 100644 index 0000000..2deb9a0 --- /dev/null +++ b/web/src/domain/customers/types.ts @@ -0,0 +1,11 @@ +// Customers domain — contract type re-exports + view-only types. +import type { Customer, PagedResponse } from '@proposal-system/api-contracts'; + +export type { + Customer, + CustomerListParams, + CreateCustomerRequest, + UpdateCustomerRequest, +} from '@proposal-system/api-contracts'; + +export type PagedCustomerResponse = PagedResponse; diff --git a/web/src/domain/customers/use-cases.ts b/web/src/domain/customers/use-cases.ts new file mode 100644 index 0000000..81ac14a --- /dev/null +++ b/web/src/domain/customers/use-cases.ts @@ -0,0 +1,68 @@ +// Customers domain — TanStack Query hooks + query keys (the only public surface). +import { keepPreviousData, useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; +import { toast } from 'react-toastify'; +import { customersApi } from './api'; +import type { CreateCustomerRequest, CustomerListParams, UpdateCustomerRequest } from './types'; + +export const customersKeys = { + all: ['customers'] as const, + lists: () => [...customersKeys.all, 'list'] as const, + list: (params: CustomerListParams) => [...customersKeys.lists(), params] as const, + search: (query: string) => [...customersKeys.all, 'search', query] as const, +}; + +/** Name autocomplete (ProposalFormPage). Call with the debounced input; + * disabled below 2 characters, matching current page behavior. */ +export function useCustomerSearch(query: string) { + return useQuery({ + queryKey: customersKeys.search(query), + queryFn: () => customersApi.search(query), + enabled: query.trim().length >= 2, + }); +} + +/** Paginated management list (CustomerManagementPage). keepPreviousData + * keeps rows/totalCount rendered while a new page/search fetches (matches + * the old usePaginatedList behavior and keeps TablePagination's count from + * collapsing to 0 mid-fetch). */ +export function useCustomerList(params: CustomerListParams = {}) { + return useQuery({ + queryKey: customersKeys.list(params), + queryFn: () => customersApi.list(params), + placeholderData: keepPreviousData, + }); +} + +export function useCreateCustomer() { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: (data: CreateCustomerRequest) => customersApi.create(data), + onSuccess: () => { + // Invalidate the whole domain: lists AND cached search-autocomplete + // results (['customers','search',q]), which lists() would not cover — + // otherwise a just-created customer is invisible in New Proposal's + // autocomplete for the 5-minute staleTime. + queryClient.invalidateQueries({ queryKey: customersKeys.all }); + toast.success('Customer created'); + }, + onError: (error: Error) => { + toast.error(`Failed to create customer: ${error.message}`); + }, + }); +} + +export function useUpdateCustomer() { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: ({ id, data }: { id: string; data: UpdateCustomerRequest }) => + customersApi.update(id, data), + onSuccess: () => { + // Whole-domain invalidation — see useCreateCustomer. + queryClient.invalidateQueries({ queryKey: customersKeys.all }); + toast.success('Customer updated'); + }, + onError: (error: Error) => { + toast.error(`Failed to update customer: ${error.message}`); + }, + }); +} diff --git a/web/src/lib/api/lineItems.ts b/web/src/domain/lineItems/api.ts similarity index 74% rename from web/src/lib/api/lineItems.ts rename to web/src/domain/lineItems/api.ts index 95f4eb8..6d18f1d 100644 --- a/web/src/lib/api/lineItems.ts +++ b/web/src/domain/lineItems/api.ts @@ -1,14 +1,6 @@ -import apiClient from './client'; -// Fix: WEB-M5 — types from the shared contracts package. -import type { LineItem, CreateLineItemRequest, UpdateLineItemEntry } from '@proposal-system/api-contracts'; - -export type { - PricingMode, - LineItemSource, - LineItem, - CreateLineItemRequest, - UpdateLineItemEntry, -} from '@proposal-system/api-contracts'; +// Line items domain — HTTP calls only (no hooks). Moved from lib/api/lineItems.ts. +import apiClient from '../../lib/api/client'; +import type { LineItem, CreateLineItemRequest, UpdateLineItemEntry } from './types'; export const lineItemsApi = { getAll: async (proposalId: string): Promise => { diff --git a/web/src/domain/lineItems/schemas.ts b/web/src/domain/lineItems/schemas.ts new file mode 100644 index 0000000..3163f75 --- /dev/null +++ b/web/src/domain/lineItems/schemas.ts @@ -0,0 +1,11 @@ +// Line items domain — contract schema re-exports. +// A row-level form schema will arrive with the LineItemEditor migration to +// react-hook-form; until a component consumes it, none is defined here. +export { + pricingModeSchema, + lineItemSourceSchema, + lineItemSchema, + createLineItemRequestSchema, + updateLineItemEntrySchema, + bulkUpdateLineItemsRequestSchema, +} from '@proposal-system/api-contracts/schemas'; diff --git a/web/src/domain/lineItems/types.ts b/web/src/domain/lineItems/types.ts new file mode 100644 index 0000000..202c3b5 --- /dev/null +++ b/web/src/domain/lineItems/types.ts @@ -0,0 +1,9 @@ +// Line items domain — contract type re-exports. +export type { + PricingMode, + LineItemSource, + LineItem, + CreateLineItemRequest, + UpdateLineItemEntry, + BulkUpdateLineItemsRequest, +} from '@proposal-system/api-contracts'; diff --git a/web/src/domain/lineItems/use-cases.ts b/web/src/domain/lineItems/use-cases.ts new file mode 100644 index 0000000..c50a047 --- /dev/null +++ b/web/src/domain/lineItems/use-cases.ts @@ -0,0 +1,19 @@ +// Line items domain — TanStack Query hooks + query keys (the only public surface). +// Mutations on line items flow through useSaveProposalWorkspace (admin domain); +// add dedicated mutation hooks here when a page actually consumes them. +import { useQuery } from '@tanstack/react-query'; +import { lineItemsApi } from './api'; + +export const lineItemsKeys = { + all: ['lineItems'] as const, + byProposal: (proposalId: string) => [...lineItemsKeys.all, proposalId] as const, +}; + +/** Line items for a proposal (AdminWorkspace editor). */ +export function useLineItems(proposalId: string | undefined) { + return useQuery({ + queryKey: lineItemsKeys.byProposal(proposalId ?? ''), + queryFn: () => lineItemsApi.getAll(proposalId!), + enabled: !!proposalId, + }); +} diff --git a/web/src/lib/api/pricingLibrary.ts b/web/src/domain/pricingLibrary/api.ts similarity index 77% rename from web/src/lib/api/pricingLibrary.ts rename to web/src/domain/pricingLibrary/api.ts index a0b4bb0..c9f7974 100644 --- a/web/src/lib/api/pricingLibrary.ts +++ b/web/src/domain/pricingLibrary/api.ts @@ -1,21 +1,12 @@ -import apiClient from './client'; -// Fix: WEB-M5 — types from the shared contracts package. +// Pricing library domain — HTTP calls only (no hooks). Moved from lib/api/pricingLibrary.ts. +import apiClient from '../../lib/api/client'; import type { PricingLibraryItem, PricingLibraryListParams, CreatePricingLibraryItemRequest, UpdatePricingLibraryItemRequest, - PagedResponse, -} from '@proposal-system/api-contracts'; - -export type { - PricingLibraryItem, - PricingLibraryListParams, - CreatePricingLibraryItemRequest, - UpdatePricingLibraryItemRequest, -} from '@proposal-system/api-contracts'; - -export type PagedPricingLibraryResponse = PagedResponse; + PagedPricingLibraryResponse, +} from './types'; export const pricingLibraryApi = { /** GET /api/pricing-library/list?page=&pageSize= — paginated list (admin/sysadmin). */ diff --git a/web/src/domain/pricingLibrary/schemas.ts b/web/src/domain/pricingLibrary/schemas.ts new file mode 100644 index 0000000..5d0c306 --- /dev/null +++ b/web/src/domain/pricingLibrary/schemas.ts @@ -0,0 +1,49 @@ +// Pricing library domain — contract schema re-exports + item form schema. +import { z } from 'zod'; +import { createPricingLibraryItemRequestSchema } from '@proposal-system/api-contracts/schemas'; +import { optionalNumericString, toNumberOrNull } from '../shared/formFields'; +import type { CreatePricingLibraryItemRequest, UpdatePricingLibraryItemRequest } from './types'; + +export { + pricingLibraryItemSchema, + createPricingLibraryItemRequestSchema, + updatePricingLibraryItemRequestSchema, +} from '@proposal-system/api-contracts/schemas'; + +/** + * Pricing-library item create/edit form. Derived from + * createPricingLibraryItemRequestSchema — unitPrice is a string in form state + * (numeric text input, empty allowed); toPricingLibraryItemRequest() converts + * to the wire type. Validation mirrors the current PricingLibraryPage rules. + */ +export const pricingLibraryItemFormSchema = createPricingLibraryItemRequestSchema + .pick({ title: true, serviceCategory: true }) + .extend({ + title: z.string().trim().min(1, 'Title is required'), + unit: z.string(), + // Shared Number-based field — rejects trailing garbage ('12abc') that + // the old parseFloat refine silently coerced to 12. + unitPrice: optionalNumericString, + keywords: z.string(), + description: z.string(), + }); + +export type PricingLibraryItemFormValues = z.infer; + +/** + * Map validated form values to the create/update request (mirrors the current + * PricingLibraryPage submit mapping: optional strings dropped when empty, + * empty unitPrice sent as null). + */ +export function toPricingLibraryItemRequest( + values: PricingLibraryItemFormValues +): CreatePricingLibraryItemRequest & UpdatePricingLibraryItemRequest { + return { + title: values.title.trim(), + serviceCategory: values.serviceCategory, + ...(values.unit.trim() ? { unit: values.unit.trim() } : {}), + unitPrice: toNumberOrNull(values.unitPrice), + ...(values.keywords.trim() ? { keywords: values.keywords.trim() } : {}), + ...(values.description.trim() ? { description: values.description.trim() } : {}), + }; +} diff --git a/web/src/domain/pricingLibrary/types.ts b/web/src/domain/pricingLibrary/types.ts new file mode 100644 index 0000000..2fc8209 --- /dev/null +++ b/web/src/domain/pricingLibrary/types.ts @@ -0,0 +1,11 @@ +// Pricing library domain — contract type re-exports + view-only types. +import type { PagedResponse, PricingLibraryItem } from '@proposal-system/api-contracts'; + +export type { + PricingLibraryItem, + PricingLibraryListParams, + CreatePricingLibraryItemRequest, + UpdatePricingLibraryItemRequest, +} from '@proposal-system/api-contracts'; + +export type PagedPricingLibraryResponse = PagedResponse; diff --git a/web/src/domain/pricingLibrary/use-cases.ts b/web/src/domain/pricingLibrary/use-cases.ts new file mode 100644 index 0000000..6ca6a74 --- /dev/null +++ b/web/src/domain/pricingLibrary/use-cases.ts @@ -0,0 +1,69 @@ +// Pricing library domain — TanStack Query hooks + query keys (the only public surface). +import { keepPreviousData, useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; +import { toast } from 'react-toastify'; +import { pricingLibraryApi } from './api'; +import type { + CreatePricingLibraryItemRequest, + PricingLibraryListParams, + UpdatePricingLibraryItemRequest, +} from './types'; + +export const pricingLibraryKeys = { + all: ['pricingLibrary'] as const, + lists: () => [...pricingLibraryKeys.all, 'list'] as const, + list: (params: PricingLibraryListParams) => [...pricingLibraryKeys.lists(), params] as const, +}; + +/** Paginated pricing library list (PricingLibraryPage). keepPreviousData + * keeps rows/totalCount rendered while a new page fetches (prevents + * TablePagination's count collapsing to 0 mid-fetch). */ +export function usePricingLibraryList(params: PricingLibraryListParams = {}) { + return useQuery({ + queryKey: pricingLibraryKeys.list(params), + queryFn: () => pricingLibraryApi.list(params), + placeholderData: keepPreviousData, + }); +} + +export function useCreatePricingLibraryItem() { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: (data: CreatePricingLibraryItemRequest) => pricingLibraryApi.create(data), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: pricingLibraryKeys.lists() }); + toast.success('Pricing item created'); + }, + onError: (error: Error) => { + toast.error(`Failed to create item: ${error.message}`); + }, + }); +} + +export function useUpdatePricingLibraryItem() { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: ({ id, data }: { id: string; data: UpdatePricingLibraryItemRequest }) => + pricingLibraryApi.update(id, data), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: pricingLibraryKeys.lists() }); + toast.success('Pricing item updated'); + }, + onError: (error: Error) => { + toast.error(`Failed to update item: ${error.message}`); + }, + }); +} + +export function useDeletePricingLibraryItem() { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: (id: string) => pricingLibraryApi.remove(id), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: pricingLibraryKeys.lists() }); + toast.success('Pricing item deleted'); + }, + onError: (error: Error) => { + toast.error(`Failed to delete item: ${error.message}`); + }, + }); +} diff --git a/web/src/lib/api/proposals.ts b/web/src/domain/proposals/api.ts similarity index 72% rename from web/src/lib/api/proposals.ts rename to web/src/domain/proposals/api.ts index 024d1d8..b077f1f 100644 --- a/web/src/lib/api/proposals.ts +++ b/web/src/domain/proposals/api.ts @@ -1,6 +1,5 @@ -import apiClient from './client'; -// Fix: WEB-M5 — types now come from the shared contracts package (single source -// of truth aligned with the .NET DTOs); re-exported so page imports stay stable. +// Proposals domain — HTTP calls only (no hooks). Moved from lib/api/proposals.ts. +import apiClient from '../../lib/api/client'; import type { CreateProposalRequest, ProposalDetail, @@ -10,20 +9,8 @@ import type { PagedResponse, PdfVersion, PresignedUpload, -} from '@proposal-system/api-contracts'; - -export type { - ServiceCategory, - Priority, - CreateProposalRequest, - ProposalDetail, - ProposalListItem, - ProposalFilters, - ProposalStats, - PagedResponse, - PdfVersion, - PresignedUpload, -} from '@proposal-system/api-contracts'; + VendorProposal, +} from './types'; export const proposalsApi = { create: async (data: CreateProposalRequest): Promise => { @@ -68,6 +55,13 @@ export const proposalsApi = { return res.data; }, + /** GET /api/proposals/{id}/vendors — vendor proposals attached to a proposal + * (previously an inline apiClient call in VendorDataPanel). */ + getVendors: async (proposalId: string): Promise => { + const res = await apiClient.get(`/proposals/${proposalId}/vendors`); + return res.data; + }, + getPdf: async (id: string): Promise<{ downloadUrl: string } | null> => { const res = await apiClient.get(`/proposals/${id}/pdf`, { responseType: 'blob', @@ -94,4 +88,21 @@ export const proposalsApi = { const downloadUrl = URL.createObjectURL(blob); return { downloadUrl }; }, + + /** + * PUT a file to a presigned S3 URL. Plain fetch by design — the presigned + * URL must NOT carry our auth headers (the axios client would attach them). + * fetch resolves on HTTP errors, so the ok check is what surfaces S3 + * rejections (expired signature, size/content-type mismatch) as failures. + */ + uploadToPresignedUrl: async (uploadUrl: string, file: File): Promise => { + const res = await fetch(uploadUrl, { + method: 'PUT', + body: file, + headers: { 'Content-Type': file.type || 'application/pdf' }, + }); + if (!res.ok) { + throw new Error(`Vendor PDF upload failed (${res.status})`); + } + }, }; diff --git a/web/src/domain/proposals/schemas.ts b/web/src/domain/proposals/schemas.ts new file mode 100644 index 0000000..ca7f63a --- /dev/null +++ b/web/src/domain/proposals/schemas.ts @@ -0,0 +1,68 @@ +// Proposals domain — contract schema re-exports + form schemas. +// Form schemas are derived from the shared contract schemas (rule 4) — form +// fields hold strings for free-text inputs; toCreateProposalRequest() maps +// validated form values to the wire type at submit. +import { z } from 'zod'; +import { createProposalRequestSchema } from '@proposal-system/api-contracts/schemas'; +import type { CreateProposalRequest } from './types'; + +export { + proposalStatusSchema, + serviceCategorySchema, + prioritySchema, + proposalListItemSchema, + proposalDetailSchema, + createProposalRequestSchema, + updateProposalRequestSchema, + proposalStatsSchema, + pdfVersionSchema, + presignedUploadSchema, +} from '@proposal-system/api-contracts/schemas'; + +// Fix: WEB-M3 — minimum length for scope of work to ensure meaningful descriptions +export const MIN_SCOPE_LENGTH = 10; + +/** + * Create-proposal form. Derived from createProposalRequestSchema: + * optional wire fields (poNumber, notes) are plain strings in the form, and + * `customCategory` is a form-only field required when serviceCategory=Other. + */ +export const createProposalFormSchema = createProposalRequestSchema + .extend({ + workOrderNumber: z.string().trim().min(1, 'Work Order Number is required'), + poNumber: z.string(), + customerName: z.string().trim().min(1, 'Customer name is required'), + customerAddress: z.string().trim().min(1, 'Site address is required'), + scopeOfWork: z + .string() + .trim() + .min(MIN_SCOPE_LENGTH, `Scope of Work must be at least ${MIN_SCOPE_LENGTH} characters`), + notes: z.string(), + customCategory: z.string(), + }) + .refine((v) => v.serviceCategory !== 'Other' || v.customCategory.trim().length > 0, { + message: 'Specify category', + path: ['customCategory'], + }); + +export type CreateProposalFormValues = z.infer; + +/** Map validated form values to the wire request (mirrors the current + * ProposalFormPage submit mapping, including the `[Category: X]` notes prefix). */ +export function toCreateProposalRequest(values: CreateProposalFormValues): CreateProposalRequest { + const notes = + values.serviceCategory === 'Other' && values.customCategory.trim() + ? `[Category: ${values.customCategory.trim()}]\n${values.notes || ''}`.trim() + : values.notes || undefined; + + return { + workOrderNumber: values.workOrderNumber, + ...(values.poNumber.trim() ? { poNumber: values.poNumber.trim() } : {}), + customerName: values.customerName, + customerAddress: values.customerAddress, + scopeOfWork: values.scopeOfWork, + serviceCategory: values.serviceCategory, + priority: values.priority, + notes, + }; +} diff --git a/web/src/domain/proposals/types.ts b/web/src/domain/proposals/types.ts new file mode 100644 index 0000000..5b8c74f --- /dev/null +++ b/web/src/domain/proposals/types.ts @@ -0,0 +1,32 @@ +// Proposals domain — contract type re-exports + view-only types. +export type { + ProposalStatus, + ServiceCategory, + Priority, + CreateProposalRequest, + ProposalDetail, + ProposalListItem, + ProposalFilters, + ProposalStats, + PagedResponse, + PdfVersion, + PresignedUpload, +} from '@proposal-system/api-contracts'; + +/** View-only shape of GET /proposals/{id}/vendors (no contract DTO yet; + * previously declared inline in VendorDataPanel). */ +export interface VendorProposal { + id: string; + vendorName: string; + fileName: string; + totalVendorCost: number; + processingStatus: string; + extractedData: { + lineItems?: Array<{ + description: string; + quantity?: number; + unitPrice?: number; + total?: number; + }>; + } | null; +} diff --git a/web/src/domain/proposals/use-cases.ts b/web/src/domain/proposals/use-cases.ts new file mode 100644 index 0000000..99c3db9 --- /dev/null +++ b/web/src/domain/proposals/use-cases.ts @@ -0,0 +1,95 @@ +// Proposals domain — TanStack Query hooks + query keys (the only public surface). +import { keepPreviousData, useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; +import { toast } from 'react-toastify'; +import { proposalsApi } from './api'; +import type { CreateProposalRequest, ProposalDetail, ProposalFilters } from './types'; + +export const proposalsKeys = { + all: ['proposals'] as const, + lists: () => [...proposalsKeys.all, 'list'] as const, + list: (filters: ProposalFilters) => [...proposalsKeys.lists(), filters] as const, + detail: (id: string) => [...proposalsKeys.all, id] as const, + stats: () => [...proposalsKeys.all, 'stats'] as const, + vendors: (id: string) => [...proposalsKeys.all, id, 'vendors'] as const, +}; + +/** Paginated/filtered proposal list (ProposalListPage, AdminDashboard queue, + * Dashboard recent). keepPreviousData mirrors usePaginatedList's + * keep-rows-while-loading behavior across page changes. */ +export function useProposals(filters: ProposalFilters = {}) { + return useQuery({ + queryKey: proposalsKeys.list(filters), + queryFn: () => proposalsApi.getAll(filters), + placeholderData: keepPreviousData, + }); +} + +export function useProposal(id: string | undefined) { + return useQuery({ + queryKey: proposalsKeys.detail(id ?? ''), + queryFn: () => proposalsApi.getById(id!), + enabled: !!id, + }); +} + +export function useProposalStats() { + return useQuery({ + queryKey: proposalsKeys.stats(), + queryFn: () => proposalsApi.getStats(), + }); +} + +/** Vendor proposals attached to a proposal (VendorDataPanel). */ +export function useVendorProposals(proposalId: string | undefined) { + return useQuery({ + queryKey: proposalsKeys.vendors(proposalId ?? ''), + queryFn: () => proposalsApi.getVendors(proposalId!), + enabled: !!proposalId, + }); +} + +export interface CreateProposalVariables { + request: CreateProposalRequest; + /** Optional vendor PDF uploaded alongside the proposal. */ + vendorFile?: File | null; +} + +/** + * Create a proposal, optionally uploading a vendor PDF via presigned URL. + * Mirrors ProposalFormPage: a failed attachment upload warns but does not + * fail the mutation; success invalidates all proposal queries and toasts. + * Page-specific side effects (navigate) belong in the call site's onSuccess. + */ +export function useCreateProposal() { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: async ({ request, vendorFile }: CreateProposalVariables): Promise => { + const proposal = await proposalsApi.create(request); + + if (vendorFile) { + try { + const { uploadUrl, vendorProposalId } = await proposalsApi.uploadAttachment( + proposal.id, + vendorFile.name + ); + // Throws on S3 rejection (non-2xx) so a failed upload is never confirmed. + await proposalsApi.uploadToPresignedUrl(uploadUrl, vendorFile); + await proposalsApi.confirmUpload(proposal.id, vendorProposalId); + } catch { + toast.warning( + `Proposal ${proposal.proposalNumber} was created, but vendor PDF upload failed. You can re-upload from the proposal detail page.` + ); + } + } + + return proposal; + }, + onSuccess: (proposal) => { + queryClient.invalidateQueries({ queryKey: proposalsKeys.all }); + toast.success(`Proposal ${proposal.proposalNumber} submitted`); + }, + onError: (error: Error) => { + toast.error(error.message || 'Failed to submit proposal'); + }, + }); +} diff --git a/web/src/domain/shared/formFields.ts b/web/src/domain/shared/formFields.ts new file mode 100644 index 0000000..db3e0c9 --- /dev/null +++ b/web/src/domain/shared/formFields.ts @@ -0,0 +1,23 @@ +// Shared form-field schemas for numeric text inputs (single source — the +// Number-based validation deliberately rejects trailing garbage like '12abc', +// which parseFloat would silently coerce to 12). +import { z } from 'zod'; + +/** Required numeric text input (>= 0). */ +export const numericString = z + .string() + .refine((v) => v.trim() !== '' && !Number.isNaN(Number(v)) && Number(v) >= 0, { + message: 'Must be a number >= 0', + }); + +/** Optional numeric text input — empty maps to null on the wire. */ +export const optionalNumericString = z + .string() + .refine((v) => v.trim() === '' || (!Number.isNaN(Number(v)) && Number(v) >= 0), { + message: 'Must be a number >= 0', + }); + +/** Convert an optionalNumericString form value to the wire number|null. */ +export function toNumberOrNull(value: string): number | null { + return value.trim() !== '' ? Number(value) : null; +} diff --git a/web/src/domain/sites/api.ts b/web/src/domain/sites/api.ts new file mode 100644 index 0000000..2e45cfb --- /dev/null +++ b/web/src/domain/sites/api.ts @@ -0,0 +1,10 @@ +// Sites domain — HTTP calls only (no hooks). Moved from lib/api/sites.ts. +import apiClient from '../../lib/api/client'; +import type { Site } from './types'; + +export const sitesApi = { + search: async (query: string): Promise => { + const res = await apiClient.get(`/sites?query=${encodeURIComponent(query)}`); + return res.data; + }, +}; diff --git a/web/src/domain/sites/schemas.ts b/web/src/domain/sites/schemas.ts new file mode 100644 index 0000000..6c29cdb --- /dev/null +++ b/web/src/domain/sites/schemas.ts @@ -0,0 +1,3 @@ +// Sites domain — contract schema re-exports. Sites are read-only (search +// autocomplete), so there are no form schemas here. +export { siteSchema } from '@proposal-system/api-contracts/schemas'; diff --git a/web/src/domain/sites/types.ts b/web/src/domain/sites/types.ts new file mode 100644 index 0000000..ab6a9ce --- /dev/null +++ b/web/src/domain/sites/types.ts @@ -0,0 +1,2 @@ +// Sites domain — contract type re-exports. +export type { Site } from '@proposal-system/api-contracts'; diff --git a/web/src/domain/sites/use-cases.ts b/web/src/domain/sites/use-cases.ts new file mode 100644 index 0000000..1e82af7 --- /dev/null +++ b/web/src/domain/sites/use-cases.ts @@ -0,0 +1,18 @@ +// Sites domain — TanStack Query hooks + query keys (the only public surface). +import { useQuery } from '@tanstack/react-query'; +import { sitesApi } from './api'; + +export const sitesKeys = { + all: ['sites'] as const, + search: (query: string) => [...sitesKeys.all, 'search', query] as const, +}; + +/** Site autocomplete (ProposalFormPage). Call with the debounced input; + * disabled below 2 characters, matching current page behavior. */ +export function useSiteSearch(query: string) { + return useQuery({ + queryKey: sitesKeys.search(query), + queryFn: () => sitesApi.search(query), + enabled: query.trim().length >= 2, + }); +} diff --git a/web/src/hooks/useDebouncedValue.ts b/web/src/hooks/useDebouncedValue.ts new file mode 100644 index 0000000..2105437 --- /dev/null +++ b/web/src/hooks/useDebouncedValue.ts @@ -0,0 +1,32 @@ +import { useEffect, useRef, useState } from 'react'; + +/** + * Debounce a changing value. Single home for the debounce logic that was + * previously centralized in usePaginatedList and then hand-copied into four + * pages during the domain-layer migration (each copy had drifted: one leaked + * its timer on unmount, two hardcoded 300ms). + * + * @param onSettle optional callback fired when the debounced value actually + * changes — used by list pages to reset pagination to page 1 on a new search. + */ +export function useDebouncedValue(value: T, delayMs: number, onSettle?: (value: T) => void): T { + const [debounced, setDebounced] = useState(value); + const debouncedRef = useRef(value); + // Latest-callback ref so consumers can pass inline callbacks without + // re-arming the timer every render. + const onSettleRef = useRef(onSettle); + onSettleRef.current = onSettle; + + useEffect(() => { + const timer = setTimeout(() => { + if (debouncedRef.current !== value) { + debouncedRef.current = value; + setDebounced(value); + onSettleRef.current?.(value); + } + }, delayMs); + return () => clearTimeout(timer); + }, [value, delayMs]); + + return debounced; +} diff --git a/web/src/hooks/usePaginatedList.ts b/web/src/hooks/usePaginatedList.ts deleted file mode 100644 index f560c0c..0000000 --- a/web/src/hooks/usePaginatedList.ts +++ /dev/null @@ -1,76 +0,0 @@ -import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; -import { DEBOUNCE_SEARCH, DEFAULT_PAGE, DEFAULT_PAGE_SIZE } from '../constants'; - -interface PaginatedResult { - items: T[]; - totalCount: number; -} - -type FetchFn = (params: Record) => Promise>; - -export function usePaginatedList(fetchFn: FetchFn, extraParams: Record = {}) { - const [rows, setRows] = useState([]); - const [search, setSearch] = useState(''); - const [debouncedSearch, setDebouncedSearch] = useState(''); - const [page, setPage] = useState(DEFAULT_PAGE); - const [pageSize, setPageSize] = useState(DEFAULT_PAGE_SIZE); - const [totalCount, setTotalCount] = useState(0); - const [loading, setLoading] = useState(true); - const [err, setErr] = useState(''); - const debounceRef = useRef | null>(null); - - const extraKey = JSON.stringify(extraParams); - - useEffect(() => { - if (debounceRef.current) clearTimeout(debounceRef.current); - debounceRef.current = setTimeout(() => { - setDebouncedSearch(search); - setPage(1); - }, DEBOUNCE_SEARCH); - return () => { - if (debounceRef.current) clearTimeout(debounceRef.current); - }; - }, [search]); - - const prevExtraKey = useRef(extraKey); - useEffect(() => { - if (prevExtraKey.current !== extraKey) { - prevExtraKey.current = extraKey; - setPage(1); - } - }, [extraKey]); - - const reload = useCallback(() => { - setLoading(true); - setErr(''); - fetchFn({ search: debouncedSearch, page, pageSize, ...extraParams }) - .then(({ items, totalCount }) => { - setRows(items); - setTotalCount(totalCount); - }) - .catch((e: Error) => setErr(e.message || 'Failed to load')) - .finally(() => setLoading(false)); - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [fetchFn, debouncedSearch, page, pageSize, extraKey]); - - useEffect(() => { - reload(); - }, [reload]); - - return useMemo( - () => ({ - rows, - search, - setSearch, - page, - setPage, - pageSize, - setPageSize, - totalCount, - loading, - err, - reload, - }), - [rows, search, page, pageSize, totalCount, loading, err, reload] - ); -} diff --git a/web/src/lib/api/sites.ts b/web/src/lib/api/sites.ts deleted file mode 100644 index eb0b8a8..0000000 --- a/web/src/lib/api/sites.ts +++ /dev/null @@ -1,12 +0,0 @@ -import apiClient from './client'; -// Fix: WEB-M5 — types from the shared contracts package. -import type { Site } from '@proposal-system/api-contracts'; - -export type { Site } from '@proposal-system/api-contracts'; - -export const sitesApi = { - search: async (query: string): Promise => { - const res = await apiClient.get(`/sites?query=${encodeURIComponent(query)}`); - return res.data; - }, -}; diff --git a/web/src/pages/admin/customers/CustomerManagementPage.tsx b/web/src/pages/admin/customers/CustomerManagementPage.tsx index bd6a515..03ce926 100644 --- a/web/src/pages/admin/customers/CustomerManagementPage.tsx +++ b/web/src/pages/admin/customers/CustomerManagementPage.tsx @@ -1,5 +1,6 @@ import { useState } from 'react'; -import { useQuery, useMutation } from '@tanstack/react-query'; +import { useForm } from 'react-hook-form'; +import { zodResolver } from '@hookform/resolvers/zod'; import { Box, Typography, @@ -28,64 +29,42 @@ import SearchIcon from '@mui/icons-material/Search'; import AddIcon from '@mui/icons-material/Add'; import EditIcon from '@mui/icons-material/Edit'; import ErrorOutlineIcon from '@mui/icons-material/ErrorOutlined'; -import { toast } from 'react-toastify'; import { - customersApi, - type Customer, - type PagedCustomerResponse, - type CreateCustomerRequest, - type UpdateCustomerRequest, -} from '../../../lib/api/customers'; -import { queryClient } from '../../../lib/queryClient'; -import { QUERY_KEYS } from '../../../constants/queryKeys'; + useCustomerList, + useCreateCustomer, + useUpdateCustomer, +} from '../../../domain/customers/use-cases'; +import type { Customer } from '../../../domain/customers/types'; +import { + customerFormSchema, + toCustomerRequest, + type CustomerFormValues, +} from '../../../domain/customers/schemas'; import { DEFAULT_PAGE, DEFAULT_PAGE_SIZE, DEBOUNCE_SEARCH } from '../../../constants'; import { useDocumentTitle } from '../../../hooks/useDocumentTitle'; +import { useDebouncedValue } from '../../../hooks/useDebouncedValue'; const COL_COUNT = 4; // Name, Address, Contact Email, Actions -/** Simple email format check (client-side only). */ -function isValidEmail(email: string): boolean { - return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email); -} - -interface CustomerFormState { - name: string; - address: string; - contactEmail: string; -} - -const EMPTY_FORM: CustomerFormState = { name: '', address: '', contactEmail: '' }; +const EMPTY_FORM: CustomerFormValues = { name: '', address: '', contactEmail: '' }; export default function CustomerManagementPage() { useDocumentTitle('Customer Management'); // --- List state --- const [search, setSearch] = useState(''); - const [debouncedSearch, setDebouncedSearch] = useState(''); const [page, setPage] = useState(DEFAULT_PAGE); const [pageSize, setPageSize] = useState(DEFAULT_PAGE_SIZE); - // Debounce search input - const [debounceTimer, setDebounceTimer] = useState | null>(null); + // Debounced search resets to page 1 (shared hook — the previous inline + // timer leaked on unmount). + const debouncedSearch = useDebouncedValue(search, DEBOUNCE_SEARCH, () => setPage(1)); + const handleSearchChange = (value: string) => setSearch(value); - const handleSearchChange = (value: string) => { - setSearch(value); - if (debounceTimer) clearTimeout(debounceTimer); - const timer = setTimeout(() => { - setDebouncedSearch(value); - setPage(1); - }, DEBOUNCE_SEARCH); - setDebounceTimer(timer); - }; - - const { - data, - isLoading, - isError, - refetch, - } = useQuery({ - queryKey: [QUERY_KEYS.customerList, debouncedSearch, page, pageSize], - queryFn: () => customersApi.list({ search: debouncedSearch || undefined, page, pageSize }), + const { data, isLoading, isError, refetch } = useCustomerList({ + search: debouncedSearch || undefined, + page, + pageSize, }); const rows = data?.items ?? []; @@ -94,101 +73,59 @@ export default function CustomerManagementPage() { // --- Dialog state --- const [dialogOpen, setDialogOpen] = useState(false); const [editingCustomer, setEditingCustomer] = useState(null); - const [form, setForm] = useState(EMPTY_FORM); - const [formErrors, setFormErrors] = useState>>({}); const isEditing = editingCustomer !== null; + const { + register, + handleSubmit, + reset, + formState: { errors }, + } = useForm({ + resolver: zodResolver(customerFormSchema), + defaultValues: EMPTY_FORM, + }); + const openCreateDialog = () => { setEditingCustomer(null); - setForm(EMPTY_FORM); - setFormErrors({}); + reset(EMPTY_FORM); setDialogOpen(true); }; const openEditDialog = (customer: Customer) => { setEditingCustomer(customer); - setForm({ + reset({ name: customer.name, address: customer.addresses?.[0] ?? '', contactEmail: customer.contactEmail ?? '', }); - setFormErrors({}); setDialogOpen(true); }; const closeDialog = () => { setDialogOpen(false); setEditingCustomer(null); - setForm(EMPTY_FORM); - setFormErrors({}); + reset(EMPTY_FORM); }; - const validateForm = (): boolean => { - const errors: Partial> = {}; - if (!form.name.trim()) errors.name = 'Name is required'; - if (!form.address.trim()) errors.address = 'Address is required'; - if (form.contactEmail.trim() && !isValidEmail(form.contactEmail.trim())) { - errors.contactEmail = 'Enter a valid email address'; - } - setFormErrors(errors); - return Object.keys(errors).length === 0; - }; - - const handleFieldChange = (field: keyof CustomerFormState, value: string) => { - setForm((prev) => ({ ...prev, [field]: value })); - // Clear field error on change - if (formErrors[field]) { - setFormErrors((prev) => { - const next = { ...prev }; - delete next[field]; - return next; - }); - } - }; - - // --- Mutations --- - const createMutation = useMutation({ - mutationFn: (data: CreateCustomerRequest) => customersApi.create(data), - onSuccess: () => { - queryClient.invalidateQueries({ queryKey: [QUERY_KEYS.customerList] }); - closeDialog(); - toast.success('Customer created'); - }, - onError: (error: Error) => { - toast.error(`Failed to create customer: ${error.message}`); - }, - }); - - const updateMutation = useMutation({ - mutationFn: ({ id, data }: { id: string; data: UpdateCustomerRequest }) => - customersApi.update(id, data), - onSuccess: () => { - queryClient.invalidateQueries({ queryKey: [QUERY_KEYS.customerList] }); - closeDialog(); - toast.success('Customer updated'); - }, - onError: (error: Error) => { - toast.error(`Failed to update customer: ${error.message}`); - }, - }); - - const handleSubmit = () => { - if (!validateForm()) return; + // --- Mutations (domain hooks: invalidation + toasts live in the hooks) --- + const createMutation = useCreateCustomer(); + const updateMutation = useUpdateCustomer(); + const onSubmit = (values: CustomerFormValues) => { // Fix: WEB-M5 — the API contract is addresses: string[] (CustomerDtos.cs); // the old singular `address` field was silently dropped by model binding. - // The form edits the primary address; any additional addresses are preserved. - const payload = { - name: form.name.trim(), - addresses: [form.address.trim(), ...(editingCustomer?.addresses.slice(1) ?? [])], - ...(form.contactEmail.trim() ? { contactEmail: form.contactEmail.trim() } : {}), - }; + // The form edits the primary address; any additional addresses are preserved + // (toCustomerRequest maps to addresses: [primary, ...rest]). + const payload = toCustomerRequest(values, editingCustomer?.addresses ?? []); if (isEditing) { - updateMutation.mutate({ id: editingCustomer.id, data: payload }); + updateMutation.mutate( + { id: editingCustomer.id, data: payload }, + { onSuccess: closeDialog } + ); } else { - createMutation.mutate(payload); + createMutation.mutate(payload, { onSuccess: closeDialog }); } }; @@ -324,54 +261,53 @@ export default function CustomerManagementPage() { {/* Create / Edit Dialog */} - {isEditing ? 'Edit Customer' : 'New Customer'} - - - handleFieldChange('name', e.target.value)} - error={!!formErrors.name} - helperText={formErrors.name} - size="small" - required - fullWidth - autoFocus - /> - handleFieldChange('address', e.target.value)} - error={!!formErrors.address} - helperText={formErrors.address} - size="small" - required - fullWidth - /> - handleFieldChange('contactEmail', e.target.value)} - error={!!formErrors.contactEmail} - helperText={formErrors.contactEmail || 'Optional. Used for proposal PDF delivery.'} - size="small" - fullWidth - /> - - - - - - +
+ {isEditing ? 'Edit Customer' : 'New Customer'} + + + + + + + + + + + +
); diff --git a/web/src/pages/admin/dashboard/AdminDashboard.tsx b/web/src/pages/admin/dashboard/AdminDashboard.tsx index b664728..8bc8700 100644 --- a/web/src/pages/admin/dashboard/AdminDashboard.tsx +++ b/web/src/pages/admin/dashboard/AdminDashboard.tsx @@ -1,6 +1,5 @@ import { useMemo, useState, Fragment } from 'react'; import { useNavigate } from 'react-router-dom'; -import { useQuery } from '@tanstack/react-query'; import { Box, Typography, @@ -37,13 +36,13 @@ import ReportIcon from '@mui/icons-material/Report'; import ErrorOutlineIcon from '@mui/icons-material/ErrorOutlined'; import KeyboardArrowDownIcon from '@mui/icons-material/KeyboardArrowDown'; import KeyboardArrowRightIcon from '@mui/icons-material/KeyboardArrowRight'; -import { usePaginatedList } from '../../../hooks/usePaginatedList'; -import { proposalsApi, type ProposalListItem } from '../../../lib/api/proposals'; -import { adminApi, type DashboardStats } from '../../../lib/api/admin'; -import { QUERY_KEYS } from '../../../constants/queryKeys'; -import { SERVICE_CATEGORIES, PRIORITIES, PROPOSAL_STATUSES, STATUS_CHIP_STYLES, STATUS_LABELS, PRIORITY_LABELS, PRIORITY_CHIP_STYLES, PRIORITY_ROW_SX } from '../../../constants'; +import { useProposals } from '../../../domain/proposals/use-cases'; +import type { ProposalListItem } from '../../../domain/proposals/types'; +import { useAdminDashboard } from '../../../domain/admin/use-cases'; +import { DEBOUNCE_SEARCH, DEFAULT_PAGE, DEFAULT_PAGE_SIZE, SERVICE_CATEGORIES, PRIORITIES, PROPOSAL_STATUSES, STATUS_CHIP_STYLES, STATUS_LABELS, PRIORITY_LABELS, PRIORITY_CHIP_STYLES, PRIORITY_ROW_SX } from '../../../constants'; import { formatBidAmount, formatDate, formatDateFull, formatAge, getAgeColor, groupRevisions } from '../../../lib/format'; import { useDocumentTitle } from '../../../hooks/useDocumentTitle'; +import { useDebouncedValue } from '../../../hooks/useDebouncedValue'; const COL_COUNT = 11; const TAB_STATUSES = ['', 'InReview', 'Approved', 'Sent', 'Revised'] as const; @@ -75,33 +74,41 @@ export default function AdminDashboard({ defaultStatus }: { defaultStatus?: stri const showTabs = !defaultStatus; // Fix: WEB-M8 — capture error state for admin stats to show error instead of misleading zeros - const { data: stats, isLoading: statsLoading, isError: statsError, refetch: refetchStats } = useQuery({ - queryKey: [QUERY_KEYS.dashboard], - queryFn: adminApi.getDashboard, - }); + const { data: stats, isLoading: statsLoading, isError: statsError, refetch: refetchStats } = useAdminDashboard(); const [statusFilter, setStatusFilter] = useState(defaultStatus ?? ''); const [categoryFilter, setCategoryFilter] = useState(''); const [priorityFilter, setPriorityFilter] = useState(''); const [expanded, setExpanded] = useState>(new Set()); + const [search, setSearch] = useState(''); + const [page, setPage] = useState(DEFAULT_PAGE); + const [pageSize, setPageSize] = useState(DEFAULT_PAGE_SIZE); + + // Debounced search resets to page 1 (mirrors the old usePaginatedList behavior). + const debouncedSearch = useDebouncedValue(search, DEBOUNCE_SEARCH, () => setPage(1)); + const { - rows, - search, - setSearch, + data: listData, + isPending: listPending, + isPlaceholderData: listIsPlaceholder, + error: listError, + } = useProposals({ + search: debouncedSearch, page, - setPage, pageSize, - setPageSize, - totalCount, - loading, - err, - } = usePaginatedList(proposalsApi.getAll, { ...(statusFilter && { status: statusFilter }), ...(categoryFilter && { serviceCategory: categoryFilter }), ...(priorityFilter && { priority: priorityFilter }), }); + const rows = useMemo(() => listData?.items ?? [], [listData]); + const totalCount = listData?.totalCount ?? 0; + // Show skeletons on initial load and while previous-page placeholder data is + // displayed for a new filter/page/search key (matches the old loading flag). + const loading = listPending || listIsPlaceholder; + const err = listError ? listError.message || 'Failed to load' : ''; + const grouped = useMemo(() => groupRevisions(rows), [rows]); const toggleExpand = (baseNumber: string) => { @@ -127,6 +134,7 @@ export default function AdminDashboard({ defaultStatus }: { defaultStatus?: stri setCategoryFilter(''); setPriorityFilter(''); setSearch(''); + setPage(1); }; const renderRow = (row: ProposalListItem, indent: boolean, showExpandIcon: boolean, isExpanded: boolean, baseNumber: string) => ( @@ -297,7 +305,10 @@ export default function AdminDashboard({ defaultStatus }: { defaultStatus?: stri label="Category" value={categoryFilter} sx={{ width: 160 }} - onChange={(e) => setCategoryFilter(e.target.value)} + onChange={(e) => { + setCategoryFilter(e.target.value); + setPage(1); + }} > All {SERVICE_CATEGORIES.map((c) => ( @@ -310,7 +321,10 @@ export default function AdminDashboard({ defaultStatus }: { defaultStatus?: stri label="Priority" value={priorityFilter} sx={{ width: 200 }} - onChange={(e) => setPriorityFilter(e.target.value)} + onChange={(e) => { + setPriorityFilter(e.target.value); + setPage(1); + }} > All {PRIORITIES.map((p) => ( @@ -324,7 +338,10 @@ export default function AdminDashboard({ defaultStatus }: { defaultStatus?: stri label="Status" value={statusFilter} sx={{ width: 140 }} - onChange={(e) => setStatusFilter(e.target.value)} + onChange={(e) => { + setStatusFilter(e.target.value); + setPage(1); + }} > All {PROPOSAL_STATUSES.map((s) => ( @@ -392,7 +409,8 @@ export default function AdminDashboard({ defaultStatus }: { defaultStatus?: stri ); })} - {!loading && rows.length === 0 && ( + {/* Empty state only when the fetch actually succeeded. */} + {!loading && !err && rows.length === 0 && ( diff --git a/web/src/pages/admin/pricing-library/PricingLibraryPage.tsx b/web/src/pages/admin/pricing-library/PricingLibraryPage.tsx index ea200c6..13581d6 100644 --- a/web/src/pages/admin/pricing-library/PricingLibraryPage.tsx +++ b/web/src/pages/admin/pricing-library/PricingLibraryPage.tsx @@ -1,5 +1,6 @@ import { useState } from 'react'; -import { useQuery, useMutation } from '@tanstack/react-query'; +import { Controller, useForm, type DefaultValues } from 'react-hook-form'; +import { zodResolver } from '@hookform/resolvers/zod'; import { Box, Typography, @@ -29,17 +30,18 @@ import AddIcon from '@mui/icons-material/Add'; import EditIcon from '@mui/icons-material/Edit'; import DeleteIcon from '@mui/icons-material/Delete'; import ErrorOutlineIcon from '@mui/icons-material/ErrorOutlined'; -import { toast } from 'react-toastify'; import { - pricingLibraryApi, - type PricingLibraryItem, - type PagedPricingLibraryResponse, - type CreatePricingLibraryItemRequest, - type UpdatePricingLibraryItemRequest, -} from '../../../lib/api/pricingLibrary'; -import type { ServiceCategory } from '@proposal-system/api-contracts'; -import { queryClient } from '../../../lib/queryClient'; -import { QUERY_KEYS } from '../../../constants/queryKeys'; + usePricingLibraryList, + useCreatePricingLibraryItem, + useUpdatePricingLibraryItem, + useDeletePricingLibraryItem, +} from '../../../domain/pricingLibrary/use-cases'; +import type { PricingLibraryItem } from '../../../domain/pricingLibrary/types'; +import { + pricingLibraryItemFormSchema, + toPricingLibraryItemRequest, + type PricingLibraryItemFormValues, +} from '../../../domain/pricingLibrary/schemas'; import { DEFAULT_PAGE, DEFAULT_PAGE_SIZE, SERVICE_CATEGORIES } from '../../../constants'; import { useDocumentTitle } from '../../../hooks/useDocumentTitle'; @@ -55,18 +57,12 @@ const CATEGORY_CHIP_STYLES: Record = { title: '', - serviceCategory: '', unit: '', unitPrice: '', keywords: '', @@ -86,15 +82,7 @@ export default function PricingLibraryPage() { const [page, setPage] = useState(DEFAULT_PAGE); const [pageSize, setPageSize] = useState(DEFAULT_PAGE_SIZE); - const { - data, - isLoading, - isError, - refetch, - } = useQuery({ - queryKey: [QUERY_KEYS.pricingLibraryList, page, pageSize], - queryFn: () => pricingLibraryApi.list({ page, pageSize }), - }); + const { data, isLoading, isError, refetch } = usePricingLibraryList({ page, pageSize }); const rows = data?.items ?? []; const totalCount = data?.totalCount ?? 0; @@ -102,21 +90,28 @@ export default function PricingLibraryPage() { // --- Create/Edit dialog state --- const [dialogOpen, setDialogOpen] = useState(false); const [editingItem, setEditingItem] = useState(null); - const [form, setForm] = useState(EMPTY_FORM); - const [formErrors, setFormErrors] = useState>>({}); const isEditing = editingItem !== null; + const { + control, + handleSubmit, + reset, + formState: { errors }, + } = useForm({ + resolver: zodResolver(pricingLibraryItemFormSchema), + defaultValues: EMPTY_FORM_VALUES, + }); + const openCreateDialog = () => { setEditingItem(null); - setForm(EMPTY_FORM); - setFormErrors({}); + reset(EMPTY_FORM_VALUES); setDialogOpen(true); }; const openEditDialog = (item: PricingLibraryItem) => { setEditingItem(item); - setForm({ + reset({ title: item.title, serviceCategory: item.serviceCategory, unit: item.unit ?? '', @@ -124,40 +119,13 @@ export default function PricingLibraryPage() { keywords: item.keywords ?? '', description: item.description ?? '', }); - setFormErrors({}); setDialogOpen(true); }; const closeDialog = () => { setDialogOpen(false); setEditingItem(null); - setForm(EMPTY_FORM); - setFormErrors({}); - }; - - const validateForm = (): boolean => { - const errors: Partial> = {}; - if (!form.title.trim()) errors.title = 'Title is required'; - if (!form.serviceCategory) errors.serviceCategory = 'Service category is required'; - if (form.unitPrice.trim() !== '') { - const parsed = parseFloat(form.unitPrice); - if (isNaN(parsed) || parsed < 0) { - errors.unitPrice = 'Must be a number >= 0'; - } - } - setFormErrors(errors); - return Object.keys(errors).length === 0; - }; - - const handleFieldChange = (field: keyof ItemFormState, value: string) => { - setForm((prev) => ({ ...prev, [field]: value })); - if (formErrors[field]) { - setFormErrors((prev) => { - const next = { ...prev }; - delete next[field]; - return next; - }); - } + reset(EMPTY_FORM_VALUES); }; // --- Delete confirm dialog state --- @@ -171,67 +139,26 @@ export default function PricingLibraryPage() { setDeleteTarget(null); }; - // --- Mutations --- - const createMutation = useMutation({ - mutationFn: (data: CreatePricingLibraryItemRequest) => pricingLibraryApi.create(data), - onSuccess: () => { - queryClient.invalidateQueries({ queryKey: [QUERY_KEYS.pricingLibraryList] }); - closeDialog(); - toast.success('Pricing item created'); - }, - onError: (error: Error) => { - toast.error(`Failed to create item: ${error.message}`); - }, - }); + // --- Mutations (domain hooks own invalidation + toasts; dialog close stays here) --- + const createMutation = useCreatePricingLibraryItem(); + const updateMutation = useUpdatePricingLibraryItem(); + const deleteMutation = useDeletePricingLibraryItem(); - const updateMutation = useMutation({ - mutationFn: ({ id, data }: { id: string; data: UpdatePricingLibraryItemRequest }) => - pricingLibraryApi.update(id, data), - onSuccess: () => { - queryClient.invalidateQueries({ queryKey: [QUERY_KEYS.pricingLibraryList] }); - closeDialog(); - toast.success('Pricing item updated'); - }, - onError: (error: Error) => { - toast.error(`Failed to update item: ${error.message}`); - }, - }); - - const deleteMutation = useMutation({ - mutationFn: (id: string) => pricingLibraryApi.remove(id), - onSuccess: () => { - queryClient.invalidateQueries({ queryKey: [QUERY_KEYS.pricingLibraryList] }); - closeDeleteDialog(); - toast.success('Pricing item deleted'); - }, - onError: (error: Error) => { - toast.error(`Failed to delete item: ${error.message}`); - }, - }); - - const handleSubmit = () => { - if (!validateForm()) return; - if (!form.serviceCategory) return; // validateForm guarantees this; narrows '' out of the union - - const payload = { - title: form.title.trim(), - serviceCategory: form.serviceCategory, - ...(form.unit.trim() ? { unit: form.unit.trim() } : {}), - unitPrice: form.unitPrice.trim() !== '' ? parseFloat(form.unitPrice) : null, - ...(form.keywords.trim() ? { keywords: form.keywords.trim() } : {}), - ...(form.description.trim() ? { description: form.description.trim() } : {}), - }; - - if (isEditing) { - updateMutation.mutate({ id: editingItem.id, data: payload }); + const onSubmit = handleSubmit((values) => { + const payload = toPricingLibraryItemRequest(values); + if (editingItem) { + updateMutation.mutate( + { id: editingItem.id, data: payload }, + { onSuccess: () => closeDialog() } + ); } else { - createMutation.mutate(payload); + createMutation.mutate(payload, { onSuccess: () => closeDialog() }); } - }; + }); const handleDeleteConfirm = () => { if (deleteTarget) { - deleteMutation.mutate(deleteTarget.id); + deleteMutation.mutate(deleteTarget.id, { onSuccess: () => closeDeleteDialog() }); } }; @@ -368,90 +295,123 @@ export default function PricingLibraryPage() { {/* Create / Edit Dialog */} {isEditing ? 'Edit Pricing Item' : 'New Pricing Item'} - - - handleFieldChange('title', e.target.value)} - error={!!formErrors.title} - helperText={formErrors.title} - size="small" - required - fullWidth - autoFocus - /> - handleFieldChange('serviceCategory', e.target.value)} - error={!!formErrors.serviceCategory} - helperText={formErrors.serviceCategory} - size="small" - required - fullWidth - select +
+ + + ( + + )} + /> + ( + + {SERVICE_CATEGORIES.map((cat) => ( + + {cat} + + ))} + + )} + /> + ( + + )} + /> + ( + + )} + /> + ( + + )} + /> + ( + + )} + /> + + + + + - - + {isSaving ? (isEditing ? 'Saving...' : 'Creating...') : isEditing ? 'Save Changes' : 'Create Item'} + + +
{/* Delete Confirm Dialog */} diff --git a/web/src/pages/admin/workspace/AdminWorkspace.tsx b/web/src/pages/admin/workspace/AdminWorkspace.tsx index 87729e8..b7bb83b 100644 --- a/web/src/pages/admin/workspace/AdminWorkspace.tsx +++ b/web/src/pages/admin/workspace/AdminWorkspace.tsx @@ -1,6 +1,5 @@ import { useState, useEffect, useCallback, useRef } from 'react'; import { useParams, useNavigate } from 'react-router-dom'; -import { useQuery, useMutation } from '@tanstack/react-query'; import { Box, Typography, @@ -31,16 +30,39 @@ import HistoryIcon from '@mui/icons-material/History'; import PictureAsPdfIcon from '@mui/icons-material/PictureAsPdf'; import UndoIcon from '@mui/icons-material/Undo'; import { toast } from 'react-toastify'; -import { proposalsApi, type ProposalDetail } from '../../../lib/api/proposals'; -import { lineItemsApi, type LineItem } from '../../../lib/api/lineItems'; -import { adminApi } from '../../../lib/api/admin'; -import { queryClient } from '../../../lib/queryClient'; -import { QUERY_KEYS } from '../../../constants/queryKeys'; +import { useProposal } from '../../../domain/proposals/use-cases'; +import { useLineItems } from '../../../domain/lineItems/use-cases'; +import type { UpdateLineItemEntry } from '../../../domain/lineItems/types'; +import { + useSimilarProposals, + useSaveProposalWorkspace, + useApproveProposal, + useSendProposal, + useReviseProposal, + useReturnToReview, + useGenerateSuggestions, + useProposalPdf, +} from '../../../domain/admin/use-cases'; import LineItemEditor, { type EditableLineItem } from '../../../components/admin/LineItemEditor'; import VendorDataPanel from '../../../components/admin/VendorDataPanel'; import SimilarProposalsPanel from '../../../components/admin/SimilarProposalsPanel'; import { STATUS_CHIP_STYLES } from '../../../constants'; +/** Map the editor's rows to the bulk-update wire entries. */ +function toUpdateEntries(items: EditableLineItem[]): UpdateLineItemEntry[] { + return items.map((li) => ({ + id: li.id, + description: li.description, + quantity: li.quantity, + unit: li.unit, + unitPrice: li.unitPrice, + totalPrice: li.totalPrice, + pricingMode: li.pricingMode, + sortOrder: li.sortOrder, + source: li.source, + })); +} + export default function AdminWorkspace() { const { id } = useParams<{ id: string }>(); const navigate = useNavigate(); @@ -55,24 +77,18 @@ export default function AdminWorkspace() { const [dirty, setDirty] = useState(false); // Fix: WEB-M7 — capture error state for failed fetch - const { data: proposal, isLoading, isError: isProposalError, error: proposalError, refetch: refetchProposal } = useQuery({ - queryKey: [QUERY_KEYS.proposals, id], - queryFn: () => proposalsApi.getById(id!), - enabled: !!id, - }); + const { + data: proposal, + isLoading, + isError: isProposalError, + error: proposalError, + refetch: refetchProposal, + } = useProposal(id); // Fix: WEB-M9 — capture loading state for line items skeleton - const { data: fetchedLineItems, isLoading: lineItemsLoading } = useQuery({ - queryKey: [QUERY_KEYS.proposalLineItems, id], - queryFn: () => lineItemsApi.getAll(id!), - enabled: !!id, - }); + const { data: fetchedLineItems, isLoading: lineItemsLoading } = useLineItems(id); - const { data: similarProposals } = useQuery({ - queryKey: ['similarProposals', id], - queryFn: () => adminApi.getSimilar(id!), - enabled: !!id, - }); + const { data: similarProposals } = useSimilarProposals(id); const hasSimilar = similarProposals && similarProposals.length > 0; @@ -121,119 +137,70 @@ export default function AdminWorkspace() { } }, [dirty, navigate]); - const saveMutation = useMutation({ - mutationFn: async () => { - await adminApi.updateProposal(id!, { refinedScope }); - await lineItemsApi.bulkUpdate( - id!, - lineItems.map((li) => ({ - id: li.id, - description: li.description, - quantity: li.quantity, - unit: li.unit, - unitPrice: li.unitPrice, - totalPrice: li.totalPrice, - pricingMode: li.pricingMode, - sortOrder: li.sortOrder, - source: li.source, - })) - ); - }, - onSuccess: () => { - queryClient.invalidateQueries({ queryKey: [QUERY_KEYS.proposalLineItems, id] }); - queryClient.invalidateQueries({ queryKey: [QUERY_KEYS.proposals, id] }); - setDirty(false); - toast.success('Changes saved'); - }, - onError: (error: Error) => { - toast.error(`Save failed: ${error.message}`); - }, - }); - - const approveMutation = useMutation({ - mutationFn: async () => { - if (dirty) { - await saveMutation.mutateAsync(); - } - await adminApi.approveProposal(id!); - }, - onSuccess: () => { - queryClient.invalidateQueries({ queryKey: [QUERY_KEYS.proposals, id] }); - queryClient.invalidateQueries({ queryKey: [QUERY_KEYS.proposalLineItems, id] }); - setApproveDialogOpen(false); - setDirty(false); - toast.success('Proposal approved'); - }, - onError: (error: Error) => { - toast.error(`Approval failed: ${error.message}`); - }, - }); - - const sendMutation = useMutation({ - mutationFn: () => adminApi.sendProposal(id!), - onSuccess: () => { - queryClient.invalidateQueries({ queryKey: [QUERY_KEYS.proposals, id] }); - setSendDialogOpen(false); - toast.success('Proposal marked as sent'); - }, - // Fix: WEB-H5 — add missing onError handler - onError: (error: Error) => { - toast.error(`Send failed: ${error.message}`); - }, - }); - - const reviseMutation = useMutation({ - mutationFn: () => adminApi.reviseProposal(id!), - onSuccess: () => { - queryClient.invalidateQueries({ queryKey: [QUERY_KEYS.proposals, id] }); - setReviseDialogOpen(false); - toast.success('Revision created'); - }, - // Fix: WEB-H6 — add missing onError handler - onError: (error: Error) => { - toast.error(`Revision failed: ${error.message}`); - }, - }); - - const regenerateMutation = useMutation({ - mutationFn: () => adminApi.generateSuggestions(id!), - onSuccess: () => { - toast.info('AI suggestion generation started'); - }, - // Fix: WEB-H5 — add missing onError handler - onError: (error: Error) => { - toast.error(`Regeneration failed: ${error.message}`); - }, - }); - + // Domain mutations: cache invalidation + success/error toasts live in the + // hooks; page-specific side effects (dialog close, dirty reset, window.open) + // are applied here via the call-site callbacks (domain README rule 6). + const saveMutation = useSaveProposalWorkspace(id ?? ''); + const approveMutation = useApproveProposal(id ?? ''); + const sendMutation = useSendProposal(id ?? ''); + const reviseMutation = useReviseProposal(id ?? ''); + const regenerateMutation = useGenerateSuggestions(id ?? ''); // Fix: WEB-M11 — wire returnToReview API method to UI - const returnToReviewMutation = useMutation({ - mutationFn: () => adminApi.returnToReview(id!), - onSuccess: () => { - queryClient.invalidateQueries({ queryKey: [QUERY_KEYS.proposals, id] }); - queryClient.invalidateQueries({ queryKey: [QUERY_KEYS.proposalLineItems, id] }); - setReturnToReviewDialogOpen(false); - toast.success('Proposal returned to review'); - }, - onError: (error: Error) => { - toast.error(`Return to review failed: ${error.message}`); - }, - }); + const returnToReviewMutation = useReturnToReview(id ?? ''); + const pdfMutation = useProposalPdf(id ?? ''); - const pdfMutation = useMutation({ - mutationFn: () => adminApi.getPdf(id!), - onSuccess: (data) => { - if (data?.downloadUrl) { - window.open(data.downloadUrl, '_blank'); - toast.success('PDF ready — downloading'); - } else { - toast.info('PDF generation queued — try again in a few seconds'); + const handleSave = () => { + saveMutation.mutate( + { refinedScope, lineItems: toUpdateEntries(lineItems) }, + { onSuccess: () => setDirty(false) } + ); + }; + + const handleConfirmApprove = async () => { + try { + // Persist any unsaved workspace edits before approving (behavior parity + // with the previous approve mutation, which saved first when dirty). + if (dirty) { + await saveMutation.mutateAsync({ refinedScope, lineItems: toUpdateEntries(lineItems) }); + // The edits are persisted regardless of whether the approve below + // succeeds — clear dirty NOW so a failed approve doesn't leave a + // false unsaved-changes prompt over already-saved work. + setDirty(false); } - }, - onError: () => { - toast.error('Failed to generate PDF'); - }, - }); + await approveMutation.mutateAsync(); + setApproveDialogOpen(false); + } catch { + // error toasts are handled by the domain hooks + } + }; + const approvePending = approveMutation.isPending || saveMutation.isPending; + + const handleConfirmSend = () => { + sendMutation.mutate(undefined, { onSuccess: () => setSendDialogOpen(false) }); + }; + + const handleConfirmRevise = () => { + reviseMutation.mutate(undefined, { onSuccess: () => setReviseDialogOpen(false) }); + }; + + const handleConfirmReturnToReview = () => { + returnToReviewMutation.mutate(undefined, { + onSuccess: () => setReturnToReviewDialogOpen(false), + }); + }; + + const handleGeneratePdf = () => { + pdfMutation.mutate(undefined, { + onSuccess: (data) => { + if (data?.downloadUrl) { + window.open(data.downloadUrl, '_blank'); + toast.success('PDF ready — downloading'); + } else { + toast.info('PDF generation queued — try again in a few seconds'); + } + }, + }); + }; const handleLineItemsChange = (updated: EditableLineItem[]) => { setLineItems(updated); @@ -478,7 +445,7 @@ export default function AdminWorkspace() { @@ -622,7 +589,7 @@ export default function AdminWorkspace() {