ci(web): Phase 5 — Prettier check + Playwright smoke, org frontend workflow (#225)

* 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<T>` (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.
This commit is contained in:
Adam Moussa 2026-07-13 21:02:20 -04:00 • committed by GitHub
parent d40a794695
commit 4629f7a21a
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
54 changed files with 1165 additions and 395 deletions

View file

@ -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:

4
.gitignore vendored
View file

@ -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/

View file

@ -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) |

View file

@ -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

6
web/.prettierignore Normal file
View file

@ -0,0 +1,6 @@
dist
coverage
node_modules
package-lock.json
playwright-report
test-results

4
web/.prettierrc Normal file
View file

@ -0,0 +1,4 @@
{
"singleQuote": true,
"printWidth": 100
}

113
web/e2e/smoke.spec.ts Normal file
View file

@ -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();
});

View file

@ -1,4 +1,4 @@
<!DOCTYPE html>
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />

81
web/package-lock.json generated
View file

@ -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",

View file

@ -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"

27
web/playwright.config.ts Normal file
View file

@ -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,
},
});

View file

@ -38,9 +38,26 @@ export default function App() {
element={
<ProtectedRoute>
{/* SHOC shell composition (ADR 0003): sidebar column + [topbar / scrolling main] */}
<Box sx={{ display: 'flex', height: '100vh', width: '100%', overflow: 'hidden', backgroundColor: 'var(--background)' }}>
<Box
sx={{
display: 'flex',
height: '100vh',
width: '100%',
overflow: 'hidden',
backgroundColor: 'var(--background)',
}}
>
<Sidebar open={sidebarOpen} onOpenChange={setSidebarOpen} />
<Box sx={{ display: 'flex', flexDirection: 'column', flex: 1, minWidth: 0, height: '100vh', overflow: 'hidden' }}>
<Box
sx={{
display: 'flex',
flexDirection: 'column',
flex: 1,
minWidth: 0,
height: '100vh',
overflow: 'hidden',
}}
>
<Topbar onToggleSidebar={() => setSidebarOpen(!sidebarOpen)} />
<Box
component="main"
@ -58,20 +75,62 @@ export default function App() {
<Route path="/proposals/:id" element={<ProposalDetailPage />} />
{/* Admin Routes */}
<Route path="/admin" element={<RoleGuard roles={['Admin', 'SysAdmin']}><AdminDashboard defaultStatus="InReview" /></RoleGuard>} />
<Route path="/admin/proposals" element={<RoleGuard roles={['Admin', 'SysAdmin']}><AdminDashboard /></RoleGuard>} />
<Route path="/admin/proposals/:id" element={<RoleGuard roles={['Admin', 'SysAdmin']}><AdminWorkspace /></RoleGuard>} />
<Route path="/admin/customers" element={<RoleGuard roles={['Admin', 'SysAdmin']}><CustomerManagementPage /></RoleGuard>} />
<Route path="/admin/pricing-library" element={<RoleGuard roles={['Admin', 'SysAdmin']}><PricingLibraryPage /></RoleGuard>} />
<Route path="/admin/users" element={
<Route
path="/admin"
element={
<RoleGuard roles={['Admin', 'SysAdmin']}>
<AdminDashboard defaultStatus="InReview" />
</RoleGuard>
}
/>
<Route
path="/admin/proposals"
element={
<RoleGuard roles={['Admin', 'SysAdmin']}>
<AdminDashboard />
</RoleGuard>
}
/>
<Route
path="/admin/proposals/:id"
element={
<RoleGuard roles={['Admin', 'SysAdmin']}>
<AdminWorkspace />
</RoleGuard>
}
/>
<Route
path="/admin/customers"
element={
<RoleGuard roles={['Admin', 'SysAdmin']}>
<CustomerManagementPage />
</RoleGuard>
}
/>
<Route
path="/admin/pricing-library"
element={
<RoleGuard roles={['Admin', 'SysAdmin']}>
<PricingLibraryPage />
</RoleGuard>
}
/>
<Route
path="/admin/users"
element={
<RoleGuard roles={['SysAdmin']}>
<Box sx={{ display: 'flex', justifyContent: 'center', pt: 6 }}>
<Card sx={{ maxWidth: 520, p: 4, textAlign: 'center' }}>
<CardContent>
<PeopleIcon sx={{ fontSize: 48, color: 'var(--color-text-muted)', mb: 2 }} />
<Typography variant="h5" sx={{ mb: 1 }}>User Management</Typography>
<PeopleIcon
sx={{ fontSize: 48, color: 'var(--color-text-muted)', mb: 2 }}
/>
<Typography variant="h5" sx={{ mb: 1 }}>
User Management
</Typography>
<Typography variant="body1" color="text.secondary" sx={{ mb: 3 }}>
Users and roles are currently managed in AWS Cognito. Contact the system administrator to update access.
Users and roles are currently managed in AWS Cognito. Contact the
system administrator to update access.
</Typography>
<Button
variant="outlined"
@ -86,7 +145,8 @@ export default function App() {
</Card>
</Box>
</RoleGuard>
} />
}
/>
<Route path="*" element={<Navigate to="/" replace />} />
</Routes>

View file

@ -24,7 +24,17 @@ export default class ErrorBoundary extends React.Component<Props, State> {
render() {
if (this.state.hasError) {
return (
<Box sx={{ display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center', minHeight: '100vh', gap: 2, p: 4 }}>
<Box
sx={{
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
justifyContent: 'center',
minHeight: '100vh',
gap: 2,
p: 4,
}}
>
<Typography variant="h5">Something went wrong</Typography>
<Typography color="text.secondary" sx={{ maxWidth: 480, textAlign: 'center' }}>
An unexpected error occurred. Please try refreshing the page.

View file

@ -7,7 +7,9 @@ export default function ProtectedRoute({ children }: { children: React.ReactNode
if (isLoading) {
return (
<Box sx={{ display: 'flex', justifyContent: 'center', alignItems: 'center', minHeight: '100vh' }}>
<Box
sx={{ display: 'flex', justifyContent: 'center', alignItems: 'center', minHeight: '100vh' }}
>
<CircularProgress />
</Box>
);

View file

@ -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({
)}
<Icon size={19} />
{!collapsed && (
<Box component="span" sx={{ overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
<Box
component="span"
sx={{ overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}
>
{item.label}
</Box>
)}
@ -168,7 +167,9 @@ export default function Sidebar({
);
const groupDivider = (
<Box sx={{ height: '1px', mx: '14px', my: '10px', backgroundColor: 'var(--color-sidebar-border)' }} />
<Box
sx={{ height: '1px', mx: '14px', my: '10px', backgroundColor: 'var(--color-sidebar-border)' }}
/>
);
const renderGroup = (label: string, items: NavEntry[], first: boolean) => (

View file

@ -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)',
}}
>
<Toolbar disableGutters sx={{ height: 'var(--spacing-topbar)', minHeight: 'var(--spacing-topbar)', gap: '18px', pl: '16px', pr: '22px' }}>
<Toolbar
disableGutters
sx={{
height: 'var(--spacing-topbar)',
minHeight: 'var(--spacing-topbar)',
gap: '18px',
pl: '16px',
pr: '22px',
}}
>
<IconButton
onClick={onToggleSidebar}
aria-label="Toggle sidebar menu"
@ -88,10 +102,14 @@ export default function Topbar({ onToggleSidebar }: { onToggleSidebar: () => voi
{initials}
</Avatar>
<Box sx={{ display: 'flex', flexDirection: 'column', lineHeight: 1.2 }}>
<Typography sx={{ fontSize: '13px', fontWeight: 600, color: 'var(--color-header-text)' }}>
<Typography
sx={{ fontSize: '13px', fontWeight: 600, color: 'var(--color-header-text)' }}
>
{user.displayName}
</Typography>
<Typography sx={{ fontSize: 'var(--text-xs)', color: 'var(--color-header-text-muted)' }}>
<Typography
sx={{ fontSize: 'var(--text-xs)', color: 'var(--color-header-text-muted)' }}
>
{user.role}
</Typography>
</Box>
@ -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)',
},
}}
>
<LogOut size={16} />

View file

@ -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,
});

View file

@ -125,11 +125,14 @@ export default function LineItemEditor({ items, onChange, disabled = false }: Li
return (
<Box>
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', mb: 1 }}>
<Typography variant="subtitle2">
Line Items ({items.length})
</Typography>
<Typography variant="subtitle2">Line Items ({items.length})</Typography>
{!disabled && (
<Button size="small" variant={items.length === 0 ? 'contained' : 'outlined'} startIcon={<AddIcon />} onClick={addItem}>
<Button
size="small"
variant={items.length === 0 ? 'contained' : 'outlined'}
startIcon={<AddIcon />}
onClick={addItem}
>
Add Line Item
</Button>
)}
@ -155,11 +158,21 @@ export default function LineItemEditor({ items, onChange, disabled = false }: Li
<TableCell>
{!disabled && (
<Box sx={{ display: 'flex', flexDirection: 'column', alignItems: 'center' }}>
<IconButton size="small" onClick={() => moveItem(index, -1)} disabled={index === 0} aria-label={`Move item ${index + 1} up`}>
<IconButton
size="small"
onClick={() => moveItem(index, -1)}
disabled={index === 0}
aria-label={`Move item ${index + 1} up`}
>
<ArrowUpwardIcon sx={{ fontSize: 14 }} />
</IconButton>
<Typography variant="caption">{index + 1}</Typography>
<IconButton size="small" onClick={() => moveItem(index, 1)} disabled={index === items.length - 1} aria-label={`Move item ${index + 1} down`}>
<IconButton
size="small"
onClick={() => moveItem(index, 1)}
disabled={index === items.length - 1}
aria-label={`Move item ${index + 1} down`}
>
<ArrowDownwardIcon sx={{ fontSize: 14 }} />
</IconButton>
</Box>
@ -200,7 +213,9 @@ export default function LineItemEditor({ items, onChange, disabled = false }: Li
fullWidth
>
{UNITS.map((u) => (
<MenuItem key={u} value={u}>{u}</MenuItem>
<MenuItem key={u} value={u}>
{u}
</MenuItem>
))}
</TextField>
</TableCell>
@ -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
<Box sx={{ display: 'flex', justifyContent: 'flex-end', mt: 1, pr: 2 }}>
<Typography variant="subtitle1" sx={{ fontWeight: 700 }}>
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,
)}
</Typography>
</Box>
</Box>

View file

@ -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 (
<Box>
<Typography variant="subtitle2" sx={{ mb: 1 }}>Similar Proposals</Typography>
<Typography variant="subtitle2" sx={{ mb: 1 }}>
Similar Proposals
</Typography>
{Array.from({ length: 3 }).map((_, i) => (
<Skeleton key={i} variant="rectangular" height={40} sx={{ mb: 1, borderRadius: 1 }} />
))}
@ -74,7 +80,9 @@ export default function SimilarProposalsPanel({ proposalId, onPullLineItem, disa
}}
>
<Box sx={{ flex: 1, minWidth: 0 }}>
<Typography variant="caption" noWrap>{li.description}</Typography>
<Typography variant="caption" noWrap>
{li.description}
</Typography>
<Typography variant="caption" color="text.secondary" sx={{ display: 'block' }}>
{li.quantity} {li.unit} @ {formatCurrency(li.totalPrice)}
</Typography>

View file

@ -16,19 +16,27 @@ export default function VendorDataPanel({ proposalId }: VendorDataPanelProps) {
if (!vendors || vendors.length === 0) {
return (
<Box>
<Typography variant="subtitle2" sx={{ mb: 0.5 }}>Vendor Data</Typography>
<Typography variant="body2" color="text.secondary">No vendor proposals attached</Typography>
<Typography variant="subtitle2" sx={{ mb: 0.5 }}>
Vendor Data
</Typography>
<Typography variant="body2" color="text.secondary">
No vendor proposals attached
</Typography>
</Box>
);
}
return (
<Box>
<Typography variant="subtitle2" sx={{ mb: 1 }}>Vendor Data</Typography>
<Typography variant="subtitle2" sx={{ mb: 1 }}>
Vendor Data
</Typography>
{vendors.map((vp) => (
<Box key={vp.id} sx={{ mb: 2 }}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 0.5 }}>
<Typography variant="body2" sx={{ fontWeight: 600 }}>{vp.vendorName}</Typography>
<Typography variant="body2" sx={{ fontWeight: 600 }}>
{vp.vendorName}
</Typography>
<Chip
label={vp.processingStatus}
size="small"
@ -36,7 +44,9 @@ export default function VendorDataPanel({ proposalId }: VendorDataPanelProps) {
variant="outlined"
/>
</Box>
<Typography variant="caption" color="text.secondary">{vp.fileName}</Typography>
<Typography variant="caption" color="text.secondary">
{vp.fileName}
</Typography>
{vp.totalVendorCost > 0 && (
<Typography variant="body2" sx={{ mt: 0.5, fontWeight: 600 }}>

View file

@ -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<string, string> = {
@ -30,7 +37,10 @@ export const STATUS_LABELS: Record<string, string> = {
};
// Sea Haven Ops status chips — flat fill + thin border + bold label (tokens/base.css).
export const STATUS_CHIP_STYLES: Record<string, { bgcolor: string; color: string; border: string }> = {
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<string, string> = {
Emergency: 'Emergency',
};
export const PRIORITY_CHIP_STYLES: Record<string, { bgcolor: string; color: string; border: string }> = {
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' },

View file

@ -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');

View file

@ -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';

View file

@ -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: {

View file

@ -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);
});
});

View file

@ -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). */

View file

@ -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({
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()), {
contactEmail: z.string().refine((v) => v.trim() === '' || EMAIL_RE.test(v.trim()), {
message: 'Enter a valid email address',
}),
});
});
export type CustomerFormValues = z.infer<typeof customerFormSchema>;
@ -40,7 +36,7 @@ export type CustomerFormValues = z.infer<typeof customerFormSchema>;
*/
export function toCustomerRequest(
values: CustomerFormValues,
existingAddresses: string[] = []
existingAddresses: string[] = [],
): CreateCustomerRequest & UpdateCustomerRequest {
return {
name: values.name.trim(),

View file

@ -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<PricingLibraryItem> => {
update: async (
id: string,
data: UpdatePricingLibraryItemRequest,
): Promise<PricingLibraryItem> => {
const res = await apiClient.put(`/pricing-library/${id}`, data);
return res.data;
},

View file

@ -36,7 +36,7 @@ export type PricingLibraryItemFormValues = z.infer<typeof pricingLibraryItemForm
* empty unitPrice sent as null).
*/
export function toPricingLibraryItemRequest(
values: PricingLibraryItemFormValues
values: PricingLibraryItemFormValues,
): CreatePricingLibraryItemRequest & UpdatePricingLibraryItemRequest {
return {
title: values.title.trim(),

View file

@ -39,7 +39,11 @@ export const proposalsApi = {
return res.data;
},
uploadAttachment: async (proposalId: string, fileName: string, vendorName?: string): Promise<PresignedUpload> => {
uploadAttachment: async (
proposalId: string,
fileName: string,
vendorName?: string,
): Promise<PresignedUpload> => {
const params = new URLSearchParams({ fileName });
if (vendorName) params.append('vendorName', vendorName);
const res = await apiClient.post(`/proposals/${proposalId}/attachments?${params.toString()}`);

View file

@ -63,21 +63,24 @@ export interface CreateProposalVariables {
export function useCreateProposal() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: async ({ request, vendorFile }: CreateProposalVariables): Promise<ProposalDetail> => {
mutationFn: async ({
request,
vendorFile,
}: CreateProposalVariables): Promise<ProposalDetail> => {
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.`,
);
}
}

