From 4629f7a21a7b41b2a6caee09e39d964b2a7229ff Mon Sep 17 00:00:00 2001 From: Adam Moussa <166072409+amoussa1229@users.noreply.github.com> Date: Mon, 13 Jul 2026 21:02:20 -0400 Subject: [PATCH] =?UTF-8?q?ci(web):=20Phase=205=20=E2=80=94=20Prettier=20c?= =?UTF-8?q?heck=20+=20Playwright=20smoke,=20org=20frontend=20workflow=20(#?= =?UTF-8?q?225)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(web): adopt SHOC design system and shell layout (ADR 0003) Port shoc-frontend-new dev's design system with its CSS-variable single-token-source mechanism: - src/styles/theme.css: SHOC token file ported verbatim (Montserrat/ DM Sans/JetBrains Mono, primary #1c75bc, navy #262262, full radius/ shadow/sidebar/header token layers); fonts self-hosted via @fontsource - src/lib/theme/{css-vars,mui-theme}.ts: getCssVar -> createTheme adapter mirroring SHOC's mui-theme.ts (palette, typography, shadows tuple, component overrides; MUI v9 slot renames expressed as class selectors); theme.ts is now a re-export - Shell: SHOC composition (sidebar column + sticky gradient topbar + scrolling main); sidebar 244px/76px collapse with brand header row, grouped nav, SHOC active treatment (white card + 3px accent bar); topbar 100-degree gradient, surface hamburger, gradient avatar pill - Brand: SeahavenMark + BrandLockup ported (Tailwind re-expressed as sx; wordmark subtitle localized to PROPOSAL SYSTEM) - Login: SHOC auth-card treatment (centered 384px card on #f9fafb) - Old "Sea Haven Ops" Inter/#2563EB theme and Nunito remnants removed; remaining hardcoded hexes replaced with tokens; lucide-react for shell/nav icons per SHOC convention Verify: tsc clean, 26/26 vitest, vite build OK; Playwright screenshots pixel-sampled against the extracted SHOC spec (all hard values exact, no blocking deviations). * feat(contracts): adopt shared api-contracts in web, add zod schemas and ProblemDetails codes Closes WEB-M5 (web hand-duplicated wire types, standing drift risk): - shared/api-contracts: rewritten as the authoritative superset of the .NET DTOs (ProposalListItem/ProposalDetail with poNumber and submittedByName, line item requests, customers, pricing library, dashboard, audit, sites, auth, presigned upload, ApiProblem); stale Proposal/UpdateLineItemsRequest shapes removed - shared/api-contracts/src/schemas.ts: zod runtime schemas coupled to every wire type via `satisfies z.ZodType` (schema/type drift is now a compile error); separate entrypoint so type-only consumers (mobile) never pull zod - web: imports @proposal-system/api-contracts (file: dep + tsconfig paths + vite preserveSymlinks); all 7 lib/api modules re-export shared types so page imports stay stable; enum unions tightened (PricingLibraryPage form state now ServiceCategory-typed) - fix(web): customer create/update sent a singular `address` field the API silently dropped (contract is addresses: string[], CustomerDtos.cs) - addresses now round-trip, extra addresses preserved on edit - api: ProblemDetails responses carry a machine-readable top-level `code` (SHOC error-code vocabulary): ValidationFailed, InvalidStateTransition, NotFound, Unauthorized, InternalError; new BusinessRuleException(code, message) maps to 422 with its code; GlobalExceptionHandlerTests cover the full mapping (wire contract) Cross-checked .NET DTOs vs TS types vs zod schemas with the orchestrator scanner (Gemini): core domains consistent; internal-only DTOs (FileDtos vendor/lambda surface, SimilarProposalDtos, UserDtos admin surface) intentionally uncovered. Verify: dotnet 166/166, web tsc + vitest 26/26 + build, mobile tsc, shared tsc all green. * feat(web): domain-layer conventions doc + react-hook-form deps (Phase 4 prep) * feat(web): scaffold domain module layer (proposals, lineItems, customers, pricingLibrary, admin, sites) Additive-only: pages still use lib/api/* and constants/queryKeys.ts until the page-migration agents run. Each domain ships api.ts (HTTP moved from lib/api), types.ts (contract re-exports + view types), schemas.ts (contract schema re-exports + form schemas with toRequest mappers), and use-cases.ts (TanStack Query v5 hooks + hierarchical query keys, mirroring current page invalidations and toast-on-error behavior). Adds an explicit vite/vitest alias for the @proposal-system/api-contracts/schemas subpath (package has no exports map) plus a schema/mapper smoke test suite. * refactor(web): proposal pages on domain layer, proposal form on react-hook-form * refactor(web): admin workspace on domain layer * refactor(web): customer management on domain layer + react-hook-form * refactor(web): pricing library on domain layer + react-hook-form * refactor(web): dashboards on domain layer * test(web): domain use-case hook coverage * refactor(web): finish domain-layer integration — migrate straggler components, delete legacy api modules - SimilarProposalsPanel -> useSimilarProposals (domain/admin); inline SimilarProposal type replaced by domain/admin/types (identical shape); query key joins the admin hierarchical key space - LineItemEditor type imports -> domain/lineItems/types - Delete now-orphaned lib/api/{proposals,lineItems,customers, pricingLibrary,admin,sites}.ts, constants/queryKeys.ts, hooks/usePaginatedList.ts (lib/api/client.ts + auth.ts stay per domain README rule 5) Verify: tsc clean, vitest 52/52, build OK, Playwright smoke of the authed shell renders on domain hooks. * fix(web): apply Phase 4 code-review findings (10 correctness + 4 cleanup) Correctness: - State-transition mutations now invalidate every cached view via invalidateProposalViews (detail + line items + lists + stats + admin dashboard) — approving no longer leaves a stale queue for the 5-minute staleTime - Presigned S3 PUT moved to proposals/api.ts with res.ok check — a rejected upload is no longer confirmed as uploaded - toCustomerRequest always sends contactEmail ('' clears); API create path normalizes empty->null to match the update path — customer emails can now be cleared from the UI - Shared Number-based numeric form fields (domain/shared/formFields): '12abc' no longer silently coerces to 12 in the pricing library - Customer create/update invalidate customersKeys.all so cached search autocompletes see new customers - AdminWorkspace clears dirty right after a successful implicit save, before approve — no false unsaved-changes prompt when approve fails - ProposalFormPage submit gate and missing-fields caption derive from ONE checks list (missing customer is now listed) - Empty states gated on !err in ProposalListPage/AdminDashboard — no contradictory error + 'no proposals' UI - VendorDataPanel migrated to useVendorProposals (kills the divergent ['vendorProposals', id] cache key and the inline apiClient query) - useCustomerList/usePricingLibraryList get keepPreviousData — no TablePagination out-of-range flash on page change Cleanup: - Dead speculative hooks removed (useCreate/BulkUpdate/DeleteLineItem, useUpdateProposal, useProposalHistory/Audit, lineItemRowFormSchema, toUpdateLineItemEntry); tests moved to the live save path (useSaveProposalWorkspace) - Shared useDebouncedValue hook replaces 4 drifted inline debounce copies (one leaked its timer on unmount, two hardcoded 300ms); DEBOUNCE_AUTOCOMPLETE=300 named - Fix: WEB-H5 / WEB-H6 finding-ID markers restored at the relocated onError handlers (CLAUDE.md traceability) - shared/api-contracts gains an exports map; /schemas resolver alias deduplicated from 3 copies to the tsconfig paths mapping Verify: tsc clean, vitest 51/51 (tests updated to pin the new invalidation/mapper behavior + new '12abc' rejection test), vite build OK, dotnet 166/166. * refactor(web): fold Redux auth/ui slices into SHOC-shape auth context + storage module Phase 4 tail of the SHOC-alignment plan. Matches SHOC's auth shape (lib/auth storage module + providers/ context split) while keeping the deliberate divergences: - sessionStorage, not localStorage (WEB-C1 stands; SHOC's localStorage is on the SHOULD-NOT-ALIGN list) - token acquisition stays in the auth pages (Cognito code exchange / dev-login) — the provider only owns session state - 401 interceptor clears storage directly (WEB-M2 behavior preserved; full-page redirect resets provider state) Sidebar open state moves to plain layout state in App passed down as props (SHOC (protected)/_layout.tsx pattern), keeping localStorage persistence. Drops @reduxjs/toolkit and react-redux. Tests: authSlice tests replaced by authStorage + AuthProvider suites (QA-C5 coverage preserved); client interceptor tests updated for the storage-based 401 path. 59 vitest green, tsc clean, vite build OK. Verified end-to-end headless: login redirect, seeded-session shell, sidebar toggle persistence, logout, expired/malformed token handling, RoleGuard bounce; recipe persisted as web/.claude/skills/verify. * fix(web): harden auth session teardown per /sh-security-review findings - AUTH-L1 (confirmed medium): logout() now clears the react-query cache — the singleton cache survived SPA logout, serving the previous principal's cached GETs to the next login in the same tab for up to staleTime with no server round-trip. - AUTH-L3 (confirmed low): isTokenValid decodes base64url before atob — valid Cognito JWTs containing '-'/'_' in the payload segment were misclassified as expired (login lockout/loop; inherited from the old authSlice). - AUTH-L2 (unverified, hardened anyway): 401 interceptor broadcasts AUTH_SESSION_CLEARED_EVENT so AuthProvider drops in-memory state synchronously, restoring the old Redux atomic-clear semantics. - INJ-1 (unverified, hardened anyway): Authorization header only set when the stored token is a string. Each fix pinned by a test; 63 vitest green, tsc clean. * docs: web stack row reflects auth-context refactor (Redux removed, MUI v9) * ci(web): Phase 5 — prettier check + Playwright smoke via org frontend workflow Converts the web CI job from ci-typescript-cdk.yaml (typecheck only) to ci-typescript-frontend.yaml: format:check, build (tsc -b included), vitest, and a Playwright chromium smoke. Folds the standalone Web Tests job into it (aggregator needs updated). Pure CI — no AWS secrets. The smoke (e2e/smoke.spec.ts) drives dev-login → dashboard shell → proposal list, plus the unauthenticated bounce, against a fully mocked API (pathname-anchored route interception — a '**/api/**' glob would swallow vite's /src/lib/api/* module URLs). Config mirrors SHOC's playwright.config.ts (port 4173, chromium, dev-server webServer). Prettier: singleQuote + printWidth 100 to match the existing codebase style; lint intentionally not added (no ESLint config yet — run-lint false, out of Phase 5 scope). rollback = revert this workflow file. * style(web): prettier format pass (mechanical) npx prettier --write . with the new .prettierrc (singleQuote, printWidth 100). No functional changes — enforced by format:check in CI from this PR on. --- .github/workflows/ci.yaml | 28 +-- .gitignore | 4 + README.md | 5 +- web/.claude/skills/verify/SKILL.md | 8 +- web/.prettierignore | 6 + web/.prettierrc | 4 + web/e2e/smoke.spec.ts | 113 +++++++++ web/index.html | 2 +- web/package-lock.json | 81 ++++++ web/package.json | 7 +- web/playwright.config.ts | 27 ++ web/src/App.tsx | 132 +++++++--- web/src/components/ErrorBoundary.tsx | 12 +- web/src/components/ProtectedRoute.tsx | 4 +- web/src/components/Sidebar.tsx | 17 +- web/src/components/Topbar.tsx | 31 ++- .../__tests__/ProtectedRoute.test.tsx | 16 +- web/src/components/admin/LineItemEditor.tsx | 44 +++- .../admin/SimilarProposalsPanel.tsx | 14 +- web/src/components/admin/VendorDataPanel.tsx | 20 +- web/src/constants/index.ts | 19 +- .../domain/__tests__/admin.use-cases.test.tsx | 4 +- .../__tests__/customers.use-cases.test.tsx | 6 +- .../__tests__/proposals.use-cases.test.tsx | 13 +- web/src/domain/__tests__/schemas.test.ts | 46 ++-- web/src/domain/admin/types.ts | 6 +- web/src/domain/customers/schemas.ts | 20 +- web/src/domain/pricingLibrary/api.ts | 5 +- web/src/domain/pricingLibrary/schemas.ts | 2 +- web/src/domain/proposals/api.ts | 6 +- web/src/domain/proposals/use-cases.ts | 9 +- web/src/hooks/useAuth.ts | 12 +- web/src/hooks/useDocumentTitle.ts | 4 +- web/src/index.css | 4 +- web/src/lib/api/__tests__/client.test.ts | 22 +- web/src/lib/api/client.ts | 4 +- .../lib/auth/__tests__/authStorage.test.ts | 14 +- web/src/lib/format.ts | 10 +- web/src/lib/theme/mui-theme.ts | 18 +- web/src/main.tsx | 2 +- .../customers/CustomerManagementPage.tsx | 34 ++- .../pages/admin/dashboard/AdminDashboard.tsx | 147 +++++++++-- .../pricing-library/PricingLibraryPage.tsx | 44 ++-- .../pages/admin/workspace/AdminWorkspace.tsx | 236 +++++++++++++----- web/src/pages/auth/LoginPage.tsx | 12 +- web/src/pages/dashboard/Dashboard.tsx | 103 ++++++-- .../proposals/detail/ProposalDetailPage.tsx | 11 +- .../pages/proposals/form/ProposalFormPage.tsx | 67 ++--- .../pages/proposals/list/ProposalListPage.tsx | 26 +- .../providers/__tests__/AuthProvider.test.tsx | 10 +- web/src/styles/fonts.css | 18 +- web/src/styles/globals.css | 8 +- web/src/styles/theme.css | 6 +- web/src/styles/typography.css | 37 ++- 54 files changed, 1165 insertions(+), 395 deletions(-) create mode 100644 web/.prettierignore create mode 100644 web/.prettierrc create mode 100644 web/e2e/smoke.spec.ts create mode 100644 web/playwright.config.ts diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index f698695..4378a48 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -27,30 +27,16 @@ jobs: web: name: Web Frontend Check - uses: Sea-Haven-Industries/.github/.github/workflows/ci-typescript-cdk.yaml@main + # Phase 5: full frontend pipeline — format:check, build (tsc -b included), + # vitest, Playwright chromium smoke. Replaces the old typecheck-only job + # plus the standalone Web Tests job. No lint script yet (run-lint: false). + uses: Sea-Haven-Industries/.github/.github/workflows/ci-typescript-frontend.yaml@main with: working-directory: web cache-dependency-path: web/package-lock.json node-version: "24" - run-cdk-synth: false - run-conventions-check: false - - web-test: - name: Web Tests - runs-on: ubuntu-latest - timeout-minutes: 10 - defaults: - run: - working-directory: web - steps: - - uses: actions/checkout@v7 - - uses: actions/setup-node@v4 - with: - node-version: "24" - cache: npm - cache-dependency-path: web/package-lock.json - - run: npm ci - - run: npm test + run-lint: false + required-scripts: "format:check,build,test,test:e2e" python: name: Python Lint @@ -106,7 +92,7 @@ jobs: # required check would otherwise never report on this repo. ci: name: ci / ci - needs: [dotnet, web, web-test, python, python-test, mobile, infra] + needs: [dotnet, web, python, python-test, mobile, infra] if: always() runs-on: ubuntu-latest steps: diff --git a/.gitignore b/.gitignore index 88de624..58e6fa3 100644 --- a/.gitignore +++ b/.gitignore @@ -69,3 +69,7 @@ api/src/ProposalSystem.Api/Data/verified-sites.json *.zip .claude/worktrees/ .claude/agents/ + +# Playwright (web e2e smoke) +web/test-results/ +web/playwright-report/ diff --git a/README.md b/README.md index 8acc442..c573544 100644 --- a/README.md +++ b/README.md @@ -114,13 +114,12 @@ npx cdk synth ### CI (on pull request to main) -Seven parallel jobs calling org reusable workflows: +Six parallel jobs calling org reusable workflows: | Job | Workflow | What it checks | |---|---|---| | .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 | inline job | vitest suite (26 tests — auth, interceptors, components) | +| Web Frontend Check | `ci-typescript-frontend.yaml` | Prettier `format:check`, build (includes `tsc -b`), vitest suite, Playwright chromium smoke (dev-login → proposal list, API mocked) | | Mobile Typecheck | `ci-typescript-cdk.yaml` | TypeScript typecheck for mobile | | Python Lint | `ci-python-sam.yaml` | ruff check + format on lambdas/ | | Python Tests | inline job | pytest suite (37 tests — pdf-generate, suggestions, library-ingest, internal API signing) | diff --git a/web/.claude/skills/verify/SKILL.md b/web/.claude/skills/verify/SKILL.md index 589ce63..d9e9154 100644 --- a/web/.claude/skills/verify/SKILL.md +++ b/web/.claude/skills/verify/SKILL.md @@ -15,9 +15,12 @@ Drive with Playwright resolved from the SHOC checkout (this repo doesn't ship it using the cached headless chromium: ```js -const { chromium } = require('/Users/adammoussa/Documents/repositories/seahaven/shoc-frontend-new/node_modules/playwright'); +const { + chromium, +} = require('/Users/adammoussa/Documents/repositories/seahaven/shoc-frontend-new/node_modules/playwright'); const browser = await chromium.launch({ - executablePath: '/Users/adammoussa/Library/Caches/ms-playwright/chromium_headless_shell-1228/chrome-headless-shell-mac-arm64/chrome-headless-shell', + executablePath: + '/Users/adammoussa/Library/Caches/ms-playwright/chromium_headless_shell-1228/chrome-headless-shell-mac-arm64/chrome-headless-shell', }); ``` @@ -29,6 +32,7 @@ await ctx.addInitScript((v) => sessionStorage.setItem('proposal_system_token', v ``` Gotchas: + - `addInitScript` re-runs on every document load — a full-page reload re-seeds the token, so "logged-out then reload" probes need a fresh un-seeded context. - Dev-login buttons on /login call the real API (`/auth/dev-login`) — they fail diff --git a/web/.prettierignore b/web/.prettierignore new file mode 100644 index 0000000..7c08b73 --- /dev/null +++ b/web/.prettierignore @@ -0,0 +1,6 @@ +dist +coverage +node_modules +package-lock.json +playwright-report +test-results diff --git a/web/.prettierrc b/web/.prettierrc new file mode 100644 index 0000000..554f2a3 --- /dev/null +++ b/web/.prettierrc @@ -0,0 +1,4 @@ +{ + "singleQuote": true, + "printWidth": 100 +} diff --git a/web/e2e/smoke.spec.ts b/web/e2e/smoke.spec.ts new file mode 100644 index 0000000..7fa5f1d --- /dev/null +++ b/web/e2e/smoke.spec.ts @@ -0,0 +1,113 @@ +// Browser smoke: dev-login → dashboard shell → proposal list (Phase 5, web CI). +// The API is fully mocked via route interception so the smoke runs in pure CI +// with no backend — it exercises the login flow, auth context, protected +// routing, the app shell, and list rendering end-to-end in a real browser. +import { test, expect, type Page } from '@playwright/test'; + +function fakeJwt(): string { + const b64 = (o: object) => + Buffer.from(JSON.stringify(o)) + .toString('base64') + .replace(/\+/g, '-') + .replace(/\//g, '_') + .replace(/=+$/, ''); + return `${b64({ alg: 'HS256' })}.${b64({ + sub: 'e2e-user', + exp: Math.floor(Date.now() / 1000) + 3600, + })}.e2e-signature`; +} + +const authUser = { + id: 'e2e-user', + email: 'adam@seahavenind.com', + displayName: 'Adam Moussa', + role: 'SysAdmin', + token: fakeJwt(), +}; + +const proposals = [ + { + id: 'p-1', + proposalNumber: 'PROP-2026-001', + customerName: 'Harbor Point Marina', + workOrderNumber: 'WO-1001', + serviceCategory: 'HVAC', + priority: 'Standard', + status: 'InReview', + totalBidAmount: 12500, + submittedAt: '2026-07-01T12:00:00Z', + submittedByName: 'Adam Moussa', + assignedAdminName: null, + }, + { + id: 'p-2', + proposalNumber: 'PROP-2026-002', + customerName: 'Seaside Property Group', + workOrderNumber: 'WO-1002', + serviceCategory: 'Electrical', + priority: 'Urgent', + status: 'Approved', + totalBidAmount: 4300, + submittedAt: '2026-07-05T09:30:00Z', + submittedByName: 'Adam Moussa', + assignedAdminName: 'Sarah Chen', + }, +]; + +async function mockApi(page: Page) { + // Match on pathname, not a '**/api/**' glob: vite dev serves app modules from + // paths like /src/lib/api/client.ts, which such a glob would swallow. + // Registered first = matched last (Playwright checks routes newest-first): + // generic fallback so nothing the shell fetches hangs or hard-errors. + await page.route( + (url) => url.pathname.startsWith('/api/'), + (route) => route.fulfill({ json: {} }), + ); + + await page.route( + (url) => url.pathname === '/api/auth/dev-login', + (route) => route.fulfill({ json: authUser }), + ); + await page.route( + (url) => url.pathname === '/api/proposals/stats', + (route) => + route.fulfill({ json: { totalCount: 2, inReviewCount: 1, approvedCount: 1, sentCount: 0 } }), + ); + await page.route( + (url) => url.pathname === '/api/proposals' && url.searchParams.has('page'), + (route) => + route.fulfill({ + json: { items: proposals, totalCount: proposals.length, page: 1, pageSize: 12 }, + }), + ); +} + +test('dev-login lands on the dashboard and the proposal list renders', async ({ page }) => { + await mockApi(page); + + await page.goto('/'); + await expect(page).toHaveURL(/\/login$/); + await expect(page.getByText('Welcome back')).toBeVisible(); + + await page.getByRole('button', { name: /SysAdmin/ }).click(); + + // Authenticated shell: topbar user chip + dashboard content. + await expect(page).toHaveURL('/'); + await expect(page.getByText('Adam Moussa').first()).toBeVisible(); + await expect(page.getByText('Recent Proposals')).toBeVisible(); + + // Proposal list via sidebar nav. + await page.getByText('My Proposals', { exact: true }).click(); + await expect(page).toHaveURL(/\/proposals$/); + await expect(page.getByRole('heading', { name: 'My Proposals' })).toBeVisible(); + await expect(page.getByText('Harbor Point Marina')).toBeVisible(); + await expect(page.getByText('Seaside Property Group')).toBeVisible(); +}); + +test('unauthenticated visits are bounced to the login card', async ({ page }) => { + await mockApi(page); + + await page.goto('/proposals'); + await expect(page).toHaveURL(/\/login$/); + await expect(page.getByText('Welcome back')).toBeVisible(); +}); diff --git a/web/index.html b/web/index.html index 7b7a1a9..1d7892e 100644 --- a/web/index.html +++ b/web/index.html @@ -1,4 +1,4 @@ - + diff --git a/web/package-lock.json b/web/package-lock.json index 14003a6..b6fe602 100644 --- a/web/package-lock.json +++ b/web/package-lock.json @@ -28,6 +28,7 @@ "zod": "^4.4.3" }, "devDependencies": { + "@playwright/test": "^1.61.1", "@testing-library/jest-dom": "^6.9.1", "@testing-library/react": "^16.3.2", "@testing-library/user-event": "^14.6.1", @@ -35,6 +36,7 @@ "@types/react-dom": "^19.0.0", "@vitejs/plugin-react": "^6.0.3", "jsdom": "^29.1.1", + "prettier": "^3.9.5", "typescript": "~7.0.2", "vite": "^8.1.3", "vitest": "^4.1.10" @@ -934,6 +936,22 @@ "url": "https://github.com/sponsors/Boshen" } }, + "node_modules/@playwright/test": { + "version": "1.61.1", + "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.61.1.tgz", + "integrity": "sha512-8nKv6+0RJSL9FE4jYOEGXnPeM/Hg12qZpmqzZjRh3qM0Y7c3z1mrOTfFLids72RDQYVh9WpLEfR5WdpNX4fkig==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright": "1.61.1" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/@popperjs/core": { "version": "2.11.8", "resolved": "https://registry.npmjs.org/@popperjs/core/-/core-2.11.8.tgz", @@ -3170,6 +3188,53 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, + "node_modules/playwright": { + "version": "1.61.1", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.61.1.tgz", + "integrity": "sha512-DWnY5o3YbLWK4GovuAVwpqL+1VwGNdUGrRr++8j8PtQQzvAVZUIMjKQ90fY689sEJZJBbZVw1rXaOKSTitkzPQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright-core": "1.61.1" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "fsevents": "2.3.2" + } + }, + "node_modules/playwright-core": { + "version": "1.61.1", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.61.1.tgz", + "integrity": "sha512-h7Qlt6m4REp25qvIdvbDtVmD4LqVXfpRxhORv9L0jzETM05p4fuPJ3dKyuSXQxDSbXnmS79HAgi9589lGSpLkg==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "playwright-core": "cli.js" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/playwright/node_modules/fsevents": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz", + "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, "node_modules/postcss": { "version": "8.5.16", "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.16.tgz", @@ -3199,6 +3264,22 @@ "node": "^10 || ^12 || >=14" } }, + "node_modules/prettier": { + "version": "3.9.5", + "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.9.5.tgz", + "integrity": "sha512-/FVl766LpUfB5vXgCYOYa0MeV/441Ia99AeICQIQFTY/Nw0roZwULcXpku5i1/m5kt/baz+s4Zogspd839HSMg==", + "dev": true, + "license": "MIT", + "bin": { + "prettier": "bin/prettier.cjs" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/prettier/prettier?sponsor=1" + } + }, "node_modules/pretty-format": { "version": "27.5.1", "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-27.5.1.tgz", diff --git a/web/package.json b/web/package.json index 0797d84..d40ade9 100644 --- a/web/package.json +++ b/web/package.json @@ -8,8 +8,11 @@ "dev": "vite", "build": "tsc -b && vite build", "preview": "vite preview", + "format": "prettier --write .", + "format:check": "prettier --check .", "test": "vitest run", - "test:watch": "vitest" + "test:watch": "vitest", + "test:e2e": "playwright test" }, "dependencies": { "@emotion/react": "^11.14.0", @@ -32,6 +35,7 @@ "zod": "^4.4.3" }, "devDependencies": { + "@playwright/test": "^1.61.1", "@testing-library/jest-dom": "^6.9.1", "@testing-library/react": "^16.3.2", "@testing-library/user-event": "^14.6.1", @@ -39,6 +43,7 @@ "@types/react-dom": "^19.0.0", "@vitejs/plugin-react": "^6.0.3", "jsdom": "^29.1.1", + "prettier": "^3.9.5", "typescript": "~7.0.2", "vite": "^8.1.3", "vitest": "^4.1.10" diff --git a/web/playwright.config.ts b/web/playwright.config.ts new file mode 100644 index 0000000..1737064 --- /dev/null +++ b/web/playwright.config.ts @@ -0,0 +1,27 @@ +// Browser smoke config — mirrors shoc-frontend-new/playwright.config.ts. +import { defineConfig, devices } from '@playwright/test'; + +export default defineConfig({ + testDir: './e2e', + fullyParallel: true, + forbidOnly: Boolean(process.env.CI), + retries: process.env.CI ? 2 : 0, + workers: process.env.CI ? 1 : undefined, + reporter: process.env.CI ? 'github' : 'list', + use: { + baseURL: 'http://localhost:4173', + trace: 'on-first-retry', + }, + projects: [ + { + name: 'chromium', + use: { ...devices['Desktop Chrome'] }, + }, + ], + webServer: { + command: 'npm run dev -- --port 4173 --strictPort', + url: 'http://localhost:4173', + reuseExistingServer: false, + timeout: 120_000, + }, +}); diff --git a/web/src/App.tsx b/web/src/App.tsx index 30d0119..407403f 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -38,9 +38,26 @@ export default function App() { element={ {/* SHOC shell composition (ADR 0003): sidebar column + [topbar / scrolling main] */} - + - + setSidebarOpen(!sidebarOpen)} /> - } /> - } /> - } /> - } /> + } /> + } /> + } /> + } /> - {/* Admin Routes */} - } /> - } /> - } /> - } /> - } /> - - - - - - User Management - - Users and roles are currently managed in AWS Cognito. Contact the system administrator to update access. - - - - - - - } /> + {/* Admin Routes */} + + + + } + /> + + + + } + /> + + + + } + /> + + + + } + /> + + + + } + /> + + + + + + + User Management + + + Users and roles are currently managed in AWS Cognito. Contact the + system administrator to update access. + + + + + + + } + /> } /> diff --git a/web/src/components/ErrorBoundary.tsx b/web/src/components/ErrorBoundary.tsx index c5b674a..c351040 100644 --- a/web/src/components/ErrorBoundary.tsx +++ b/web/src/components/ErrorBoundary.tsx @@ -24,7 +24,17 @@ export default class ErrorBoundary extends React.Component { render() { if (this.state.hasError) { return ( - + Something went wrong An unexpected error occurred. Please try refreshing the page. diff --git a/web/src/components/ProtectedRoute.tsx b/web/src/components/ProtectedRoute.tsx index 16656bf..20625f2 100644 --- a/web/src/components/ProtectedRoute.tsx +++ b/web/src/components/ProtectedRoute.tsx @@ -7,7 +7,9 @@ export default function ProtectedRoute({ children }: { children: React.ReactNode if (isLoading) { return ( - + ); diff --git a/web/src/components/Sidebar.tsx b/web/src/components/Sidebar.tsx index 00808f8..c65331d 100644 --- a/web/src/components/Sidebar.tsx +++ b/web/src/components/Sidebar.tsx @@ -35,13 +35,9 @@ const adminNav: NavEntry[] = [ { label: 'Pricing Library', path: '/admin/pricing-library', icon: BookOpen }, ]; -const sysadminNav: NavEntry[] = [ - { label: 'Users', path: '/admin/users', icon: Users }, -]; +const sysadminNav: NavEntry[] = [{ label: 'Users', path: '/admin/users', icon: Users }]; -const ALL_NAV_PATHS = new Set( - [...dispatcherNav, ...adminNav, ...sysadminNav].map(n => n.path) -); +const ALL_NAV_PATHS = new Set([...dispatcherNav, ...adminNav, ...sysadminNav].map((n) => n.path)); function isNavActive(pathname: string, itemPath: string): boolean { if (pathname === itemPath) return true; @@ -115,7 +111,10 @@ function NavItem({ )} {!collapsed && ( - + {item.label} )} @@ -168,7 +167,9 @@ export default function Sidebar({ ); const groupDivider = ( - + ); const renderGroup = (label: string, items: NavEntry[], first: boolean) => ( diff --git a/web/src/components/Topbar.tsx b/web/src/components/Topbar.tsx index 3db385b..b8f48ad 100644 --- a/web/src/components/Topbar.tsx +++ b/web/src/components/Topbar.tsx @@ -7,7 +7,12 @@ export default function Topbar({ onToggleSidebar }: { onToggleSidebar: () => voi const { user, logout } = useAuth(); const initials = user?.displayName - ? user.displayName.split(' ').map(n => n[0]).join('').toUpperCase().slice(0, 2) + ? user.displayName + .split(' ') + .map((n) => n[0]) + .join('') + .toUpperCase() + .slice(0, 2) : 'U'; const isDevMode = !import.meta.env.VITE_COGNITO_DOMAIN; @@ -24,7 +29,16 @@ export default function Topbar({ onToggleSidebar }: { onToggleSidebar: () => voi boxShadow: 'var(--color-header-shadow)', }} > - + voi {initials} - + {user.displayName} - + {user.role} @@ -101,7 +119,10 @@ export default function Topbar({ onToggleSidebar }: { onToggleSidebar: () => voi aria-label="Log out" sx={{ color: 'var(--color-header-text-muted)', - '&:hover': { color: 'var(--color-header-text)', backgroundColor: 'var(--color-header-surface-hover)' }, + '&:hover': { + color: 'var(--color-header-text)', + backgroundColor: 'var(--color-header-surface-hover)', + }, }} > diff --git a/web/src/components/__tests__/ProtectedRoute.test.tsx b/web/src/components/__tests__/ProtectedRoute.test.tsx index ef9ab4d..0ab7a2e 100644 --- a/web/src/components/__tests__/ProtectedRoute.test.tsx +++ b/web/src/components/__tests__/ProtectedRoute.test.tsx @@ -87,7 +87,13 @@ describe('RoleGuard', () => { it('QA-C5: redirects to / when user role is not in allowed list (dispatcher cannot access admin)', () => { mockUseAuth.mockReturnValue({ isAuthenticated: true, - user: { id: '2', email: 'dispatch@test.com', displayName: 'Dispatcher', role: 'Dispatcher', token: 'tok' }, + user: { + id: '2', + email: 'dispatch@test.com', + displayName: 'Dispatcher', + role: 'Dispatcher', + token: 'tok', + }, isLoading: false, }); @@ -121,7 +127,13 @@ describe('RoleGuard', () => { it('QA-C5: SysAdmin can access sysadmin-only routes', () => { mockUseAuth.mockReturnValue({ isAuthenticated: true, - user: { id: '3', email: 'sysadmin@test.com', displayName: 'SysAdmin', role: 'SysAdmin', token: 'tok' }, + user: { + id: '3', + email: 'sysadmin@test.com', + displayName: 'SysAdmin', + role: 'SysAdmin', + token: 'tok', + }, isLoading: false, }); diff --git a/web/src/components/admin/LineItemEditor.tsx b/web/src/components/admin/LineItemEditor.tsx index 0f7b7fb..34bbc30 100644 --- a/web/src/components/admin/LineItemEditor.tsx +++ b/web/src/components/admin/LineItemEditor.tsx @@ -125,11 +125,14 @@ export default function LineItemEditor({ items, onChange, disabled = false }: Li return ( - - Line Items ({items.length}) - + Line Items ({items.length}) {!disabled && ( - )} @@ -155,11 +158,21 @@ export default function LineItemEditor({ items, onChange, disabled = false }: Li {!disabled && ( - moveItem(index, -1)} disabled={index === 0} aria-label={`Move item ${index + 1} up`}> + moveItem(index, -1)} + disabled={index === 0} + aria-label={`Move item ${index + 1} up`} + > {index + 1} - moveItem(index, 1)} disabled={index === items.length - 1} aria-label={`Move item ${index + 1} down`}> + moveItem(index, 1)} + disabled={index === items.length - 1} + aria-label={`Move item ${index + 1} down`} + > @@ -200,7 +213,9 @@ export default function LineItemEditor({ items, onChange, disabled = false }: Li fullWidth > {UNITS.map((u) => ( - {u} + + {u} + ))} @@ -209,7 +224,9 @@ export default function LineItemEditor({ items, onChange, disabled = false }: Li size="small" type="number" value={item.unitPrice ?? ''} - onChange={(e) => updateItem(index, 'unitPrice', parseFloat(e.target.value) || 0)} + onChange={(e) => + updateItem(index, 'unitPrice', parseFloat(e.target.value) || 0) + } disabled={disabled || item.pricingMode === 'TotalPrice'} variant="standard" slotProps={{ htmlInput: { min: 0, step: 0.01 } }} @@ -220,7 +237,9 @@ export default function LineItemEditor({ items, onChange, disabled = false }: Li size="small" type="number" value={item.totalPrice} - onChange={(e) => updateItem(index, 'totalPrice', parseFloat(e.target.value) || 0)} + onChange={(e) => + updateItem(index, 'totalPrice', parseFloat(e.target.value) || 0) + } disabled={disabled || item.pricingMode === 'UnitPrice'} variant="standard" slotProps={{ htmlInput: { min: 0, step: 0.01 } }} @@ -272,7 +291,12 @@ export default function LineItemEditor({ items, onChange, disabled = false }: Li - Proposal Total: {subtotal === 0 ? 'Pending pricing' : new Intl.NumberFormat('en-US', { style: 'currency', currency: 'USD' }).format(subtotal)} + Proposal Total:{' '} + {subtotal === 0 + ? 'Pending pricing' + : new Intl.NumberFormat('en-US', { style: 'currency', currency: 'USD' }).format( + subtotal, + )} diff --git a/web/src/components/admin/SimilarProposalsPanel.tsx b/web/src/components/admin/SimilarProposalsPanel.tsx index fec0149..759bee3 100644 --- a/web/src/components/admin/SimilarProposalsPanel.tsx +++ b/web/src/components/admin/SimilarProposalsPanel.tsx @@ -20,13 +20,19 @@ interface SimilarProposalsPanelProps { disabled?: boolean; } -export default function SimilarProposalsPanel({ proposalId, onPullLineItem, disabled }: SimilarProposalsPanelProps) { +export default function SimilarProposalsPanel({ + proposalId, + onPullLineItem, + disabled, +}: SimilarProposalsPanelProps) { const { data: similar, isLoading } = useSimilarProposals(proposalId); if (isLoading) { return ( - Similar Proposals + + Similar Proposals + {Array.from({ length: 3 }).map((_, i) => ( ))} @@ -74,7 +80,9 @@ export default function SimilarProposalsPanel({ proposalId, onPullLineItem, disa }} > - {li.description} + + {li.description} + {li.quantity} {li.unit} @ {formatCurrency(li.totalPrice)} diff --git a/web/src/components/admin/VendorDataPanel.tsx b/web/src/components/admin/VendorDataPanel.tsx index a3e956d..345435b 100644 --- a/web/src/components/admin/VendorDataPanel.tsx +++ b/web/src/components/admin/VendorDataPanel.tsx @@ -16,19 +16,27 @@ export default function VendorDataPanel({ proposalId }: VendorDataPanelProps) { if (!vendors || vendors.length === 0) { return ( - Vendor Data - No vendor proposals attached + + Vendor Data + + + No vendor proposals attached + ); } return ( - Vendor Data + + Vendor Data + {vendors.map((vp) => ( - {vp.vendorName} + + {vp.vendorName} + - {vp.fileName} + + {vp.fileName} + {vp.totalVendorCost > 0 && ( diff --git a/web/src/constants/index.ts b/web/src/constants/index.ts index aed79d1..7eb4b33 100644 --- a/web/src/constants/index.ts +++ b/web/src/constants/index.ts @@ -18,7 +18,14 @@ export const STORAGE_KEY_TOKEN = 'proposal_system_token'; export const STORAGE_KEY_SIDEBAR = 'sidebarOpen'; export const PROPOSAL_STATUSES = ['Draft', 'InReview', 'Approved', 'Sent', 'Revised'] as const; -export const SERVICE_CATEGORIES = ['HVAC', 'Plumbing', 'Electrical', 'General', 'Renovation', 'Other'] as const; +export const SERVICE_CATEGORIES = [ + 'HVAC', + 'Plumbing', + 'Electrical', + 'General', + 'Renovation', + 'Other', +] as const; export const PRIORITIES = ['Standard', 'Urgent', 'Emergency'] as const; export const STATUS_LABELS: Record = { @@ -30,7 +37,10 @@ export const STATUS_LABELS: Record = { }; // Sea Haven Ops status chips — flat fill + thin border + bold label (tokens/base.css). -export const STATUS_CHIP_STYLES: Record = { +export const STATUS_CHIP_STYLES: Record< + string, + { bgcolor: string; color: string; border: string } +> = { Draft: { bgcolor: '#F3F4F6', color: '#374151', border: '1px solid #D1D5DB' }, InReview: { bgcolor: '#E0F2FE', color: '#075985', border: '1px solid #7DD3FC' }, Approved: { bgcolor: '#DCFCE7', color: '#166534', border: '1px solid #86EFAC' }, @@ -47,7 +57,10 @@ export const PRIORITY_LABELS: Record = { Emergency: 'Emergency', }; -export const PRIORITY_CHIP_STYLES: Record = { +export const PRIORITY_CHIP_STYLES: Record< + string, + { bgcolor: string; color: string; border: string } +> = { Standard: { bgcolor: '#F3F4F6', color: '#374151', border: '1px solid #D1D5DB' }, Urgent: { bgcolor: '#FFEDD5', color: '#9A3412', border: '1px solid #FED7AA' }, Emergency: { bgcolor: '#FEE2E2', color: '#991B1B', border: '1px solid #FECACA' }, diff --git a/web/src/domain/__tests__/admin.use-cases.test.tsx b/web/src/domain/__tests__/admin.use-cases.test.tsx index fc734f6..50a7ab3 100644 --- a/web/src/domain/__tests__/admin.use-cases.test.tsx +++ b/web/src/domain/__tests__/admin.use-cases.test.tsx @@ -82,9 +82,7 @@ describe('useApproveProposal', () => { }); it('toasts the failure and skips invalidation when approval is rejected', async () => { - vi.mocked(adminApi.approveProposal).mockRejectedValue( - new Error('proposal is not in review') - ); + vi.mocked(adminApi.approveProposal).mockRejectedValue(new Error('proposal is not in review')); const { queryClient, wrapper } = createQueryHarness(); const invalidateSpy = vi.spyOn(queryClient, 'invalidateQueries'); diff --git a/web/src/domain/__tests__/customers.use-cases.test.tsx b/web/src/domain/__tests__/customers.use-cases.test.tsx index 059be69..63bf9c7 100644 --- a/web/src/domain/__tests__/customers.use-cases.test.tsx +++ b/web/src/domain/__tests__/customers.use-cases.test.tsx @@ -6,11 +6,7 @@ import { describe, it, expect, vi, beforeEach } from 'vitest'; import { renderHook, waitFor } from '@testing-library/react'; import { toast } from 'react-toastify'; import { createQueryHarness } from './hookTestUtils'; -import { - customersKeys, - useCreateCustomer, - useUpdateCustomer, -} from '../customers/use-cases'; +import { customersKeys, useCreateCustomer, useUpdateCustomer } from '../customers/use-cases'; import { customersApi } from '../customers/api'; import type { Customer } from '../customers/types'; diff --git a/web/src/domain/__tests__/proposals.use-cases.test.tsx b/web/src/domain/__tests__/proposals.use-cases.test.tsx index cc57edf..69699ae 100644 --- a/web/src/domain/__tests__/proposals.use-cases.test.tsx +++ b/web/src/domain/__tests__/proposals.use-cases.test.tsx @@ -5,13 +5,14 @@ import { describe, it, expect, vi, beforeEach } from 'vitest'; import { renderHook, waitFor } from '@testing-library/react'; import { toast } from 'react-toastify'; import { createQueryHarness } from './hookTestUtils'; -import { - proposalsKeys, - useProposals, - useCreateProposal, -} from '../proposals/use-cases'; +import { proposalsKeys, useProposals, useCreateProposal } from '../proposals/use-cases'; import { proposalsApi } from '../proposals/api'; -import type { ProposalDetail, ProposalFilters, ProposalListItem, PagedResponse } from '../proposals/types'; +import type { + ProposalDetail, + ProposalFilters, + ProposalListItem, + PagedResponse, +} from '../proposals/types'; vi.mock('../proposals/api', () => ({ proposalsApi: { diff --git a/web/src/domain/__tests__/schemas.test.ts b/web/src/domain/__tests__/schemas.test.ts index 6497fe1..ca2045f 100644 --- a/web/src/domain/__tests__/schemas.test.ts +++ b/web/src/domain/__tests__/schemas.test.ts @@ -40,15 +40,15 @@ describe('createProposalFormSchema', () => { }); it('requires customCategory when serviceCategory is Other', () => { - expect( - createProposalFormSchema.safeParse({ ...valid, serviceCategory: 'Other' }).success - ).toBe(false); + expect(createProposalFormSchema.safeParse({ ...valid, serviceCategory: 'Other' }).success).toBe( + false, + ); expect( createProposalFormSchema.safeParse({ ...valid, serviceCategory: 'Other', customCategory: 'Roofing', - }).success + }).success, ).toBe(true); }); @@ -74,19 +74,21 @@ describe('customerFormSchema', () => { it('rejects a malformed email but accepts a valid one', () => { expect(customerFormSchema.safeParse({ ...valid, contactEmail: 'nope' }).success).toBe(false); - expect( - customerFormSchema.safeParse({ ...valid, contactEmail: 'a@b.com' }).success - ).toBe(true); + expect(customerFormSchema.safeParse({ ...valid, contactEmail: 'a@b.com' }).success).toBe(true); }); it('maps to addresses[] preserving extra existing addresses', () => { - const request = toCustomerRequest( - { name: ' Acme ', address: ' 2 New St ', contactEmail: '' }, - ['1 Old St', '3 Extra Ave'] - ); + const request = toCustomerRequest({ name: ' Acme ', address: ' 2 New St ', contactEmail: '' }, [ + '1 Old St', + '3 Extra Ave', + ]); // contactEmail is ALWAYS sent — empty string clears the stored email // (code-review finding, Phase 4: omission made emails impossible to clear). - expect(request).toEqual({ name: 'Acme', addresses: ['2 New St', '3 Extra Ave'], contactEmail: '' }); + expect(request).toEqual({ + name: 'Acme', + addresses: ['2 New St', '3 Extra Ave'], + contactEmail: '', + }); }); }); @@ -102,17 +104,21 @@ describe('pricingLibraryItemFormSchema', () => { it('accepts empty unitPrice and rejects negatives/non-numbers', () => { expect(pricingLibraryItemFormSchema.safeParse(valid).success).toBe(true); - expect( - pricingLibraryItemFormSchema.safeParse({ ...valid, unitPrice: '-4' }).success - ).toBe(false); - expect( - pricingLibraryItemFormSchema.safeParse({ ...valid, unitPrice: 'abc' }).success - ).toBe(false); + expect(pricingLibraryItemFormSchema.safeParse({ ...valid, unitPrice: '-4' }).success).toBe( + false, + ); + expect(pricingLibraryItemFormSchema.safeParse({ ...valid, unitPrice: 'abc' }).success).toBe( + false, + ); }); it('maps empty unitPrice to null and drops empty optional strings', () => { const request = toPricingLibraryItemRequest(valid); - expect(request).toEqual({ title: 'Condenser coil cleaning', serviceCategory: 'HVAC', unitPrice: null }); + expect(request).toEqual({ + title: 'Condenser coil cleaning', + serviceCategory: 'HVAC', + unitPrice: null, + }); const priced = toPricingLibraryItemRequest({ ...valid, unitPrice: '12.50', unit: 'each' }); expect(priced.unitPrice).toBe(12.5); expect(priced.unit).toBe('each'); @@ -131,7 +137,7 @@ describe('shared numeric form fields', () => { unitPrice: '12abc', keywords: '', description: '', - }).success + }).success, ).toBe(false); }); }); diff --git a/web/src/domain/admin/types.ts b/web/src/domain/admin/types.ts index 5fa57ae..7637d74 100644 --- a/web/src/domain/admin/types.ts +++ b/web/src/domain/admin/types.ts @@ -1,5 +1,9 @@ // Admin domain — contract type re-exports + view-only types. -export type { DashboardStats, UpdateProposalRequest, AuditEntry } from '@proposal-system/api-contracts'; +export type { + DashboardStats, + UpdateProposalRequest, + AuditEntry, +} from '@proposal-system/api-contracts'; /** View-only shape of GET /proposals/{id}/similar (RAG similarity results — * no contract DTO yet; previously declared inline in SimilarProposalsPanel). */ diff --git a/web/src/domain/customers/schemas.ts b/web/src/domain/customers/schemas.ts index e7c2ab0..4148b44 100644 --- a/web/src/domain/customers/schemas.ts +++ b/web/src/domain/customers/schemas.ts @@ -17,17 +17,13 @@ const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; * form edits a single primary address string (the wire type takes * addresses: string[]); toCustomerRequest() maps to the request shape. */ -export const customerFormSchema = createCustomerRequestSchema - .pick({ name: true }) - .extend({ - name: z.string().trim().min(1, 'Name is required'), - address: z.string().trim().min(1, 'Address is required'), - contactEmail: z - .string() - .refine((v) => v.trim() === '' || EMAIL_RE.test(v.trim()), { - message: 'Enter a valid email address', - }), - }); +export const customerFormSchema = createCustomerRequestSchema.pick({ name: true }).extend({ + name: z.string().trim().min(1, 'Name is required'), + address: z.string().trim().min(1, 'Address is required'), + contactEmail: z.string().refine((v) => v.trim() === '' || EMAIL_RE.test(v.trim()), { + message: 'Enter a valid email address', + }), +}); export type CustomerFormValues = z.infer; @@ -40,7 +36,7 @@ export type CustomerFormValues = z.infer; */ export function toCustomerRequest( values: CustomerFormValues, - existingAddresses: string[] = [] + existingAddresses: string[] = [], ): CreateCustomerRequest & UpdateCustomerRequest { return { name: values.name.trim(), diff --git a/web/src/domain/pricingLibrary/api.ts b/web/src/domain/pricingLibrary/api.ts index c9f7974..81dce19 100644 --- a/web/src/domain/pricingLibrary/api.ts +++ b/web/src/domain/pricingLibrary/api.ts @@ -25,7 +25,10 @@ export const pricingLibraryApi = { }, /** PUT /api/pricing-library/{id} — update an existing pricing library item. */ - update: async (id: string, data: UpdatePricingLibraryItemRequest): Promise => { + update: async ( + id: string, + data: UpdatePricingLibraryItemRequest, + ): Promise => { const res = await apiClient.put(`/pricing-library/${id}`, data); return res.data; }, diff --git a/web/src/domain/pricingLibrary/schemas.ts b/web/src/domain/pricingLibrary/schemas.ts index 5d0c306..3999c0d 100644 --- a/web/src/domain/pricingLibrary/schemas.ts +++ b/web/src/domain/pricingLibrary/schemas.ts @@ -36,7 +36,7 @@ export type PricingLibraryItemFormValues = z.infer => { + 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()}`); diff --git a/web/src/domain/proposals/use-cases.ts b/web/src/domain/proposals/use-cases.ts index 99c3db9..3f24f5e 100644 --- a/web/src/domain/proposals/use-cases.ts +++ b/web/src/domain/proposals/use-cases.ts @@ -63,21 +63,24 @@ export interface CreateProposalVariables { export function useCreateProposal() { const queryClient = useQueryClient(); return useMutation({ - mutationFn: async ({ request, vendorFile }: CreateProposalVariables): Promise => { + mutationFn: async ({ + request, + vendorFile, + }: CreateProposalVariables): Promise => { const proposal = await proposalsApi.create(request); if (vendorFile) { try { const { uploadUrl, vendorProposalId } = await proposalsApi.uploadAttachment( proposal.id, - vendorFile.name + vendorFile.name, ); // Throws on S3 rejection (non-2xx) so a failed upload is never confirmed. await proposalsApi.uploadToPresignedUrl(uploadUrl, vendorFile); await proposalsApi.confirmUpload(proposal.id, vendorProposalId); } catch { toast.warning( - `Proposal ${proposal.proposalNumber} was created, but vendor PDF upload failed. You can re-upload from the proposal detail page.` + `Proposal ${proposal.proposalNumber} was created, but vendor PDF upload failed. You can re-upload from the proposal detail page.`, ); } } diff --git a/web/src/hooks/useAuth.ts b/web/src/hooks/useAuth.ts index aecd0e4..12d0d6b 100644 --- a/web/src/hooks/useAuth.ts +++ b/web/src/hooks/useAuth.ts @@ -5,8 +5,16 @@ import { useAuthContext } from '../providers/authContext'; // Thin wrapper over the auth context (SHOC shape) that layers on the // router/toast side effects components expect from logout. export function useAuth() { - const { user, isAuthenticated, isLoading, error, login, logout: clearSession, setError, setLoading } = - useAuthContext(); + const { + user, + isAuthenticated, + isLoading, + error, + login, + logout: clearSession, + setError, + setLoading, + } = useAuthContext(); const navigate = useNavigate(); const logout = () => { diff --git a/web/src/hooks/useDocumentTitle.ts b/web/src/hooks/useDocumentTitle.ts index 6dcab8f..62f8f9b 100644 --- a/web/src/hooks/useDocumentTitle.ts +++ b/web/src/hooks/useDocumentTitle.ts @@ -5,6 +5,8 @@ const BASE_TITLE = 'Proposal System'; export function useDocumentTitle(title?: string) { useEffect(() => { document.title = title ? `${title} | ${BASE_TITLE}` : BASE_TITLE; - return () => { document.title = BASE_TITLE; }; + return () => { + document.title = BASE_TITLE; + }; }, [title]); } diff --git a/web/src/index.css b/web/src/index.css index 9acafa1..f1a4d61 100644 --- a/web/src/index.css +++ b/web/src/index.css @@ -3,7 +3,9 @@ box-sizing: border-box; } -html, body, #root { +html, +body, +#root { height: 100%; } diff --git a/web/src/lib/api/__tests__/client.test.ts b/web/src/lib/api/__tests__/client.test.ts index b2daf07..c2aa9b7 100644 --- a/web/src/lib/api/__tests__/client.test.ts +++ b/web/src/lib/api/__tests__/client.test.ts @@ -57,10 +57,18 @@ vi.mock('axios', () => { const sessionStorageData: Record = {}; vi.stubGlobal('sessionStorage', { getItem: vi.fn((key: string) => sessionStorageData[key] ?? null), - setItem: vi.fn((key: string, value: string) => { sessionStorageData[key] = value; }), - removeItem: vi.fn((key: string) => { delete sessionStorageData[key]; }), - clear: vi.fn(() => { Object.keys(sessionStorageData).forEach(k => delete sessionStorageData[k]); }), - get length() { return Object.keys(sessionStorageData).length; }, + setItem: vi.fn((key: string, value: string) => { + sessionStorageData[key] = value; + }), + removeItem: vi.fn((key: string) => { + delete sessionStorageData[key]; + }), + clear: vi.fn(() => { + Object.keys(sessionStorageData).forEach((k) => delete sessionStorageData[k]); + }), + get length() { + return Object.keys(sessionStorageData).length; + }, key: vi.fn((index: number) => Object.keys(sessionStorageData)[index] ?? null), }); @@ -80,7 +88,7 @@ describe('API client interceptors', () => { beforeEach(() => { vi.clearAllMocks(); - Object.keys(sessionStorageData).forEach(k => delete sessionStorageData[k]); + Object.keys(sessionStorageData).forEach((k) => delete sessionStorageData[k]); mockLocation.href = ''; }); @@ -250,7 +258,9 @@ describe('API client interceptors', () => { const promise = interceptors.responseRejected(error); - await expect(promise).rejects.toThrow('No response from server. Please check your connection.'); + await expect(promise).rejects.toThrow( + 'No response from server. Please check your connection.', + ); }); it('passes through non-axios errors unchanged', async () => { diff --git a/web/src/lib/api/client.ts b/web/src/lib/api/client.ts index 3e5ffcd..664cc31 100644 --- a/web/src/lib/api/client.ts +++ b/web/src/lib/api/client.ts @@ -25,7 +25,7 @@ apiClient.interceptors.request.use( } return config; }, - (error) => Promise.reject(error) + (error) => Promise.reject(error), ); apiClient.interceptors.response.use( @@ -60,7 +60,7 @@ apiClient.interceptors.response.use( } return Promise.reject(error); - } + }, ); export default apiClient; diff --git a/web/src/lib/auth/__tests__/authStorage.test.ts b/web/src/lib/auth/__tests__/authStorage.test.ts index cdaa2ca..4e31fd1 100644 --- a/web/src/lib/auth/__tests__/authStorage.test.ts +++ b/web/src/lib/auth/__tests__/authStorage.test.ts @@ -14,10 +14,12 @@ import { STORAGE_KEY_TOKEN } from '../../../constants'; function createToken(expOffsetSeconds: number): string { const header = btoa(JSON.stringify({ alg: 'HS256' })); - const payload = btoa(JSON.stringify({ - sub: 'test-user', - exp: Math.floor(Date.now() / 1000) + expOffsetSeconds, - })); + const payload = btoa( + JSON.stringify({ + sub: 'test-user', + exp: Math.floor(Date.now() / 1000) + expOffsetSeconds, + }), + ); return `${header}.${payload}.fake-signature`; } @@ -69,7 +71,9 @@ describe('authStorage', () => { it('isTokenValid accepts a base64url payload (JWT segments are base64url, not base64)', () => { // '>>>' encodes to 'Pj4-' in base64url — the '-' made the old atob() call throw. - const payload = btoa(JSON.stringify({ sub: '>>>>>>', exp: Math.floor(Date.now() / 1000) + 3600 })) + const payload = btoa( + JSON.stringify({ sub: '>>>>>>', exp: Math.floor(Date.now() / 1000) + 3600 }), + ) .replace(/\+/g, '-') .replace(/\//g, '_') .replace(/=+$/, ''); diff --git a/web/src/lib/format.ts b/web/src/lib/format.ts index 0d75c6b..66a8aeb 100644 --- a/web/src/lib/format.ts +++ b/web/src/lib/format.ts @@ -17,7 +17,11 @@ export function formatDate(iso: string): string { } export function formatDateFull(iso: string): string { - return new Date(iso).toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' }); + return new Date(iso).toLocaleDateString('en-US', { + month: 'short', + day: 'numeric', + year: 'numeric', + }); } export function formatDateTime(iso: string | null): string { @@ -58,7 +62,9 @@ export interface GroupedProposal { baseNumber: string; } -export function groupRevisions(rows: T[]): GroupedProposal[] { +export function groupRevisions( + rows: T[], +): GroupedProposal[] { const groups = new Map(); for (const row of rows) { const base = row.proposalNumber.replace(/-R\d+$/, ''); diff --git a/web/src/lib/theme/mui-theme.ts b/web/src/lib/theme/mui-theme.ts index 28be2c5..ece517d 100644 --- a/web/src/lib/theme/mui-theme.ts +++ b/web/src/lib/theme/mui-theme.ts @@ -37,9 +37,18 @@ 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 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'; @@ -149,7 +158,8 @@ export const muiTheme = createTheme({ 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)', + 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, diff --git a/web/src/main.tsx b/web/src/main.tsx index d27f8f8..d9a5028 100644 --- a/web/src/main.tsx +++ b/web/src/main.tsx @@ -26,5 +26,5 @@ ReactDOM.createRoot(document.getElementById('root')!).render( - + , ); diff --git a/web/src/pages/admin/customers/CustomerManagementPage.tsx b/web/src/pages/admin/customers/CustomerManagementPage.tsx index 03ce926..b408d36 100644 --- a/web/src/pages/admin/customers/CustomerManagementPage.tsx +++ b/web/src/pages/admin/customers/CustomerManagementPage.tsx @@ -120,10 +120,7 @@ export default function CustomerManagementPage() { const payload = toCustomerRequest(values, editingCustomer?.addresses ?? []); if (isEditing) { - updateMutation.mutate( - { id: editingCustomer.id, data: payload }, - { onSuccess: closeDialog } - ); + updateMutation.mutate({ id: editingCustomer.id, data: payload }, { onSuccess: closeDialog }); } else { createMutation.mutate(payload, { onSuccess: closeDialog }); } @@ -135,11 +132,7 @@ export default function CustomerManagementPage() { Customer Management - @@ -222,10 +215,7 @@ export default function CustomerManagementPage() { - openEditDialog(customer)} - > + openEditDialog(customer)}> @@ -289,7 +279,9 @@ export default function CustomerManagementPage() { type="email" {...register('contactEmail')} error={!!errors.contactEmail} - helperText={errors.contactEmail?.message || 'Optional. Used for proposal PDF delivery.'} + helperText={ + errors.contactEmail?.message || 'Optional. Used for proposal PDF delivery.' + } size="small" fullWidth /> @@ -299,12 +291,14 @@ export default function CustomerManagementPage() { - diff --git a/web/src/pages/admin/dashboard/AdminDashboard.tsx b/web/src/pages/admin/dashboard/AdminDashboard.tsx index 8bc8700..0da0ccd 100644 --- a/web/src/pages/admin/dashboard/AdminDashboard.tsx +++ b/web/src/pages/admin/dashboard/AdminDashboard.tsx @@ -39,27 +39,82 @@ import KeyboardArrowRightIcon from '@mui/icons-material/KeyboardArrowRight'; import { useProposals } from '../../../domain/proposals/use-cases'; import type { ProposalListItem } from '../../../domain/proposals/types'; import { useAdminDashboard } from '../../../domain/admin/use-cases'; -import { DEBOUNCE_SEARCH, DEFAULT_PAGE, DEFAULT_PAGE_SIZE, SERVICE_CATEGORIES, PRIORITIES, PROPOSAL_STATUSES, STATUS_CHIP_STYLES, STATUS_LABELS, PRIORITY_LABELS, PRIORITY_CHIP_STYLES, PRIORITY_ROW_SX } from '../../../constants'; -import { formatBidAmount, formatDate, formatDateFull, formatAge, getAgeColor, groupRevisions } from '../../../lib/format'; +import { + DEBOUNCE_SEARCH, + DEFAULT_PAGE, + DEFAULT_PAGE_SIZE, + SERVICE_CATEGORIES, + PRIORITIES, + PROPOSAL_STATUSES, + STATUS_CHIP_STYLES, + STATUS_LABELS, + PRIORITY_LABELS, + PRIORITY_CHIP_STYLES, + PRIORITY_ROW_SX, +} from '../../../constants'; +import { + formatBidAmount, + formatDate, + formatDateFull, + formatAge, + getAgeColor, + groupRevisions, +} from '../../../lib/format'; import { useDocumentTitle } from '../../../hooks/useDocumentTitle'; import { useDebouncedValue } from '../../../hooks/useDebouncedValue'; const COL_COUNT = 11; const TAB_STATUSES = ['', 'InReview', 'Approved', 'Sent', 'Revised'] as const; -function StatCard({ icon, label, value, color, subtitle }: { icon: React.ReactNode; label: string; value: string; color: string; subtitle?: string }) { +function StatCard({ + icon, + label, + value, + color, + subtitle, +}: { + icon: React.ReactNode; + label: string; + value: string; + color: string; + subtitle?: string; +}) { return ( - + {icon} - + {label} - + {value} {subtitle && ( - + {subtitle} )} @@ -74,7 +129,12 @@ export default function AdminDashboard({ defaultStatus }: { defaultStatus?: stri const showTabs = !defaultStatus; // Fix: WEB-M8 — capture error state for admin stats to show error instead of misleading zeros - const { data: stats, isLoading: statsLoading, isError: statsError, refetch: refetchStats } = useAdminDashboard(); + const { + data: stats, + isLoading: statsLoading, + isError: statsError, + refetch: refetchStats, + } = useAdminDashboard(); const [statusFilter, setStatusFilter] = useState(defaultStatus ?? ''); const [categoryFilter, setCategoryFilter] = useState(''); @@ -120,7 +180,7 @@ export default function AdminDashboard({ defaultStatus }: { defaultStatus?: stri }); }; - const tabIndex = TAB_STATUSES.indexOf(statusFilter as typeof TAB_STATUSES[number]); + const tabIndex = TAB_STATUSES.indexOf(statusFilter as (typeof TAB_STATUSES)[number]); const handleTabChange = (_: React.SyntheticEvent, newValue: number) => { setStatusFilter(TAB_STATUSES[newValue] ?? ''); @@ -137,7 +197,13 @@ export default function AdminDashboard({ defaultStatus }: { defaultStatus?: stri setPage(1); }; - const renderRow = (row: ProposalListItem, indent: boolean, showExpandIcon: boolean, isExpanded: boolean, baseNumber: string) => ( + const renderRow = ( + row: ProposalListItem, + indent: boolean, + showExpandIcon: boolean, + isExpanded: boolean, + baseNumber: string, + ) => ( - {isExpanded ? : } + {isExpanded ? ( + + ) : ( + + )} )} @@ -170,11 +240,21 @@ export default function AdminDashboard({ defaultStatus }: { defaultStatus?: stri label={PRIORITY_LABELS[row.priority] || row.priority} size="small" sx={{ ...PRIORITY_CHIP_STYLES[row.priority], '& .MuiChip-icon': { color: 'inherit' } }} - icon={row.priority === 'Emergency' ? : row.priority === 'Urgent' ? : undefined} + icon={ + row.priority === 'Emergency' ? ( + + ) : row.priority === 'Urgent' ? ( + + ) : undefined + } /> - + {formatBidAmount(row.totalBidAmount)} @@ -183,7 +263,12 @@ export default function AdminDashboard({ defaultStatus }: { defaultStatus?: stri {row.submittedByName || '-'} - + {formatAge(row.submittedAt)} @@ -191,7 +276,9 @@ export default function AdminDashboard({ defaultStatus }: { defaultStatus?: stri return ( - {defaultStatus ? 'Admin Queue' : 'All Proposals'} + + {defaultStatus ? 'Admin Queue' : 'All Proposals'} + {/* Fix: WEB-M8 — show error state instead of misleading zeros when stats fetch fails */} @@ -312,7 +399,9 @@ export default function AdminDashboard({ defaultStatus }: { defaultStatus?: stri > All {SERVICE_CATEGORIES.map((c) => ( - {c} + + {c} + ))} All {PRIORITIES.map((p) => ( - {PRIORITY_LABELS[p] || p} + + {PRIORITY_LABELS[p] || p} + ))} {!showTabs && ( @@ -345,7 +436,9 @@ export default function AdminDashboard({ defaultStatus }: { defaultStatus?: stri > All {PROPOSAL_STATUSES.map((s) => ( - {STATUS_LABELS[s] || s} + + {STATUS_LABELS[s] || s} + ))} )} @@ -357,7 +450,9 @@ export default function AdminDashboard({ defaultStatus }: { defaultStatus?: stri {err && ( - {err} + + {err} + )} @@ -382,7 +477,9 @@ export default function AdminDashboard({ defaultStatus }: { defaultStatus?: stri ? Array.from({ length: 5 }).map((_, i) => ( {Array.from({ length: COL_COUNT }).map((_, j) => ( - + + + ))} )) @@ -390,7 +487,13 @@ export default function AdminDashboard({ defaultStatus }: { defaultStatus?: stri const isExpanded = expanded.has(group.baseNumber); return ( - {renderRow(group.latest, false, group.hasRevisions, isExpanded, group.baseNumber)} + {renderRow( + group.latest, + false, + group.hasRevisions, + isExpanded, + group.baseNumber, + )} {group.hasRevisions && ( @@ -398,7 +501,7 @@ export default function AdminDashboard({ defaultStatus }: { defaultStatus?: stri {group.revisions.map((rev) => - renderRow(rev, true, false, false, group.baseNumber) + renderRow(rev, true, false, false, group.baseNumber), )}
diff --git a/web/src/pages/admin/pricing-library/PricingLibraryPage.tsx b/web/src/pages/admin/pricing-library/PricingLibraryPage.tsx index 13581d6..b220b7b 100644 --- a/web/src/pages/admin/pricing-library/PricingLibraryPage.tsx +++ b/web/src/pages/admin/pricing-library/PricingLibraryPage.tsx @@ -149,7 +149,7 @@ export default function PricingLibraryPage() { if (editingItem) { updateMutation.mutate( { id: editingItem.id, data: payload }, - { onSuccess: () => closeDialog() } + { onSuccess: () => closeDialog() }, ); } else { createMutation.mutate(payload, { onSuccess: () => closeDialog() }); @@ -168,11 +168,7 @@ export default function PricingLibraryPage() { Pricing Library - @@ -220,7 +216,11 @@ export default function PricingLibraryPage() {
)) : rows.map((item) => { - const defaultChip = { bgcolor: '#F1F5F9', color: '#475569', border: '1px solid #CBD5E1' }; + const defaultChip = { + bgcolor: '#F1F5F9', + color: '#475569', + border: '1px solid #CBD5E1', + }; const chipStyle = CATEGORY_CHIP_STYLES[item.serviceCategory] ?? defaultChip; return ( @@ -239,17 +239,16 @@ export default function PricingLibraryPage() { />
{item.unit ?? '—'} - + {formatPrice(item.unitPrice)} {item.source} - openEditDialog(item)} - > + openEditDialog(item)}> @@ -359,7 +358,9 @@ export default function PricingLibraryPage() { label="Unit Price" type="number" error={!!errors.unitPrice} - helperText={errors.unitPrice?.message || 'Optional. Leave blank if price varies.'} + helperText={ + errors.unitPrice?.message || 'Optional. Leave blank if price varies.' + } size="small" fullWidth slotProps={{ @@ -403,12 +404,14 @@ export default function PricingLibraryPage() { - @@ -419,7 +422,8 @@ export default function PricingLibraryPage() { Delete Pricing Item - Are you sure you want to delete {deleteTarget?.title}? This action cannot be undone. + Are you sure you want to delete {deleteTarget?.title}? This action + cannot be undone. diff --git a/web/src/pages/admin/workspace/AdminWorkspace.tsx b/web/src/pages/admin/workspace/AdminWorkspace.tsx index b7bb83b..d0b2ef4 100644 --- a/web/src/pages/admin/workspace/AdminWorkspace.tsx +++ b/web/src/pages/admin/workspace/AdminWorkspace.tsx @@ -105,7 +105,7 @@ export default function AdminWorkspace() { pricingMode: li.pricingMode, sortOrder: li.sortOrder, source: li.source, - })) + })), ); } }, [fetchedLineItems]); @@ -128,14 +128,17 @@ export default function AdminWorkspace() { const [navBlocked, setNavBlocked] = useState(false); const pendingNavRef = useRef(null); - const guardedNavigate = useCallback((to: string) => { - if (dirty) { - pendingNavRef.current = to; - setNavBlocked(true); - } else { - navigate(to); - } - }, [dirty, navigate]); + const guardedNavigate = useCallback( + (to: string) => { + if (dirty) { + pendingNavRef.current = to; + setNavBlocked(true); + } else { + navigate(to); + } + }, + [dirty, navigate], + ); // Domain mutations: cache invalidation + success/error toasts live in the // hooks; page-specific side effects (dialog close, dirty reset, window.open) @@ -152,7 +155,7 @@ export default function AdminWorkspace() { const handleSave = () => { saveMutation.mutate( { refinedScope, lineItems: toUpdateEntries(lineItems) }, - { onSuccess: () => setDirty(false) } + { onSuccess: () => setDirty(false) }, ); }; @@ -227,9 +230,14 @@ export default function AdminWorkspace() { } > - Failed to load proposal{proposalError instanceof Error ? `: ${proposalError.message}` : '.'} + Failed to load proposal + {proposalError instanceof Error ? `: ${proposalError.message}` : '.'} - @@ -240,7 +248,11 @@ export default function AdminWorkspace() { return ( Proposal not found - @@ -257,16 +269,19 @@ export default function AdminWorkspace() { const bidTotal = lineItems.reduce((sum, li) => sum + li.totalPrice, 0); // Tooltip messages for disabled actions - const approveDisabledReason = - !isEditable - ? `Cannot approve: proposal is ${proposal.status}` - : lineItems.length === 0 - ? 'Add at least one line item before approving' - : bidTotal <= 0 - ? 'Total bid must be greater than zero' - : ''; - const sendDisabledReason = !canSend ? `Cannot send: proposal must be Approved (currently ${proposal.status})` : ''; - const reviseDisabledReason = !canRevise ? `Cannot revise: proposal must be Sent (currently ${proposal.status})` : ''; + const approveDisabledReason = !isEditable + ? `Cannot approve: proposal is ${proposal.status}` + : lineItems.length === 0 + ? 'Add at least one line item before approving' + : bidTotal <= 0 + ? 'Total bid must be greater than zero' + : ''; + const sendDisabledReason = !canSend + ? `Cannot send: proposal must be Approved (currently ${proposal.status})` + : ''; + const reviseDisabledReason = !canRevise + ? `Cannot revise: proposal must be Sent (currently ${proposal.status})` + : ''; return ( @@ -282,9 +297,7 @@ export default function AdminWorkspace() { Admin Queue - - {proposal.proposalNumber} - + {proposal.proposalNumber} @@ -292,25 +305,41 @@ export default function AdminWorkspace() { {/* Proposal Info Bar */} - + - Customer - {proposal.customerName} + + Customer + + + {proposal.customerName} + - WO # - {proposal.workOrderNumber} + + WO # + + + {proposal.workOrderNumber} + - Category + + Category + {proposal.serviceCategory} - Priority + + Priority + - Address + + Address + {proposal.customerAddress} @@ -320,28 +349,51 @@ export default function AdminWorkspace() { {/* Left Panel: Reference */} - + - Submission Details + + Submission Details + - Submitted By - {proposal.submittedByName || '-'} + + Submitted By + + + {proposal.submittedByName || '-'} + - Submitted + + Submitted + {new Date(proposal.submittedAt).toLocaleString()} - Original Scope - + + Original Scope + + {proposal.scopeOfWork} {proposal.notes && ( <> - Notes + + Notes + {proposal.notes} @@ -358,14 +410,19 @@ export default function AdminWorkspace() { - Refined Scope + + Refined Scope + { setRefinedScope(e.target.value); setDirty(true); }} + onChange={(e) => { + setRefinedScope(e.target.value); + setDirty(true); + }} disabled={!isEditable} size="small" sx={{ mb: 2 }} @@ -374,12 +431,29 @@ export default function AdminWorkspace() { {/* Fix: WEB-M9 — show loading skeleton while line items are being fetched */} {lineItemsLoading ? ( - + - + {Array.from({ length: 4 }).map((_, i) => ( - + ))} @@ -404,7 +478,8 @@ export default function AdminWorkspace() { { - const maxSort = lineItems.length > 0 ? Math.max(...lineItems.map((i) => i.sortOrder)) : 0; + const maxSort = + lineItems.length > 0 ? Math.max(...lineItems.map((i) => i.sortOrder)) : 0; handleLineItemsChange([ ...lineItems, { ...item, id: null, sortOrder: maxSort + 1, source: 'Historical' }, @@ -419,10 +494,26 @@ export default function AdminWorkspace() { {/* Action Bar */} - - + + {dirty && ( - Unsaved changes + + Unsaved changes + )} {!dirty && } @@ -437,10 +528,7 @@ export default function AdminWorkspace() { > Regenerate - + - @@ -549,11 +641,17 @@ export default function AdminWorkspace() { Line Items: {lineItems.length} - Total Bid: {new Intl.NumberFormat('en-US', { style: 'currency', currency: 'USD' }).format(bidTotal)} + Total Bid:{' '} + {new Intl.NumberFormat('en-US', { style: 'currency', currency: 'USD' }).format( + bidTotal, + )} {proposal.vendorTotalCost != null && ( - Vendor Cost: {new Intl.NumberFormat('en-US', { style: 'currency', currency: 'USD' }).format(proposal.vendorTotalCost)} + Vendor Cost:{' '} + {new Intl.NumberFormat('en-US', { style: 'currency', currency: 'USD' }).format( + proposal.vendorTotalCost, + )} )} @@ -576,8 +674,8 @@ export default function AdminWorkspace() { Mark as Sent - Mark {proposal.proposalNumber} as sent to {proposal.customerName}? - This action cannot be undone. + Mark {proposal.proposalNumber} as sent to {proposal.customerName}? This + action cannot be undone. {/* PR4-B: surface delivery behavior — server emails PDF if customer has a contact email */} @@ -602,8 +700,8 @@ export default function AdminWorkspace() { Create Revision - Create a new revision of {proposal.proposalNumber}? - The current version will be marked as revised and a new editable copy will be created. + Create a new revision of {proposal.proposalNumber}? The current version + will be marked as revised and a new editable copy will be created. @@ -623,8 +721,8 @@ export default function AdminWorkspace() { Return to Review - Return {proposal.proposalNumber} to In Review status? - This will clear the approval and allow further edits to line items and scope. + Return {proposal.proposalNumber} to In Review status? This will clear + the approval and allow further edits to line items and scope. diff --git a/web/src/pages/auth/LoginPage.tsx b/web/src/pages/auth/LoginPage.tsx index cf3e692..8fad566 100644 --- a/web/src/pages/auth/LoginPage.tsx +++ b/web/src/pages/auth/LoginPage.tsx @@ -134,7 +134,9 @@ export default function LoginPage() { > SysAdmin - Full access + + Full access + diff --git a/web/src/pages/dashboard/Dashboard.tsx b/web/src/pages/dashboard/Dashboard.tsx index d6ed031..16b1221 100644 --- a/web/src/pages/dashboard/Dashboard.tsx +++ b/web/src/pages/dashboard/Dashboard.tsx @@ -28,19 +28,55 @@ import { STATUS_CHIP_STYLES } from '../../constants'; import { formatCurrency, formatDate, formatDateFull } from '../../lib/format'; import { useDocumentTitle } from '../../hooks/useDocumentTitle'; -function KpiCard({ icon, label, value, subtitle }: { icon: React.ReactNode; label: string; value: string; subtitle?: string }) { +function KpiCard({ + icon, + label, + value, + subtitle, +}: { + icon: React.ReactNode; + label: string; + value: string; + subtitle?: string; +}) { return ( - + - {icon} - + + {icon} + + {label} - + {value} {subtitle && ( - + {subtitle} )} @@ -53,10 +89,19 @@ export default function Dashboard() { useDocumentTitle('Dashboard'); const navigate = useNavigate(); - const { data: recentData, isLoading: recentLoading, isError: recentError } = useProposals({ page: 1, pageSize: 5, mine: true }); + const { + data: recentData, + isLoading: recentLoading, + isError: recentError, + } = useProposals({ page: 1, pageSize: 5, mine: true }); // Fix: WEB-M8 — capture error state for stats to show error instead of misleading zeros - const { data: stats, isLoading: statsLoading, isError: statsError, refetch: refetchStats } = useProposalStats(); + const { + data: stats, + isLoading: statsLoading, + isError: statsError, + refetch: refetchStats, + } = useProposalStats(); const proposals = recentData?.items ?? []; const total = stats?.totalCount ?? 0; @@ -101,28 +146,48 @@ export default function Dashboard() { {statsLoading ? ( ) : ( - } label="Total Submitted" value={String(total)} subtitle="All time" /> + } + label="Total Submitted" + value={String(total)} + subtitle="All time" + /> )} {statsLoading ? ( ) : ( - } label="In Review" value={String(counts.inReview)} subtitle="Awaiting pricing" /> + } + label="In Review" + value={String(counts.inReview)} + subtitle="Awaiting pricing" + /> )} {statsLoading ? ( ) : ( - } label="Approved" value={String(counts.approved)} subtitle="Ready to send" /> + } + label="Approved" + value={String(counts.approved)} + subtitle="Ready to send" + /> )} {statsLoading ? ( ) : ( - } label="Sent" value={String(counts.sent)} subtitle="Delivered to customer" /> + } + label="Sent" + value={String(counts.sent)} + subtitle="Delivered to customer" + /> )} @@ -131,7 +196,9 @@ export default function Dashboard() { - + Recent Proposals