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/3] =?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/3] 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/3] 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: {