View file

@ -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 = () => {

View file

@ -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]);
}

View file

@ -3,7 +3,9 @@
box-sizing: border-box;
}
html, body, #root {
html,
body,
#root {
height: 100%;
}

View file

@ -57,10 +57,18 @@ vi.mock('axios', () => {
const sessionStorageData: Record<string, string> = {};
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 () => {

View file

@ -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;

View file

@ -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({
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(/=+$/, '');

View file

@ -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<T extends { proposalNumber: string }> {
baseNumber: string;
}
export function groupRevisions<T extends { proposalNumber: string }>(rows: T[]): GroupedProposal<T>[] {
export function groupRevisions<T extends { proposalNumber: string }>(
rows: T[],
): GroupedProposal<T>[] {
const groups = new Map<string, T[]>();
for (const row of rows) {
const base = row.proposalNumber.replace(/-R\d+$/, '');

View file

@ -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,

View file

@ -26,5 +26,5 @@ ReactDOM.createRoot(document.getElementById('root')!).render(
</ThemeProvider>
</QueryClientProvider>
</AuthProvider>
</React.StrictMode>
</React.StrictMode>,
);

View file

@ -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() {
<Box>
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', mb: 3 }}>
<Typography variant="h5">Customer Management</Typography>
<Button
variant="contained"
startIcon={<AddIcon />}
onClick={openCreateDialog}
>
<Button variant="contained" startIcon={<AddIcon />} onClick={openCreateDialog}>
New Customer
</Button>
</Box>
@ -222,10 +215,7 @@ export default function CustomerManagementPage() {
</TableCell>
<TableCell>
<Tooltip title="Edit customer" arrow>
<IconButton
size="small"
onClick={() => openEditDialog(customer)}
>
<IconButton size="small" onClick={() => openEditDialog(customer)}>
<EditIcon fontSize="small" />
</IconButton>
</Tooltip>
@ -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() {
<Button onClick={closeDialog} disabled={isSaving}>
Cancel
</Button>
<Button
type="submit"
variant="contained"
disabled={isSaving}
>
{isSaving ? (isEditing ? 'Saving...' : 'Creating...') : isEditing ? 'Save Changes' : 'Create Customer'}
<Button type="submit" variant="contained" disabled={isSaving}>
{isSaving
? isEditing
? 'Saving...'
: 'Creating...'
: isEditing
? 'Save Changes'
: 'Create Customer'}
</Button>
</DialogActions>
</form>

View file

@ -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 (
<Card sx={{ borderRadius: '8px', transition: 'border-color 120ms ease', '&:hover': { borderColor: '#9CA3AF' } }}>
<Card
sx={{
borderRadius: '8px',
transition: 'border-color 120ms ease',
'&:hover': { borderColor: '#9CA3AF' },
}}
>
<CardContent sx={{ textAlign: 'center', py: 2.5 }}>
<Box sx={{ color, display: 'flex', justifyContent: 'center', mb: 1 }}>{icon}</Box>
<Typography sx={{ fontSize: '12px', color: '#64748B', textTransform: 'uppercase', letterSpacing: '0.06em', fontWeight: 600, mb: 1 }}>
<Typography
sx={{
fontSize: '12px',
color: '#64748B',
textTransform: 'uppercase',
letterSpacing: '0.06em',
fontWeight: 600,
mb: 1,
}}
>
{label}
</Typography>
<Typography sx={{ fontSize: '30px', fontWeight: 700, color: 'var(--foreground)', letterSpacing: '-0.02em' }}>
<Typography
sx={{
fontSize: '30px',
fontWeight: 700,
color: 'var(--foreground)',
letterSpacing: '-0.02em',
}}
>
{value}
</Typography>
{subtitle && (
<Typography sx={{ fontSize: '11px', color: '#94A3B8', fontWeight: 500, marginTop: '4px' }}>
<Typography
sx={{ fontSize: '11px', color: '#94A3B8', fontWeight: 500, marginTop: '4px' }}
>
{subtitle}
</Typography>
)}
@ -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,
) => (
<TableRow
key={row.id}
hover
@ -157,7 +223,11 @@ export default function AdminDashboard({ defaultStatus }: { defaultStatus?: stri
toggleExpand(baseNumber);
}}
>
{isExpanded ? <KeyboardArrowDownIcon fontSize="small" /> : <KeyboardArrowRightIcon fontSize="small" />}
{isExpanded ? (
<KeyboardArrowDownIcon fontSize="small" />
) : (
<KeyboardArrowRightIcon fontSize="small" />
)}
</IconButton>
)}
</TableCell>
@ -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' ? <ReportIcon /> : row.priority === 'Urgent' ? <WarningAmberIcon /> : undefined}
icon={
row.priority === 'Emergency' ? (
<ReportIcon />
) : row.priority === 'Urgent' ? (
<WarningAmberIcon />
) : undefined
}
/>
</TableCell>
<TableCell>
<Chip label={STATUS_LABELS[row.status] || row.status} size="small" sx={STATUS_CHIP_STYLES[row.status]} />
<Chip
label={STATUS_LABELS[row.status] || row.status}
size="small"
sx={STATUS_CHIP_STYLES[row.status]}
/>
</TableCell>
<TableCell align="right">{formatBidAmount(row.totalBidAmount)}</TableCell>
<TableCell>
@ -183,7 +263,12 @@ export default function AdminDashboard({ defaultStatus }: { defaultStatus?: stri
</Tooltip>
</TableCell>
<TableCell>{row.submittedByName || '-'}</TableCell>
<TableCell sx={{ color: getAgeColor(row.submittedAt), fontWeight: getAgeColor(row.submittedAt) ? 600 : undefined }}>
<TableCell
sx={{
color: getAgeColor(row.submittedAt),
fontWeight: getAgeColor(row.submittedAt) ? 600 : undefined,
}}
>
{formatAge(row.submittedAt)}
</TableCell>
</TableRow>
@ -191,7 +276,9 @@ export default function AdminDashboard({ defaultStatus }: { defaultStatus?: stri
return (
<Box>
<Typography variant="h5" sx={{ mb: 3 }}>{defaultStatus ? 'Admin Queue' : 'All Proposals'}</Typography>
<Typography variant="h5" sx={{ mb: 3 }}>
{defaultStatus ? 'Admin Queue' : 'All Proposals'}
</Typography>
{/* Fix: WEB-M8 — show error state instead of misleading zeros when stats fetch fails */}
<Grid container spacing={2} sx={{ mb: 3 }}>
@ -312,7 +399,9 @@ export default function AdminDashboard({ defaultStatus }: { defaultStatus?: stri
>
<MenuItem value="">All</MenuItem>
{SERVICE_CATEGORIES.map((c) => (
<MenuItem key={c} value={c}>{c}</MenuItem>
<MenuItem key={c} value={c}>
{c}
</MenuItem>
))}
</TextField>
<TextField
@ -328,7 +417,9 @@ export default function AdminDashboard({ defaultStatus }: { defaultStatus?: stri
>
<MenuItem value="">All</MenuItem>
{PRIORITIES.map((p) => (
<MenuItem key={p} value={p}>{PRIORITY_LABELS[p] || p}</MenuItem>
<MenuItem key={p} value={p}>
{PRIORITY_LABELS[p] || p}
</MenuItem>
))}
</TextField>
{!showTabs && (
@ -345,7 +436,9 @@ export default function AdminDashboard({ defaultStatus }: { defaultStatus?: stri
>
<MenuItem value="">All</MenuItem>
{PROPOSAL_STATUSES.map((s) => (
<MenuItem key={s} value={s}>{STATUS_LABELS[s] || s}</MenuItem>
<MenuItem key={s} value={s}>
{STATUS_LABELS[s] || s}
</MenuItem>
))}
</TextField>
)}
@ -357,7 +450,9 @@ export default function AdminDashboard({ defaultStatus }: { defaultStatus?: stri
</Box>
{err && (
<Typography color="error" sx={{ mb: 2 }}>{err}</Typography>
<Typography color="error" sx={{ mb: 2 }}>
{err}
</Typography>
)}
<TableContainer>
@ -382,7 +477,9 @@ export default function AdminDashboard({ defaultStatus }: { defaultStatus?: stri
? Array.from({ length: 5 }).map((_, i) => (
<TableRow key={i}>
{Array.from({ length: COL_COUNT }).map((_, j) => (
<TableCell key={j}><Skeleton variant="text" /></TableCell>
<TableCell key={j}>
<Skeleton variant="text" />
</TableCell>
))}
</TableRow>
))
@ -390,7 +487,13 @@ export default function AdminDashboard({ defaultStatus }: { defaultStatus?: stri
const isExpanded = expanded.has(group.baseNumber);
return (
<Fragment key={group.baseNumber}>
{renderRow(group.latest, false, group.hasRevisions, isExpanded, group.baseNumber)}
{renderRow(
group.latest,
false,
group.hasRevisions,
isExpanded,
group.baseNumber,
)}
{group.hasRevisions && (
<TableRow>
<TableCell colSpan={COL_COUNT} sx={{ p: 0, border: 0 }}>
@ -398,7 +501,7 @@ export default function AdminDashboard({ defaultStatus }: { defaultStatus?: stri
<Table size="small">
<TableBody>
{group.revisions.map((rev) =>
renderRow(rev, true, false, false, group.baseNumber)
renderRow(rev, true, false, false, group.baseNumber),
)}
</TableBody>
</Table>

View file

@ -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() {
<Box>
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', mb: 3 }}>
<Typography variant="h5">Pricing Library</Typography>
<Button
variant="contained"
startIcon={<AddIcon />}
onClick={openCreateDialog}
>
<Button variant="contained" startIcon={<AddIcon />} onClick={openCreateDialog}>
New Item
</Button>
</Box>
@ -220,7 +216,11 @@ export default function PricingLibraryPage() {
</TableRow>
))
: 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 (
<TableRow key={item.id} hover>
@ -239,17 +239,16 @@ export default function PricingLibraryPage() {
/>
</TableCell>
<TableCell>{item.unit ?? '—'}</TableCell>
<TableCell sx={{ textAlign: 'right', fontVariantNumeric: 'tabular-nums' }}>
<TableCell
sx={{ textAlign: 'right', fontVariantNumeric: 'tabular-nums' }}
>
{formatPrice(item.unitPrice)}
</TableCell>
<TableCell>{item.source}</TableCell>
<TableCell>
<Box sx={{ display: 'flex', gap: 0.5 }}>
<Tooltip title="Edit item" arrow>
<IconButton
size="small"
onClick={() => openEditDialog(item)}
>
<IconButton size="small" onClick={() => openEditDialog(item)}>
<EditIcon fontSize="small" />
</IconButton>
</Tooltip>
@ -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() {
<Button onClick={closeDialog} disabled={isSaving}>
Cancel
</Button>
<Button
type="submit"
variant="contained"
disabled={isSaving}
>
{isSaving ? (isEditing ? 'Saving...' : 'Creating...') : isEditing ? 'Save Changes' : 'Create Item'}
<Button type="submit" variant="contained" disabled={isSaving}>
{isSaving
? isEditing
? 'Saving...'
: 'Creating...'
: isEditing
? 'Save Changes'
: 'Create Item'}
</Button>
</DialogActions>
</form>
@ -419,7 +422,8 @@ export default function PricingLibraryPage() {
<DialogTitle>Delete Pricing Item</DialogTitle>
<DialogContent>
<Typography>
Are you sure you want to delete <strong>{deleteTarget?.title}</strong>? This action cannot be undone.
Are you sure you want to delete <strong>{deleteTarget?.title}</strong>? This action
cannot be undone.
</Typography>
</DialogContent>
<DialogActions>

View file

@ -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<string | null>(null);
const guardedNavigate = useCallback((to: string) => {
const guardedNavigate = useCallback(
(to: string) => {
if (dirty) {
pendingNavRef.current = to;
setNavBlocked(true);
} else {
navigate(to);
}
}, [dirty, navigate]);
},
[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() {
</Button>
}
>
Failed to load proposal{proposalError instanceof Error ? `: ${proposalError.message}` : '.'}
Failed to load proposal
{proposalError instanceof Error ? `: ${proposalError.message}` : '.'}
</Alert>
<Button startIcon={<ArrowBackIcon />} onClick={() => guardedNavigate('/admin')} sx={{ mt: 2 }}>
<Button
startIcon={<ArrowBackIcon />}
onClick={() => guardedNavigate('/admin')}
sx={{ mt: 2 }}
>
Back to Dashboard
</Button>
</Box>
@ -240,7 +248,11 @@ export default function AdminWorkspace() {
return (
<Box>
<Typography color="error">Proposal not found</Typography>
<Button startIcon={<ArrowBackIcon />} onClick={() => guardedNavigate('/admin')} sx={{ mt: 2 }}>
<Button
startIcon={<ArrowBackIcon />}
onClick={() => guardedNavigate('/admin')}
sx={{ mt: 2 }}
>
Back to Dashboard
</Button>
</Box>
@ -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
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 sendDisabledReason = !canSend
? `Cannot send: proposal must be Approved (currently ${proposal.status})`
: '';
const reviseDisabledReason = !canRevise
? `Cannot revise: proposal must be Sent (currently ${proposal.status})`
: '';
return (
<Box>
@ -282,9 +297,7 @@ export default function AdminWorkspace() {
Admin Queue
</Link>
<NavigateNextIcon sx={{ fontSize: 16, color: 'text.secondary' }} />
<Typography sx={{ fontSize: 26, fontWeight: 800 }}>
{proposal.proposalNumber}
</Typography>
<Typography sx={{ fontSize: 26, fontWeight: 800 }}>{proposal.proposalNumber}</Typography>
</Box>
<Chip label={`Rev ${proposal.currentRevision}`} size="small" variant="outlined" />
<Chip label={proposal.status} sx={STATUS_CHIP_STYLES[proposal.status]} />
@ -292,25 +305,41 @@ export default function AdminWorkspace() {
{/* Proposal Info Bar */}
<Card sx={{ mb: 2 }}>
<CardContent sx={{ py: 1.5, display: 'flex', gap: 3, flexWrap: 'wrap', alignItems: 'center' }}>
<CardContent
sx={{ py: 1.5, display: 'flex', gap: 3, flexWrap: 'wrap', alignItems: 'center' }}
>
<Box>
<Typography variant="caption" color="text.secondary">Customer</Typography>
<Typography variant="body2" sx={{ fontWeight: 600 }}>{proposal.customerName}</Typography>
<Typography variant="caption" color="text.secondary">
Customer
</Typography>
<Typography variant="body2" sx={{ fontWeight: 600 }}>
{proposal.customerName}
</Typography>
</Box>
<Box>
<Typography variant="caption" color="text.secondary">WO #</Typography>
<Typography variant="body2" sx={{ fontWeight: 600 }}>{proposal.workOrderNumber}</Typography>
<Typography variant="caption" color="text.secondary">
WO #
</Typography>
<Typography variant="body2" sx={{ fontWeight: 600 }}>
{proposal.workOrderNumber}
</Typography>
</Box>
<Box>
<Typography variant="caption" color="text.secondary">Category</Typography>
<Typography variant="caption" color="text.secondary">
Category
</Typography>
<Typography variant="body2">{proposal.serviceCategory}</Typography>
</Box>
<Box>
<Typography variant="caption" color="text.secondary">Priority</Typography>
<Typography variant="caption" color="text.secondary">
Priority
</Typography>
<Chip label={proposal.priority} size="small" sx={{ ml: 0.5 }} />
</Box>
<Box>
<Typography variant="caption" color="text.secondary">Address</Typography>
<Typography variant="caption" color="text.secondary">
Address
</Typography>
<Typography variant="body2">{proposal.customerAddress}</Typography>
</Box>
</CardContent>
@ -320,28 +349,51 @@ export default function AdminWorkspace() {
<Grid container spacing={2}>
{/* Left Panel: Reference */}
<Grid size={{ xs: 12, md: 3 }}>
<Card sx={{ position: 'sticky', top: 88, alignSelf: 'flex-start', maxHeight: 'calc(100vh - 120px)', overflowY: 'auto' }}>
<Card
sx={{
position: 'sticky',
top: 88,
alignSelf: 'flex-start',
maxHeight: 'calc(100vh - 120px)',
overflowY: 'auto',
}}
>
<CardContent>
<Typography variant="subtitle2" sx={{ mb: 1 }}>Submission Details</Typography>
<Typography variant="subtitle2" sx={{ mb: 1 }}>
Submission Details
</Typography>
<Typography variant="caption" color="text.secondary">Submitted By</Typography>
<Typography variant="body2" sx={{ mb: 1 }}>{proposal.submittedByName || '-'}</Typography>
<Typography variant="caption" color="text.secondary">
Submitted By
</Typography>
<Typography variant="body2" sx={{ mb: 1 }}>
{proposal.submittedByName || '-'}
</Typography>
<Typography variant="caption" color="text.secondary">Submitted</Typography>
<Typography variant="caption" color="text.secondary">
Submitted
</Typography>
<Typography variant="body2" sx={{ mb: 1 }}>
{new Date(proposal.submittedAt).toLocaleString()}
</Typography>
<Divider sx={{ my: 1.5 }} />
<Typography variant="caption" color="text.secondary">Original Scope</Typography>
<Typography variant="body2" sx={{ whiteSpace: 'pre-wrap', mb: 1, fontSize: '0.8rem' }}>
<Typography variant="caption" color="text.secondary">
Original Scope
</Typography>
<Typography
variant="body2"
sx={{ whiteSpace: 'pre-wrap', mb: 1, fontSize: '0.8rem' }}
>
{proposal.scopeOfWork}
</Typography>
{proposal.notes && (
<>
<Typography variant="caption" color="text.secondary">Notes</Typography>
<Typography variant="caption" color="text.secondary">
Notes
</Typography>
<Typography variant="body2" sx={{ whiteSpace: 'pre-wrap', fontSize: '0.8rem' }}>
{proposal.notes}
</Typography>
@ -358,14 +410,19 @@ export default function AdminWorkspace() {
<Grid size={{ xs: 12, md: hasSimilar ? 6 : 9 }}>
<Card>
<CardContent>
<Typography variant="subtitle2" sx={{ mb: 1 }}>Refined Scope</Typography>
<Typography variant="subtitle2" sx={{ mb: 1 }}>
Refined Scope
</Typography>
<TextField
fullWidth
multiline
minRows={3}
maxRows={6}
value={refinedScope}
onChange={(e) => { 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 ? (
<Box>
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', mb: 1 }}>
<Box
sx={{
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
mb: 1,
}}
>
<Skeleton variant="text" width={120} />
<Skeleton variant="rectangular" width={120} height={32} sx={{ borderRadius: 1 }} />
<Skeleton
variant="rectangular"
width={120}
height={32}
sx={{ borderRadius: 1 }}
/>
</Box>
{Array.from({ length: 4 }).map((_, i) => (
<Skeleton key={i} variant="rectangular" height={48} sx={{ mb: 0.5, borderRadius: 0.5 }} />
<Skeleton
key={i}
variant="rectangular"
height={48}
sx={{ mb: 0.5, borderRadius: 0.5 }}
/>
))}
<Box sx={{ display: 'flex', justifyContent: 'flex-end', mt: 1 }}>
<Skeleton variant="text" width={180} />
@ -404,7 +478,8 @@ export default function AdminWorkspace() {
<SimilarProposalsPanel
proposalId={id!}
onPullLineItem={(item) => {
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() {
</Grid>
{/* Action Bar */}
<Card sx={{ mt: 2, position: 'sticky', bottom: 0, zIndex: 10, bgcolor: 'rgba(255,255,255,0.96)', backdropFilter: 'blur(8px)', borderTop: '1px solid #E5E7EB', boxShadow: '0 -4px 12px rgba(17,24,39,0.06)', border: 'none' }}>
<CardContent sx={{ py: 1.5, display: 'flex', gap: 2, alignItems: 'center', flexWrap: 'wrap' }}>
<Card
sx={{
mt: 2,
position: 'sticky',
bottom: 0,
zIndex: 10,
bgcolor: 'rgba(255,255,255,0.96)',
backdropFilter: 'blur(8px)',
borderTop: '1px solid #E5E7EB',
boxShadow: '0 -4px 12px rgba(17,24,39,0.06)',
border: 'none',
}}
>
<CardContent
sx={{ py: 1.5, display: 'flex', gap: 2, alignItems: 'center', flexWrap: 'wrap' }}
>
{dirty && (
<Alert severity="info" sx={{ py: 0, flexGrow: 1 }}>Unsaved changes</Alert>
<Alert severity="info" sx={{ py: 0, flexGrow: 1 }}>
Unsaved changes
</Alert>
)}
{!dirty && <Box sx={{ flexGrow: 1 }} />}
@ -437,10 +528,7 @@ export default function AdminWorkspace() {
>
Regenerate
</Button>
<Tooltip
title={!dirty ? 'No unsaved changes' : ''}
disableHoverListener={dirty}
>
<Tooltip title={!dirty ? 'No unsaved changes' : ''} disableHoverListener={dirty}>
<span>
<Button
variant="contained"
@ -528,12 +616,16 @@ export default function AdminWorkspace() {
</DialogContent>
<DialogActions>
<Button onClick={() => setNavBlocked(false)}>Stay</Button>
<Button variant="contained" color="error" onClick={() => {
<Button
variant="contained"
color="error"
onClick={() => {
setNavBlocked(false);
const dest = pendingNavRef.current ?? '/admin';
pendingNavRef.current = null;
navigate(dest);
}}>
}}
>
Discard & Leave
</Button>
</DialogActions>
@ -549,11 +641,17 @@ export default function AdminWorkspace() {
<Box sx={{ mt: 2, p: 2, bgcolor: 'grey.50', borderRadius: 1 }}>
<Typography variant="body2">Line Items: {lineItems.length}</Typography>
<Typography variant="body2">
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,
)}
</Typography>
{proposal.vendorTotalCost != null && (
<Typography variant="body2">
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,
)}
</Typography>
)}
</Box>
@ -576,8 +674,8 @@ export default function AdminWorkspace() {
<DialogTitle>Mark as Sent</DialogTitle>
<DialogContent>
<Typography>
Mark <strong>{proposal.proposalNumber}</strong> as sent to {proposal.customerName}?
This action cannot be undone.
Mark <strong>{proposal.proposalNumber}</strong> as sent to {proposal.customerName}? This
action cannot be undone.
</Typography>
{/* PR4-B: surface delivery behavior — server emails PDF if customer has a contact email */}
<Alert severity="info" sx={{ mt: 2 }}>
@ -602,8 +700,8 @@ export default function AdminWorkspace() {
<DialogTitle>Create Revision</DialogTitle>
<DialogContent>
<Typography>
Create a new revision of <strong>{proposal.proposalNumber}</strong>?
The current version will be marked as revised and a new editable copy will be created.
Create a new revision of <strong>{proposal.proposalNumber}</strong>? The current version
will be marked as revised and a new editable copy will be created.
</Typography>
</DialogContent>
<DialogActions>
@ -623,8 +721,8 @@ export default function AdminWorkspace() {
<DialogTitle>Return to Review</DialogTitle>
<DialogContent>
<Typography>
Return <strong>{proposal.proposalNumber}</strong> to In Review status?
This will clear the approval and allow further edits to line items and scope.
Return <strong>{proposal.proposalNumber}</strong> to In Review status? This will clear
the approval and allow further edits to line items and scope.
</Typography>
</DialogContent>
<DialogActions>

View file

@ -134,7 +134,9 @@ export default function LoginPage() {
>
<Box sx={{ textAlign: 'left', flex: 1 }}>
<Box>SysAdmin</Box>
<Box sx={{ fontSize: 'var(--text-xs)', fontWeight: 400, opacity: 0.8 }}>Full access</Box>
<Box sx={{ fontSize: 'var(--text-xs)', fontWeight: 400, opacity: 0.8 }}>
Full access
</Box>
</Box>
</Button>
<Button
@ -147,7 +149,9 @@ export default function LoginPage() {
>
<Box sx={{ textAlign: 'left', flex: 1 }}>
<Box>Admin</Box>
<Box sx={{ fontSize: 'var(--text-xs)', fontWeight: 400, opacity: 0.8 }}>Review, price, and approve proposals</Box>
<Box sx={{ fontSize: 'var(--text-xs)', fontWeight: 400, opacity: 0.8 }}>
Review, price, and approve proposals
</Box>
</Box>
</Button>
<Button
@ -160,7 +164,9 @@ export default function LoginPage() {
>
<Box sx={{ textAlign: 'left', flex: 1 }}>
<Box>Dispatcher</Box>
<Box sx={{ fontSize: 'var(--text-xs)', fontWeight: 400, opacity: 0.8 }}>Submit and track proposals</Box>
<Box sx={{ fontSize: 'var(--text-xs)', fontWeight: 400, opacity: 0.8 }}>
Submit and track proposals
</Box>
</Box>
</Button>
</Stack>

View file

@ -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 (
<Card sx={{ borderRadius: '8px', transition: 'border-color 120ms ease', '&:hover': { borderColor: '#9CA3AF' } }}>
<Card
sx={{
borderRadius: '8px',
transition: 'border-color 120ms ease',
'&:hover': { borderColor: '#9CA3AF' },
}}
>
<CardContent sx={{ textAlign: 'center', py: 2.5 }}>
<Box sx={{ display: 'flex', justifyContent: 'center', mb: 1, color: 'var(--primary)' }}>{icon}</Box>
<Typography sx={{ fontSize: '12px', color: '#64748B', textTransform: 'uppercase', letterSpacing: '0.06em', fontWeight: 600, mb: 1 }}>
<Box sx={{ display: 'flex', justifyContent: 'center', mb: 1, color: 'var(--primary)' }}>
{icon}
</Box>
<Typography
sx={{
fontSize: '12px',
color: '#64748B',
textTransform: 'uppercase',
letterSpacing: '0.06em',
fontWeight: 600,
mb: 1,
}}
>
{label}
</Typography>
<Typography sx={{ fontSize: '30px', fontWeight: 700, color: 'var(--foreground)', letterSpacing: '-0.02em' }}>
<Typography
sx={{
fontSize: '30px',
fontWeight: 700,
color: 'var(--foreground)',
letterSpacing: '-0.02em',
}}
>
{value}
</Typography>
{subtitle && (
<Typography sx={{ fontSize: '11px', color: '#94A3B8', fontWeight: 500, marginTop: '4px' }}>
<Typography
sx={{ fontSize: '11px', color: '#94A3B8', fontWeight: 500, marginTop: '4px' }}
>
{subtitle}
</Typography>
)}
@ -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 ? (
<Skeleton variant="rectangular" height={120} sx={{ borderRadius: 2 }} />
) : (
<KpiCard icon={<DescriptionIcon fontSize="large" />} label="Total Submitted" value={String(total)} subtitle="All time" />
<KpiCard
icon={<DescriptionIcon fontSize="large" />}
label="Total Submitted"
value={String(total)}
subtitle="All time"
/>
)}
</Grid>
<Grid size={{ xs: 6, sm: 3 }}>
{statsLoading ? (
<Skeleton variant="rectangular" height={120} sx={{ borderRadius: 2 }} />
) : (
<KpiCard icon={<HourglassTopIcon fontSize="large" />} label="In Review" value={String(counts.inReview)} subtitle="Awaiting pricing" />
<KpiCard
icon={<HourglassTopIcon fontSize="large" />}
label="In Review"
value={String(counts.inReview)}
subtitle="Awaiting pricing"
/>
)}
</Grid>
<Grid size={{ xs: 6, sm: 3 }}>
{statsLoading ? (
<Skeleton variant="rectangular" height={120} sx={{ borderRadius: 2 }} />
) : (
<KpiCard icon={<CheckCircleIcon fontSize="large" />} label="Approved" value={String(counts.approved)} subtitle="Ready to send" />
<KpiCard
icon={<CheckCircleIcon fontSize="large" />}
label="Approved"
value={String(counts.approved)}
subtitle="Ready to send"
/>
)}
</Grid>
<Grid size={{ xs: 6, sm: 3 }}>
{statsLoading ? (
<Skeleton variant="rectangular" height={120} sx={{ borderRadius: 2 }} />
) : (
<KpiCard icon={<SendIcon fontSize="large" />} label="Sent" value={String(counts.sent)} subtitle="Delivered to customer" />
<KpiCard
icon={<SendIcon fontSize="large" />}
label="Sent"
value={String(counts.sent)}
subtitle="Delivered to customer"
/>
)}
</Grid>
</>
@ -131,7 +196,9 @@ export default function Dashboard() {
<Card>
<CardContent sx={{ pb: 1 }}>
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', mb: 2 }}>
<Box
sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', mb: 2 }}
>
<Typography variant="h6">Recent Proposals</Typography>
<Button size="small" onClick={() => navigate('/proposals')}>
View All
@ -155,7 +222,9 @@ export default function Dashboard() {
? Array.from({ length: 5 }).map((_, i) => (
<TableRow key={i}>
{Array.from({ length: 6 }).map((_, j) => (
<TableCell key={j}><Skeleton variant="text" /></TableCell>
<TableCell key={j}>
<Skeleton variant="text" />
</TableCell>
))}
</TableRow>
))
@ -170,7 +239,11 @@ export default function Dashboard() {
<TableCell>{row.customerName}</TableCell>
<TableCell>{row.serviceCategory}</TableCell>
<TableCell>
<Chip label={row.status} size="small" sx={STATUS_CHIP_STYLES[row.status]} />
<Chip
label={row.status}
size="small"
sx={STATUS_CHIP_STYLES[row.status]}
/>
</TableCell>
<TableCell align="right">{formatCurrency(row.totalBidAmount)}</TableCell>
<TableCell>

View file

@ -73,11 +73,7 @@ export default function ProposalDetailPage() {
<Typography variant="h5" sx={{ flexGrow: 1 }}>
{proposal.proposalNumber}
</Typography>
<Chip
label={proposal.status}
size="medium"
sx={STATUS_CHIP_STYLES[proposal.status]}
/>
<Chip label={proposal.status} size="medium" sx={STATUS_CHIP_STYLES[proposal.status]} />
</Box>
<Card sx={{ mb: 3 }}>
@ -87,7 +83,10 @@ export default function ProposalDetailPage() {
</Typography>
{proposal.status === 'Revised' ? (
<Box sx={{ textAlign: 'center', py: 1 }}>
<Chip label="Revised — a new revision has been created" sx={STATUS_CHIP_STYLES['Revised']} />
<Chip
label="Revised — a new revision has been created"
sx={STATUS_CHIP_STYLES['Revised']}
/>
</Box>
) : (
<Stepper activeStep={activeStep >= 0 ? activeStep : 0} alternativeLabel>

View file

@ -25,7 +25,12 @@ import {
} from '../../../domain/proposals/schemas';
import { useSiteSearch } from '../../../domain/sites/use-cases';
import { useCustomerSearch } from '../../../domain/customers/use-cases';
import { SERVICE_CATEGORIES, PRIORITIES, PRIORITY_LABELS, DEBOUNCE_AUTOCOMPLETE } from '../../../constants';
import {
SERVICE_CATEGORIES,
PRIORITIES,
PRIORITY_LABELS,
DEBOUNCE_AUTOCOMPLETE,
} from '../../../constants';
import { useDebouncedValue } from '../../../hooks/useDebouncedValue';
const MAX_FILE_SIZE_BYTES = 25 * 1024 * 1024;
@ -34,13 +39,7 @@ const ALLOWED_MIME_TYPES = ['application/pdf'];
export default function ProposalFormPage() {
const navigate = useNavigate();
const {
register,
handleSubmit,
control,
setValue,
watch,
} = useForm<CreateProposalFormValues>({
const { register, handleSubmit, control, setValue, watch } = useForm<CreateProposalFormValues>({
resolver: zodResolver(createProposalFormSchema),
defaultValues: {
workOrderNumber: '',
@ -91,9 +90,7 @@ export default function ProposalFormPage() {
].filter(Boolean);
const address = addressParts.join(', ');
const display =
manualSiteCode && address
? `${manualSiteCode} — ${address}`
: manualSiteCode || address;
manualSiteCode && address ? `${manualSiteCode} — ${address}` : manualSiteCode || address;
setValue('customerAddress', display);
}, [manualEntry, manualSiteCode, manualStreet, manualCity, manualState, manualZip, setValue]);
@ -106,7 +103,7 @@ export default function ProposalFormPage() {
onSuccess: (proposal) => {
navigate(`/proposals/${proposal.id}`);
},
}
},
);
};
@ -135,12 +132,21 @@ export default function ProposalFormPage() {
{ label: 'Specify category', ok: serviceCategory !== 'Other' || !!customCategory.trim() },
{
// Fix: WEB-M3 — show minimum length requirement in missing fields summary
label: !scopeOfWork.trim() ? 'Scope of Work' : `Scope of Work (min ${MIN_SCOPE_LENGTH} chars)`,
label: !scopeOfWork.trim()
? 'Scope of Work'
: `Scope of Work (min ${MIN_SCOPE_LENGTH} chars)`,
ok: scopeOfWork.trim().length >= MIN_SCOPE_LENGTH,
},
];
return checks.filter((c) => !c.ok).map((c) => c.label);
}, [workOrderNumber, customerName, customerAddress, serviceCategory, scopeOfWork, customCategory]);
}, [
workOrderNumber,
customerName,
customerAddress,
serviceCategory,
scopeOfWork,
customCategory,
]);
const isValid = missingFields.length === 0;
@ -153,7 +159,9 @@ export default function ProposalFormPage() {
<Box component="form" onSubmit={handleSubmit(onSubmit)}>
<Card sx={{ mb: 2 }}>
<CardContent>
<Typography variant="subtitle1" sx={{ fontWeight: 600, mb: 2 }}>Job Details</Typography>
<Typography variant="subtitle1" sx={{ fontWeight: 600, mb: 2 }}>
Job Details
</Typography>
<Grid container spacing={3}>
<Grid size={{ xs: 12, sm: 6, md: 3 }}>
<TextField
@ -164,11 +172,7 @@ export default function ProposalFormPage() {
/>
</Grid>
<Grid size={{ xs: 12, sm: 6, md: 3 }}>
<TextField
label="Purchase Order Number"
fullWidth
{...register('poNumber')}
/>
<TextField label="Purchase Order Number" fullWidth {...register('poNumber')} />
</Grid>
{/* PR4: freeSolo customer search — dispatchers can pick an existing
customer or type a new name (customerName is a free string). */}
@ -191,7 +195,7 @@ export default function ProposalFormPage() {
}
}}
onChange={(_, value) => {
const selected = typeof value === 'string' ? value : value ?? '';
const selected = typeof value === 'string' ? value : (value ?? '');
setValue('customerName', selected);
setCustomerInputValue(selected);
}}
@ -252,7 +256,9 @@ export default function ProposalFormPage() {
<Card sx={{ mb: 2 }}>
<CardContent>
<Typography variant="subtitle1" sx={{ fontWeight: 600, mb: 2 }}>Site & Location</Typography>
<Typography variant="subtitle1" sx={{ fontWeight: 600, mb: 2 }}>
Site & Location
</Typography>
{manualEntry ? (
<Box>
<Grid container spacing={2}>
@ -327,9 +333,7 @@ export default function ProposalFormPage() {
options={siteOptions}
filterOptions={(x) => x}
getOptionLabel={(opt) =>
opt.fullAddress
? `${opt.siteCode} — ${opt.fullAddress}`
: opt.siteCode
opt.fullAddress ? `${opt.siteCode} — ${opt.fullAddress}` : opt.siteCode
}
loading={siteLoading}
inputValue={siteInputValue}
@ -394,7 +398,9 @@ export default function ProposalFormPage() {
<Card sx={{ mb: 2 }}>
<CardContent>
<Typography variant="subtitle1" sx={{ fontWeight: 600, mb: 2 }}>Work Details</Typography>
<Typography variant="subtitle1" sx={{ fontWeight: 600, mb: 2 }}>
Work Details
</Typography>
<Grid container spacing={3}>
<Grid size={{ xs: 12, sm: 6 }}>
<Controller
@ -476,13 +482,18 @@ export default function ProposalFormPage() {
setVendorFile(null);
return;
}
if (!file.name.toLowerCase().endsWith('.pdf') || !ALLOWED_MIME_TYPES.includes(file.type)) {
if (
!file.name.toLowerCase().endsWith('.pdf') ||
!ALLOWED_MIME_TYPES.includes(file.type)
) {
toast.error('Only PDF files are accepted.');
e.target.value = '';
return;
}
if (file.size > MAX_FILE_SIZE_BYTES) {
toast.error('File size exceeds the 25 MB limit. Please choose a smaller file.');
toast.error(
'File size exceeds the 25 MB limit. Please choose a smaller file.',
);
e.target.value = '';
return;
}

View file

@ -22,7 +22,12 @@ import SearchIcon from '@mui/icons-material/Search';
import AddCircleIcon from '@mui/icons-material/AddCircle';
import { useProposals } from '../../../domain/proposals/use-cases';
import { useDebouncedValue } from '../../../hooks/useDebouncedValue';
import { STATUS_CHIP_STYLES, DEBOUNCE_SEARCH, DEFAULT_PAGE, DEFAULT_PAGE_SIZE } from '../../../constants';
import {
STATUS_CHIP_STYLES,
DEBOUNCE_SEARCH,
DEFAULT_PAGE,
DEFAULT_PAGE_SIZE,
} from '../../../constants';
import { formatBidAmount, formatDate, formatDateFull } from '../../../lib/format';
import { useDocumentTitle } from '../../../hooks/useDocumentTitle';
@ -133,13 +138,22 @@ export default function ProposalListPage() {
/>
</TableCell>
<TableCell align="right">
{row.totalBidAmount > 0
? formatBidAmount(row.totalBidAmount)
: <Typography variant="body2" component="span" sx={{ color: 'var(--color-text-muted)' }}>Not priced</Typography>
}
{row.totalBidAmount > 0 ? (
formatBidAmount(row.totalBidAmount)
) : (
<Typography
variant="body2"
component="span"
sx={{ color: 'var(--color-text-muted)' }}
>
Not priced
</Typography>
)}
</TableCell>
<TableCell>
<span title={formatDateFull(row.submittedAt)}>{formatDate(row.submittedAt)}</span>
<span title={formatDateFull(row.submittedAt)}>
{formatDate(row.submittedAt)}
</span>
</TableCell>
</TableRow>
))}

View file

@ -19,10 +19,12 @@ import { STORAGE_KEY_TOKEN } from '../../constants';
function createToken(expOffsetSeconds: number): string {
const header = btoa(JSON.stringify({ alg: 'HS256' }));
const payload = btoa(JSON.stringify({
const payload = btoa(
JSON.stringify({
sub: 'test-user',
exp: Math.floor(Date.now() / 1000) + expOffsetSeconds,
}));
}),
);
return `${header}.${payload}.fake-signature`;
}

View file

@ -1,12 +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/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/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";
@import '@fontsource/jetbrains-mono/400.css';
@import '@fontsource/jetbrains-mono/500.css';
@import '@fontsource/jetbrains-mono/600.css';

View file

@ -1,5 +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";
@import './fonts.css';
@import './theme.css';
@import './typography.css';
@import './motion.css';

View file

@ -9,9 +9,9 @@
--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-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);

View file

@ -12,18 +12,41 @@ body {
background-color: var(--color-bg-page);
}
h1, h2, h3, h4, h5, h6 {
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; }
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);