diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml
index f698695..4378a48 100644
--- a/.github/workflows/ci.yaml
+++ b/.github/workflows/ci.yaml
@@ -27,30 +27,16 @@ jobs:
web:
name: Web Frontend Check
- uses: Sea-Haven-Industries/.github/.github/workflows/ci-typescript-cdk.yaml@main
+ # Phase 5: full frontend pipeline — format:check, build (tsc -b included),
+ # vitest, Playwright chromium smoke. Replaces the old typecheck-only job
+ # plus the standalone Web Tests job. No lint script yet (run-lint: false).
+ uses: Sea-Haven-Industries/.github/.github/workflows/ci-typescript-frontend.yaml@main
with:
working-directory: web
cache-dependency-path: web/package-lock.json
node-version: "24"
- run-cdk-synth: false
- run-conventions-check: false
-
- web-test:
- name: Web Tests
- runs-on: ubuntu-latest
- timeout-minutes: 10
- defaults:
- run:
- working-directory: web
- steps:
- - uses: actions/checkout@v7
- - uses: actions/setup-node@v4
- with:
- node-version: "24"
- cache: npm
- cache-dependency-path: web/package-lock.json
- - run: npm ci
- - run: npm test
+ run-lint: false
+ required-scripts: "format:check,build,test,test:e2e"
python:
name: Python Lint
@@ -106,7 +92,7 @@ jobs:
# required check would otherwise never report on this repo.
ci:
name: ci / ci
- needs: [dotnet, web, web-test, python, python-test, mobile, infra]
+ needs: [dotnet, web, python, python-test, mobile, infra]
if: always()
runs-on: ubuntu-latest
steps:
diff --git a/.gitignore b/.gitignore
index 88de624..58e6fa3 100644
--- a/.gitignore
+++ b/.gitignore
@@ -69,3 +69,7 @@ api/src/ProposalSystem.Api/Data/verified-sites.json
*.zip
.claude/worktrees/
.claude/agents/
+
+# Playwright (web e2e smoke)
+web/test-results/
+web/playwright-report/
diff --git a/README.md b/README.md
index 8acc442..c573544 100644
--- a/README.md
+++ b/README.md
@@ -114,13 +114,12 @@ npx cdk synth
### CI (on pull request to main)
-Seven parallel jobs calling org reusable workflows:
+Six parallel jobs calling org reusable workflows:
| Job | Workflow | What it checks |
|---|---|---|
| .NET Build & Test | `ci-dotnet.yaml` | Restore, build, test the API solution (123 xUnit tests) |
-| Web Frontend Check | `ci-typescript-cdk.yaml` | TypeScript typecheck for web |
-| Web Tests | inline job | vitest suite (26 tests — auth, interceptors, components) |
+| Web Frontend Check | `ci-typescript-frontend.yaml` | Prettier `format:check`, build (includes `tsc -b`), vitest suite, Playwright chromium smoke (dev-login → proposal list, API mocked) |
| Mobile Typecheck | `ci-typescript-cdk.yaml` | TypeScript typecheck for mobile |
| Python Lint | `ci-python-sam.yaml` | ruff check + format on lambdas/ |
| Python Tests | inline job | pytest suite (37 tests — pdf-generate, suggestions, library-ingest, internal API signing) |
diff --git a/web/.claude/skills/verify/SKILL.md b/web/.claude/skills/verify/SKILL.md
index 589ce63..d9e9154 100644
--- a/web/.claude/skills/verify/SKILL.md
+++ b/web/.claude/skills/verify/SKILL.md
@@ -15,9 +15,12 @@ Drive with Playwright resolved from the SHOC checkout (this repo doesn't ship it
using the cached headless chromium:
```js
-const { chromium } = require('/Users/adammoussa/Documents/repositories/seahaven/shoc-frontend-new/node_modules/playwright');
+const {
+ chromium,
+} = require('/Users/adammoussa/Documents/repositories/seahaven/shoc-frontend-new/node_modules/playwright');
const browser = await chromium.launch({
- executablePath: '/Users/adammoussa/Library/Caches/ms-playwright/chromium_headless_shell-1228/chrome-headless-shell-mac-arm64/chrome-headless-shell',
+ executablePath:
+ '/Users/adammoussa/Library/Caches/ms-playwright/chromium_headless_shell-1228/chrome-headless-shell-mac-arm64/chrome-headless-shell',
});
```
@@ -29,6 +32,7 @@ await ctx.addInitScript((v) => sessionStorage.setItem('proposal_system_token', v
```
Gotchas:
+
- `addInitScript` re-runs on every document load — a full-page reload re-seeds the
token, so "logged-out then reload" probes need a fresh un-seeded context.
- Dev-login buttons on /login call the real API (`/auth/dev-login`) — they fail
diff --git a/web/.prettierignore b/web/.prettierignore
new file mode 100644
index 0000000..7c08b73
--- /dev/null
+++ b/web/.prettierignore
@@ -0,0 +1,6 @@
+dist
+coverage
+node_modules
+package-lock.json
+playwright-report
+test-results
diff --git a/web/.prettierrc b/web/.prettierrc
new file mode 100644
index 0000000..554f2a3
--- /dev/null
+++ b/web/.prettierrc
@@ -0,0 +1,4 @@
+{
+ "singleQuote": true,
+ "printWidth": 100
+}
diff --git a/web/e2e/smoke.spec.ts b/web/e2e/smoke.spec.ts
new file mode 100644
index 0000000..7fa5f1d
--- /dev/null
+++ b/web/e2e/smoke.spec.ts
@@ -0,0 +1,113 @@
+// Browser smoke: dev-login → dashboard shell → proposal list (Phase 5, web CI).
+// The API is fully mocked via route interception so the smoke runs in pure CI
+// with no backend — it exercises the login flow, auth context, protected
+// routing, the app shell, and list rendering end-to-end in a real browser.
+import { test, expect, type Page } from '@playwright/test';
+
+function fakeJwt(): string {
+ const b64 = (o: object) =>
+ Buffer.from(JSON.stringify(o))
+ .toString('base64')
+ .replace(/\+/g, '-')
+ .replace(/\//g, '_')
+ .replace(/=+$/, '');
+ return `${b64({ alg: 'HS256' })}.${b64({
+ sub: 'e2e-user',
+ exp: Math.floor(Date.now() / 1000) + 3600,
+ })}.e2e-signature`;
+}
+
+const authUser = {
+ id: 'e2e-user',
+ email: 'adam@seahavenind.com',
+ displayName: 'Adam Moussa',
+ role: 'SysAdmin',
+ token: fakeJwt(),
+};
+
+const proposals = [
+ {
+ id: 'p-1',
+ proposalNumber: 'PROP-2026-001',
+ customerName: 'Harbor Point Marina',
+ workOrderNumber: 'WO-1001',
+ serviceCategory: 'HVAC',
+ priority: 'Standard',
+ status: 'InReview',
+ totalBidAmount: 12500,
+ submittedAt: '2026-07-01T12:00:00Z',
+ submittedByName: 'Adam Moussa',
+ assignedAdminName: null,
+ },
+ {
+ id: 'p-2',
+ proposalNumber: 'PROP-2026-002',
+ customerName: 'Seaside Property Group',
+ workOrderNumber: 'WO-1002',
+ serviceCategory: 'Electrical',
+ priority: 'Urgent',
+ status: 'Approved',
+ totalBidAmount: 4300,
+ submittedAt: '2026-07-05T09:30:00Z',
+ submittedByName: 'Adam Moussa',
+ assignedAdminName: 'Sarah Chen',
+ },
+];
+
+async function mockApi(page: Page) {
+ // Match on pathname, not a '**/api/**' glob: vite dev serves app modules from
+ // paths like /src/lib/api/client.ts, which such a glob would swallow.
+ // Registered first = matched last (Playwright checks routes newest-first):
+ // generic fallback so nothing the shell fetches hangs or hard-errors.
+ await page.route(
+ (url) => url.pathname.startsWith('/api/'),
+ (route) => route.fulfill({ json: {} }),
+ );
+
+ await page.route(
+ (url) => url.pathname === '/api/auth/dev-login',
+ (route) => route.fulfill({ json: authUser }),
+ );
+ await page.route(
+ (url) => url.pathname === '/api/proposals/stats',
+ (route) =>
+ route.fulfill({ json: { totalCount: 2, inReviewCount: 1, approvedCount: 1, sentCount: 0 } }),
+ );
+ await page.route(
+ (url) => url.pathname === '/api/proposals' && url.searchParams.has('page'),
+ (route) =>
+ route.fulfill({
+ json: { items: proposals, totalCount: proposals.length, page: 1, pageSize: 12 },
+ }),
+ );
+}
+
+test('dev-login lands on the dashboard and the proposal list renders', async ({ page }) => {
+ await mockApi(page);
+
+ await page.goto('/');
+ await expect(page).toHaveURL(/\/login$/);
+ await expect(page.getByText('Welcome back')).toBeVisible();
+
+ await page.getByRole('button', { name: /SysAdmin/ }).click();
+
+ // Authenticated shell: topbar user chip + dashboard content.
+ await expect(page).toHaveURL('/');
+ await expect(page.getByText('Adam Moussa').first()).toBeVisible();
+ await expect(page.getByText('Recent Proposals')).toBeVisible();
+
+ // Proposal list via sidebar nav.
+ await page.getByText('My Proposals', { exact: true }).click();
+ await expect(page).toHaveURL(/\/proposals$/);
+ await expect(page.getByRole('heading', { name: 'My Proposals' })).toBeVisible();
+ await expect(page.getByText('Harbor Point Marina')).toBeVisible();
+ await expect(page.getByText('Seaside Property Group')).toBeVisible();
+});
+
+test('unauthenticated visits are bounced to the login card', async ({ page }) => {
+ await mockApi(page);
+
+ await page.goto('/proposals');
+ await expect(page).toHaveURL(/\/login$/);
+ await expect(page.getByText('Welcome back')).toBeVisible();
+});
diff --git a/web/index.html b/web/index.html
index 7b7a1a9..1d7892e 100644
--- a/web/index.html
+++ b/web/index.html
@@ -1,4 +1,4 @@
-
+
diff --git a/web/package-lock.json b/web/package-lock.json
index 14003a6..b6fe602 100644
--- a/web/package-lock.json
+++ b/web/package-lock.json
@@ -28,6 +28,7 @@
"zod": "^4.4.3"
},
"devDependencies": {
+ "@playwright/test": "^1.61.1",
"@testing-library/jest-dom": "^6.9.1",
"@testing-library/react": "^16.3.2",
"@testing-library/user-event": "^14.6.1",
@@ -35,6 +36,7 @@
"@types/react-dom": "^19.0.0",
"@vitejs/plugin-react": "^6.0.3",
"jsdom": "^29.1.1",
+ "prettier": "^3.9.5",
"typescript": "~7.0.2",
"vite": "^8.1.3",
"vitest": "^4.1.10"
@@ -934,6 +936,22 @@
"url": "https://github.com/sponsors/Boshen"
}
},
+ "node_modules/@playwright/test": {
+ "version": "1.61.1",
+ "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.61.1.tgz",
+ "integrity": "sha512-8nKv6+0RJSL9FE4jYOEGXnPeM/Hg12qZpmqzZjRh3qM0Y7c3z1mrOTfFLids72RDQYVh9WpLEfR5WdpNX4fkig==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "dependencies": {
+ "playwright": "1.61.1"
+ },
+ "bin": {
+ "playwright": "cli.js"
+ },
+ "engines": {
+ "node": ">=18"
+ }
+ },
"node_modules/@popperjs/core": {
"version": "2.11.8",
"resolved": "https://registry.npmjs.org/@popperjs/core/-/core-2.11.8.tgz",
@@ -3170,6 +3188,53 @@
"url": "https://github.com/sponsors/jonschlinkert"
}
},
+ "node_modules/playwright": {
+ "version": "1.61.1",
+ "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.61.1.tgz",
+ "integrity": "sha512-DWnY5o3YbLWK4GovuAVwpqL+1VwGNdUGrRr++8j8PtQQzvAVZUIMjKQ90fY689sEJZJBbZVw1rXaOKSTitkzPQ==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "dependencies": {
+ "playwright-core": "1.61.1"
+ },
+ "bin": {
+ "playwright": "cli.js"
+ },
+ "engines": {
+ "node": ">=18"
+ },
+ "optionalDependencies": {
+ "fsevents": "2.3.2"
+ }
+ },
+ "node_modules/playwright-core": {
+ "version": "1.61.1",
+ "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.61.1.tgz",
+ "integrity": "sha512-h7Qlt6m4REp25qvIdvbDtVmD4LqVXfpRxhORv9L0jzETM05p4fuPJ3dKyuSXQxDSbXnmS79HAgi9589lGSpLkg==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "bin": {
+ "playwright-core": "cli.js"
+ },
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/playwright/node_modules/fsevents": {
+ "version": "2.3.2",
+ "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz",
+ "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==",
+ "dev": true,
+ "hasInstallScript": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": "^8.16.0 || ^10.6.0 || >=11.0.0"
+ }
+ },
"node_modules/postcss": {
"version": "8.5.16",
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.16.tgz",
@@ -3199,6 +3264,22 @@
"node": "^10 || ^12 || >=14"
}
},
+ "node_modules/prettier": {
+ "version": "3.9.5",
+ "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.9.5.tgz",
+ "integrity": "sha512-/FVl766LpUfB5vXgCYOYa0MeV/441Ia99AeICQIQFTY/Nw0roZwULcXpku5i1/m5kt/baz+s4Zogspd839HSMg==",
+ "dev": true,
+ "license": "MIT",
+ "bin": {
+ "prettier": "bin/prettier.cjs"
+ },
+ "engines": {
+ "node": ">=14"
+ },
+ "funding": {
+ "url": "https://github.com/prettier/prettier?sponsor=1"
+ }
+ },
"node_modules/pretty-format": {
"version": "27.5.1",
"resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-27.5.1.tgz",
diff --git a/web/package.json b/web/package.json
index 0797d84..d40ade9 100644
--- a/web/package.json
+++ b/web/package.json
@@ -8,8 +8,11 @@
"dev": "vite",
"build": "tsc -b && vite build",
"preview": "vite preview",
+ "format": "prettier --write .",
+ "format:check": "prettier --check .",
"test": "vitest run",
- "test:watch": "vitest"
+ "test:watch": "vitest",
+ "test:e2e": "playwright test"
},
"dependencies": {
"@emotion/react": "^11.14.0",
@@ -32,6 +35,7 @@
"zod": "^4.4.3"
},
"devDependencies": {
+ "@playwright/test": "^1.61.1",
"@testing-library/jest-dom": "^6.9.1",
"@testing-library/react": "^16.3.2",
"@testing-library/user-event": "^14.6.1",
@@ -39,6 +43,7 @@
"@types/react-dom": "^19.0.0",
"@vitejs/plugin-react": "^6.0.3",
"jsdom": "^29.1.1",
+ "prettier": "^3.9.5",
"typescript": "~7.0.2",
"vite": "^8.1.3",
"vitest": "^4.1.10"
diff --git a/web/playwright.config.ts b/web/playwright.config.ts
new file mode 100644
index 0000000..1737064
--- /dev/null
+++ b/web/playwright.config.ts
@@ -0,0 +1,27 @@
+// Browser smoke config — mirrors shoc-frontend-new/playwright.config.ts.
+import { defineConfig, devices } from '@playwright/test';
+
+export default defineConfig({
+ testDir: './e2e',
+ fullyParallel: true,
+ forbidOnly: Boolean(process.env.CI),
+ retries: process.env.CI ? 2 : 0,
+ workers: process.env.CI ? 1 : undefined,
+ reporter: process.env.CI ? 'github' : 'list',
+ use: {
+ baseURL: 'http://localhost:4173',
+ trace: 'on-first-retry',
+ },
+ projects: [
+ {
+ name: 'chromium',
+ use: { ...devices['Desktop Chrome'] },
+ },
+ ],
+ webServer: {
+ command: 'npm run dev -- --port 4173 --strictPort',
+ url: 'http://localhost:4173',
+ reuseExistingServer: false,
+ timeout: 120_000,
+ },
+});
diff --git a/web/src/App.tsx b/web/src/App.tsx
index 30d0119..407403f 100644
--- a/web/src/App.tsx
+++ b/web/src/App.tsx
@@ -38,9 +38,26 @@ export default function App() {
element={
{/* SHOC shell composition (ADR 0003): sidebar column + [topbar / scrolling main] */}
-
+
-
+
setSidebarOpen(!sidebarOpen)} />
- } />
- } />
- } />
- } />
+ } />
+ } />
+ } />
+ } />
- {/* Admin Routes */}
- } />
- } />
- } />
- } />
- } />
-
-
-
-
-
- User Management
-
- Users and roles are currently managed in AWS Cognito. Contact the system administrator to update access.
-
-
-
-
-
-
- } />
+ {/* Admin Routes */}
+
+
+
+ }
+ />
+
+
+
+ }
+ />
+
+
+
+ }
+ />
+
+
+
+ }
+ />
+
+
+
+ }
+ />
+
+
+
+
+
+
+ User Management
+
+
+ Users and roles are currently managed in AWS Cognito. Contact the
+ system administrator to update access.
+
+
+
+
+
+
+ }
+ />
} />
diff --git a/web/src/components/ErrorBoundary.tsx b/web/src/components/ErrorBoundary.tsx
index c5b674a..c351040 100644
--- a/web/src/components/ErrorBoundary.tsx
+++ b/web/src/components/ErrorBoundary.tsx
@@ -24,7 +24,17 @@ export default class ErrorBoundary extends React.Component {
render() {
if (this.state.hasError) {
return (
-
+
Something went wrong
An unexpected error occurred. Please try refreshing the page.
diff --git a/web/src/components/ProtectedRoute.tsx b/web/src/components/ProtectedRoute.tsx
index 16656bf..20625f2 100644
--- a/web/src/components/ProtectedRoute.tsx
+++ b/web/src/components/ProtectedRoute.tsx
@@ -7,7 +7,9 @@ export default function ProtectedRoute({ children }: { children: React.ReactNode
if (isLoading) {
return (
-
+
);
diff --git a/web/src/components/Sidebar.tsx b/web/src/components/Sidebar.tsx
index 00808f8..c65331d 100644
--- a/web/src/components/Sidebar.tsx
+++ b/web/src/components/Sidebar.tsx
@@ -35,13 +35,9 @@ const adminNav: NavEntry[] = [
{ label: 'Pricing Library', path: '/admin/pricing-library', icon: BookOpen },
];
-const sysadminNav: NavEntry[] = [
- { label: 'Users', path: '/admin/users', icon: Users },
-];
+const sysadminNav: NavEntry[] = [{ label: 'Users', path: '/admin/users', icon: Users }];
-const ALL_NAV_PATHS = new Set(
- [...dispatcherNav, ...adminNav, ...sysadminNav].map(n => n.path)
-);
+const ALL_NAV_PATHS = new Set([...dispatcherNav, ...adminNav, ...sysadminNav].map((n) => n.path));
function isNavActive(pathname: string, itemPath: string): boolean {
if (pathname === itemPath) return true;
@@ -115,7 +111,10 @@ function NavItem({
)}
{!collapsed && (
-
+
{item.label}
)}
@@ -168,7 +167,9 @@ export default function Sidebar({
);
const groupDivider = (
-
+
);
const renderGroup = (label: string, items: NavEntry[], first: boolean) => (
diff --git a/web/src/components/Topbar.tsx b/web/src/components/Topbar.tsx
index 3db385b..b8f48ad 100644
--- a/web/src/components/Topbar.tsx
+++ b/web/src/components/Topbar.tsx
@@ -7,7 +7,12 @@ export default function Topbar({ onToggleSidebar }: { onToggleSidebar: () => voi
const { user, logout } = useAuth();
const initials = user?.displayName
- ? user.displayName.split(' ').map(n => n[0]).join('').toUpperCase().slice(0, 2)
+ ? user.displayName
+ .split(' ')
+ .map((n) => n[0])
+ .join('')
+ .toUpperCase()
+ .slice(0, 2)
: 'U';
const isDevMode = !import.meta.env.VITE_COGNITO_DOMAIN;
@@ -24,7 +29,16 @@ export default function Topbar({ onToggleSidebar }: { onToggleSidebar: () => voi
boxShadow: 'var(--color-header-shadow)',
}}
>
-
+
voi
{initials}
-
+
{user.displayName}
-
+
{user.role}
@@ -101,7 +119,10 @@ export default function Topbar({ onToggleSidebar }: { onToggleSidebar: () => voi
aria-label="Log out"
sx={{
color: 'var(--color-header-text-muted)',
- '&:hover': { color: 'var(--color-header-text)', backgroundColor: 'var(--color-header-surface-hover)' },
+ '&:hover': {
+ color: 'var(--color-header-text)',
+ backgroundColor: 'var(--color-header-surface-hover)',
+ },
}}
>
diff --git a/web/src/components/__tests__/ProtectedRoute.test.tsx b/web/src/components/__tests__/ProtectedRoute.test.tsx
index ef9ab4d..0ab7a2e 100644
--- a/web/src/components/__tests__/ProtectedRoute.test.tsx
+++ b/web/src/components/__tests__/ProtectedRoute.test.tsx
@@ -87,7 +87,13 @@ describe('RoleGuard', () => {
it('QA-C5: redirects to / when user role is not in allowed list (dispatcher cannot access admin)', () => {
mockUseAuth.mockReturnValue({
isAuthenticated: true,
- user: { id: '2', email: 'dispatch@test.com', displayName: 'Dispatcher', role: 'Dispatcher', token: 'tok' },
+ user: {
+ id: '2',
+ email: 'dispatch@test.com',
+ displayName: 'Dispatcher',
+ role: 'Dispatcher',
+ token: 'tok',
+ },
isLoading: false,
});
@@ -121,7 +127,13 @@ describe('RoleGuard', () => {
it('QA-C5: SysAdmin can access sysadmin-only routes', () => {
mockUseAuth.mockReturnValue({
isAuthenticated: true,
- user: { id: '3', email: 'sysadmin@test.com', displayName: 'SysAdmin', role: 'SysAdmin', token: 'tok' },
+ user: {
+ id: '3',
+ email: 'sysadmin@test.com',
+ displayName: 'SysAdmin',
+ role: 'SysAdmin',
+ token: 'tok',
+ },
isLoading: false,
});
diff --git a/web/src/components/admin/LineItemEditor.tsx b/web/src/components/admin/LineItemEditor.tsx
index 0f7b7fb..34bbc30 100644
--- a/web/src/components/admin/LineItemEditor.tsx
+++ b/web/src/components/admin/LineItemEditor.tsx
@@ -125,11 +125,14 @@ export default function LineItemEditor({ items, onChange, disabled = false }: Li
return (
-
- Line Items ({items.length})
-
+ Line Items ({items.length})
{!disabled && (
- } onClick={addItem}>
+ }
+ onClick={addItem}
+ >
Add Line Item
)}
@@ -155,11 +158,21 @@ export default function LineItemEditor({ items, onChange, disabled = false }: Li
{!disabled && (
- moveItem(index, -1)} disabled={index === 0} aria-label={`Move item ${index + 1} up`}>
+ moveItem(index, -1)}
+ disabled={index === 0}
+ aria-label={`Move item ${index + 1} up`}
+ >
{index + 1}
- moveItem(index, 1)} disabled={index === items.length - 1} aria-label={`Move item ${index + 1} down`}>
+ moveItem(index, 1)}
+ disabled={index === items.length - 1}
+ aria-label={`Move item ${index + 1} down`}
+ >
@@ -200,7 +213,9 @@ export default function LineItemEditor({ items, onChange, disabled = false }: Li
fullWidth
>
{UNITS.map((u) => (
-
+
))}
@@ -209,7 +224,9 @@ export default function LineItemEditor({ items, onChange, disabled = false }: Li
size="small"
type="number"
value={item.unitPrice ?? ''}
- onChange={(e) => updateItem(index, 'unitPrice', parseFloat(e.target.value) || 0)}
+ onChange={(e) =>
+ updateItem(index, 'unitPrice', parseFloat(e.target.value) || 0)
+ }
disabled={disabled || item.pricingMode === 'TotalPrice'}
variant="standard"
slotProps={{ htmlInput: { min: 0, step: 0.01 } }}
@@ -220,7 +237,9 @@ export default function LineItemEditor({ items, onChange, disabled = false }: Li
size="small"
type="number"
value={item.totalPrice}
- onChange={(e) => updateItem(index, 'totalPrice', parseFloat(e.target.value) || 0)}
+ onChange={(e) =>
+ updateItem(index, 'totalPrice', parseFloat(e.target.value) || 0)
+ }
disabled={disabled || item.pricingMode === 'UnitPrice'}
variant="standard"
slotProps={{ htmlInput: { min: 0, step: 0.01 } }}
@@ -272,7 +291,12 @@ export default function LineItemEditor({ items, onChange, disabled = false }: Li
- Proposal Total: {subtotal === 0 ? 'Pending pricing' : new Intl.NumberFormat('en-US', { style: 'currency', currency: 'USD' }).format(subtotal)}
+ Proposal Total:{' '}
+ {subtotal === 0
+ ? 'Pending pricing'
+ : new Intl.NumberFormat('en-US', { style: 'currency', currency: 'USD' }).format(
+ subtotal,
+ )}
diff --git a/web/src/components/admin/SimilarProposalsPanel.tsx b/web/src/components/admin/SimilarProposalsPanel.tsx
index fec0149..759bee3 100644
--- a/web/src/components/admin/SimilarProposalsPanel.tsx
+++ b/web/src/components/admin/SimilarProposalsPanel.tsx
@@ -20,13 +20,19 @@ interface SimilarProposalsPanelProps {
disabled?: boolean;
}
-export default function SimilarProposalsPanel({ proposalId, onPullLineItem, disabled }: SimilarProposalsPanelProps) {
+export default function SimilarProposalsPanel({
+ proposalId,
+ onPullLineItem,
+ disabled,
+}: SimilarProposalsPanelProps) {
const { data: similar, isLoading } = useSimilarProposals(proposalId);
if (isLoading) {
return (
- Similar Proposals
+
+ Similar Proposals
+
{Array.from({ length: 3 }).map((_, i) => (
))}
@@ -74,7 +80,9 @@ export default function SimilarProposalsPanel({ proposalId, onPullLineItem, disa
}}
>
- {li.description}
+
+ {li.description}
+
{li.quantity} {li.unit} @ {formatCurrency(li.totalPrice)}
diff --git a/web/src/components/admin/VendorDataPanel.tsx b/web/src/components/admin/VendorDataPanel.tsx
index a3e956d..345435b 100644
--- a/web/src/components/admin/VendorDataPanel.tsx
+++ b/web/src/components/admin/VendorDataPanel.tsx
@@ -16,19 +16,27 @@ export default function VendorDataPanel({ proposalId }: VendorDataPanelProps) {
if (!vendors || vendors.length === 0) {
return (
- Vendor Data
- No vendor proposals attached
+
+ Vendor Data
+
+
+ No vendor proposals attached
+
);
}
return (
- Vendor Data
+
+ Vendor Data
+
{vendors.map((vp) => (
- {vp.vendorName}
+
+ {vp.vendorName}
+
- {vp.fileName}
+
+ {vp.fileName}
+
{vp.totalVendorCost > 0 && (
diff --git a/web/src/constants/index.ts b/web/src/constants/index.ts
index aed79d1..7eb4b33 100644
--- a/web/src/constants/index.ts
+++ b/web/src/constants/index.ts
@@ -18,7 +18,14 @@ export const STORAGE_KEY_TOKEN = 'proposal_system_token';
export const STORAGE_KEY_SIDEBAR = 'sidebarOpen';
export const PROPOSAL_STATUSES = ['Draft', 'InReview', 'Approved', 'Sent', 'Revised'] as const;
-export const SERVICE_CATEGORIES = ['HVAC', 'Plumbing', 'Electrical', 'General', 'Renovation', 'Other'] as const;
+export const SERVICE_CATEGORIES = [
+ 'HVAC',
+ 'Plumbing',
+ 'Electrical',
+ 'General',
+ 'Renovation',
+ 'Other',
+] as const;
export const PRIORITIES = ['Standard', 'Urgent', 'Emergency'] as const;
export const STATUS_LABELS: Record = {
@@ -30,7 +37,10 @@ export const STATUS_LABELS: Record = {
};
// Sea Haven Ops status chips — flat fill + thin border + bold label (tokens/base.css).
-export const STATUS_CHIP_STYLES: Record = {
+export const STATUS_CHIP_STYLES: Record<
+ string,
+ { bgcolor: string; color: string; border: string }
+> = {
Draft: { bgcolor: '#F3F4F6', color: '#374151', border: '1px solid #D1D5DB' },
InReview: { bgcolor: '#E0F2FE', color: '#075985', border: '1px solid #7DD3FC' },
Approved: { bgcolor: '#DCFCE7', color: '#166534', border: '1px solid #86EFAC' },
@@ -47,7 +57,10 @@ export const PRIORITY_LABELS: Record = {
Emergency: 'Emergency',
};
-export const PRIORITY_CHIP_STYLES: Record = {
+export const PRIORITY_CHIP_STYLES: Record<
+ string,
+ { bgcolor: string; color: string; border: string }
+> = {
Standard: { bgcolor: '#F3F4F6', color: '#374151', border: '1px solid #D1D5DB' },
Urgent: { bgcolor: '#FFEDD5', color: '#9A3412', border: '1px solid #FED7AA' },
Emergency: { bgcolor: '#FEE2E2', color: '#991B1B', border: '1px solid #FECACA' },
diff --git a/web/src/domain/__tests__/admin.use-cases.test.tsx b/web/src/domain/__tests__/admin.use-cases.test.tsx
index fc734f6..50a7ab3 100644
--- a/web/src/domain/__tests__/admin.use-cases.test.tsx
+++ b/web/src/domain/__tests__/admin.use-cases.test.tsx
@@ -82,9 +82,7 @@ describe('useApproveProposal', () => {
});
it('toasts the failure and skips invalidation when approval is rejected', async () => {
- vi.mocked(adminApi.approveProposal).mockRejectedValue(
- new Error('proposal is not in review')
- );
+ vi.mocked(adminApi.approveProposal).mockRejectedValue(new Error('proposal is not in review'));
const { queryClient, wrapper } = createQueryHarness();
const invalidateSpy = vi.spyOn(queryClient, 'invalidateQueries');
diff --git a/web/src/domain/__tests__/customers.use-cases.test.tsx b/web/src/domain/__tests__/customers.use-cases.test.tsx
index 059be69..63bf9c7 100644
--- a/web/src/domain/__tests__/customers.use-cases.test.tsx
+++ b/web/src/domain/__tests__/customers.use-cases.test.tsx
@@ -6,11 +6,7 @@ import { describe, it, expect, vi, beforeEach } from 'vitest';
import { renderHook, waitFor } from '@testing-library/react';
import { toast } from 'react-toastify';
import { createQueryHarness } from './hookTestUtils';
-import {
- customersKeys,
- useCreateCustomer,
- useUpdateCustomer,
-} from '../customers/use-cases';
+import { customersKeys, useCreateCustomer, useUpdateCustomer } from '../customers/use-cases';
import { customersApi } from '../customers/api';
import type { Customer } from '../customers/types';
diff --git a/web/src/domain/__tests__/proposals.use-cases.test.tsx b/web/src/domain/__tests__/proposals.use-cases.test.tsx
index cc57edf..69699ae 100644
--- a/web/src/domain/__tests__/proposals.use-cases.test.tsx
+++ b/web/src/domain/__tests__/proposals.use-cases.test.tsx
@@ -5,13 +5,14 @@ import { describe, it, expect, vi, beforeEach } from 'vitest';
import { renderHook, waitFor } from '@testing-library/react';
import { toast } from 'react-toastify';
import { createQueryHarness } from './hookTestUtils';
-import {
- proposalsKeys,
- useProposals,
- useCreateProposal,
-} from '../proposals/use-cases';
+import { proposalsKeys, useProposals, useCreateProposal } from '../proposals/use-cases';
import { proposalsApi } from '../proposals/api';
-import type { ProposalDetail, ProposalFilters, ProposalListItem, PagedResponse } from '../proposals/types';
+import type {
+ ProposalDetail,
+ ProposalFilters,
+ ProposalListItem,
+ PagedResponse,
+} from '../proposals/types';
vi.mock('../proposals/api', () => ({
proposalsApi: {
diff --git a/web/src/domain/__tests__/schemas.test.ts b/web/src/domain/__tests__/schemas.test.ts
index 6497fe1..ca2045f 100644
--- a/web/src/domain/__tests__/schemas.test.ts
+++ b/web/src/domain/__tests__/schemas.test.ts
@@ -40,15 +40,15 @@ describe('createProposalFormSchema', () => {
});
it('requires customCategory when serviceCategory is Other', () => {
- expect(
- createProposalFormSchema.safeParse({ ...valid, serviceCategory: 'Other' }).success
- ).toBe(false);
+ expect(createProposalFormSchema.safeParse({ ...valid, serviceCategory: 'Other' }).success).toBe(
+ false,
+ );
expect(
createProposalFormSchema.safeParse({
...valid,
serviceCategory: 'Other',
customCategory: 'Roofing',
- }).success
+ }).success,
).toBe(true);
});
@@ -74,19 +74,21 @@ describe('customerFormSchema', () => {
it('rejects a malformed email but accepts a valid one', () => {
expect(customerFormSchema.safeParse({ ...valid, contactEmail: 'nope' }).success).toBe(false);
- expect(
- customerFormSchema.safeParse({ ...valid, contactEmail: 'a@b.com' }).success
- ).toBe(true);
+ expect(customerFormSchema.safeParse({ ...valid, contactEmail: 'a@b.com' }).success).toBe(true);
});
it('maps to addresses[] preserving extra existing addresses', () => {
- const request = toCustomerRequest(
- { name: ' Acme ', address: ' 2 New St ', contactEmail: '' },
- ['1 Old St', '3 Extra Ave']
- );
+ const request = toCustomerRequest({ name: ' Acme ', address: ' 2 New St ', contactEmail: '' }, [
+ '1 Old St',
+ '3 Extra Ave',
+ ]);
// contactEmail is ALWAYS sent — empty string clears the stored email
// (code-review finding, Phase 4: omission made emails impossible to clear).
- expect(request).toEqual({ name: 'Acme', addresses: ['2 New St', '3 Extra Ave'], contactEmail: '' });
+ expect(request).toEqual({
+ name: 'Acme',
+ addresses: ['2 New St', '3 Extra Ave'],
+ contactEmail: '',
+ });
});
});
@@ -102,17 +104,21 @@ describe('pricingLibraryItemFormSchema', () => {
it('accepts empty unitPrice and rejects negatives/non-numbers', () => {
expect(pricingLibraryItemFormSchema.safeParse(valid).success).toBe(true);
- expect(
- pricingLibraryItemFormSchema.safeParse({ ...valid, unitPrice: '-4' }).success
- ).toBe(false);
- expect(
- pricingLibraryItemFormSchema.safeParse({ ...valid, unitPrice: 'abc' }).success
- ).toBe(false);
+ expect(pricingLibraryItemFormSchema.safeParse({ ...valid, unitPrice: '-4' }).success).toBe(
+ false,
+ );
+ expect(pricingLibraryItemFormSchema.safeParse({ ...valid, unitPrice: 'abc' }).success).toBe(
+ false,
+ );
});
it('maps empty unitPrice to null and drops empty optional strings', () => {
const request = toPricingLibraryItemRequest(valid);
- expect(request).toEqual({ title: 'Condenser coil cleaning', serviceCategory: 'HVAC', unitPrice: null });
+ expect(request).toEqual({
+ title: 'Condenser coil cleaning',
+ serviceCategory: 'HVAC',
+ unitPrice: null,
+ });
const priced = toPricingLibraryItemRequest({ ...valid, unitPrice: '12.50', unit: 'each' });
expect(priced.unitPrice).toBe(12.5);
expect(priced.unit).toBe('each');
@@ -131,7 +137,7 @@ describe('shared numeric form fields', () => {
unitPrice: '12abc',
keywords: '',
description: '',
- }).success
+ }).success,
).toBe(false);
});
});
diff --git a/web/src/domain/admin/types.ts b/web/src/domain/admin/types.ts
index 5fa57ae..7637d74 100644
--- a/web/src/domain/admin/types.ts
+++ b/web/src/domain/admin/types.ts
@@ -1,5 +1,9 @@
// Admin domain — contract type re-exports + view-only types.
-export type { DashboardStats, UpdateProposalRequest, AuditEntry } from '@proposal-system/api-contracts';
+export type {
+ DashboardStats,
+ UpdateProposalRequest,
+ AuditEntry,
+} from '@proposal-system/api-contracts';
/** View-only shape of GET /proposals/{id}/similar (RAG similarity results —
* no contract DTO yet; previously declared inline in SimilarProposalsPanel). */
diff --git a/web/src/domain/customers/schemas.ts b/web/src/domain/customers/schemas.ts
index e7c2ab0..4148b44 100644
--- a/web/src/domain/customers/schemas.ts
+++ b/web/src/domain/customers/schemas.ts
@@ -17,17 +17,13 @@ const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
* form edits a single primary address string (the wire type takes
* addresses: string[]); toCustomerRequest() maps to the request shape.
*/
-export const customerFormSchema = createCustomerRequestSchema
- .pick({ name: true })
- .extend({
- name: z.string().trim().min(1, 'Name is required'),
- address: z.string().trim().min(1, 'Address is required'),
- contactEmail: z
- .string()
- .refine((v) => v.trim() === '' || EMAIL_RE.test(v.trim()), {
- message: 'Enter a valid email address',
- }),
- });
+export const customerFormSchema = createCustomerRequestSchema.pick({ name: true }).extend({
+ name: z.string().trim().min(1, 'Name is required'),
+ address: z.string().trim().min(1, 'Address is required'),
+ contactEmail: z.string().refine((v) => v.trim() === '' || EMAIL_RE.test(v.trim()), {
+ message: 'Enter a valid email address',
+ }),
+});
export type CustomerFormValues = z.infer;
@@ -40,7 +36,7 @@ export type CustomerFormValues = z.infer;
*/
export function toCustomerRequest(
values: CustomerFormValues,
- existingAddresses: string[] = []
+ existingAddresses: string[] = [],
): CreateCustomerRequest & UpdateCustomerRequest {
return {
name: values.name.trim(),
diff --git a/web/src/domain/pricingLibrary/api.ts b/web/src/domain/pricingLibrary/api.ts
index c9f7974..81dce19 100644
--- a/web/src/domain/pricingLibrary/api.ts
+++ b/web/src/domain/pricingLibrary/api.ts
@@ -25,7 +25,10 @@ export const pricingLibraryApi = {
},
/** PUT /api/pricing-library/{id} — update an existing pricing library item. */
- update: async (id: string, data: UpdatePricingLibraryItemRequest): Promise => {
+ update: async (
+ id: string,
+ data: UpdatePricingLibraryItemRequest,
+ ): Promise => {
const res = await apiClient.put(`/pricing-library/${id}`, data);
return res.data;
},
diff --git a/web/src/domain/pricingLibrary/schemas.ts b/web/src/domain/pricingLibrary/schemas.ts
index 5d0c306..3999c0d 100644
--- a/web/src/domain/pricingLibrary/schemas.ts
+++ b/web/src/domain/pricingLibrary/schemas.ts
@@ -36,7 +36,7 @@ export type PricingLibraryItemFormValues = z.infer => {
+ uploadAttachment: async (
+ proposalId: string,
+ fileName: string,
+ vendorName?: string,
+ ): Promise => {
const params = new URLSearchParams({ fileName });
if (vendorName) params.append('vendorName', vendorName);
const res = await apiClient.post(`/proposals/${proposalId}/attachments?${params.toString()}`);
diff --git a/web/src/domain/proposals/use-cases.ts b/web/src/domain/proposals/use-cases.ts
index 99c3db9..3f24f5e 100644
--- a/web/src/domain/proposals/use-cases.ts
+++ b/web/src/domain/proposals/use-cases.ts
@@ -63,21 +63,24 @@ export interface CreateProposalVariables {
export function useCreateProposal() {
const queryClient = useQueryClient();
return useMutation({
- mutationFn: async ({ request, vendorFile }: CreateProposalVariables): Promise => {
+ mutationFn: async ({
+ request,
+ vendorFile,
+ }: CreateProposalVariables): Promise => {
const proposal = await proposalsApi.create(request);
if (vendorFile) {
try {
const { uploadUrl, vendorProposalId } = await proposalsApi.uploadAttachment(
proposal.id,
- vendorFile.name
+ vendorFile.name,
);
// Throws on S3 rejection (non-2xx) so a failed upload is never confirmed.
await proposalsApi.uploadToPresignedUrl(uploadUrl, vendorFile);
await proposalsApi.confirmUpload(proposal.id, vendorProposalId);
} catch {
toast.warning(
- `Proposal ${proposal.proposalNumber} was created, but vendor PDF upload failed. You can re-upload from the proposal detail page.`
+ `Proposal ${proposal.proposalNumber} was created, but vendor PDF upload failed. You can re-upload from the proposal detail page.`,
);
}
}
diff --git a/web/src/hooks/useAuth.ts b/web/src/hooks/useAuth.ts
index aecd0e4..12d0d6b 100644
--- a/web/src/hooks/useAuth.ts
+++ b/web/src/hooks/useAuth.ts
@@ -5,8 +5,16 @@ import { useAuthContext } from '../providers/authContext';
// Thin wrapper over the auth context (SHOC shape) that layers on the
// router/toast side effects components expect from logout.
export function useAuth() {
- const { user, isAuthenticated, isLoading, error, login, logout: clearSession, setError, setLoading } =
- useAuthContext();
+ const {
+ user,
+ isAuthenticated,
+ isLoading,
+ error,
+ login,
+ logout: clearSession,
+ setError,
+ setLoading,
+ } = useAuthContext();
const navigate = useNavigate();
const logout = () => {
diff --git a/web/src/hooks/useDocumentTitle.ts b/web/src/hooks/useDocumentTitle.ts
index 6dcab8f..62f8f9b 100644
--- a/web/src/hooks/useDocumentTitle.ts
+++ b/web/src/hooks/useDocumentTitle.ts
@@ -5,6 +5,8 @@ const BASE_TITLE = 'Proposal System';
export function useDocumentTitle(title?: string) {
useEffect(() => {
document.title = title ? `${title} | ${BASE_TITLE}` : BASE_TITLE;
- return () => { document.title = BASE_TITLE; };
+ return () => {
+ document.title = BASE_TITLE;
+ };
}, [title]);
}
diff --git a/web/src/index.css b/web/src/index.css
index 9acafa1..f1a4d61 100644
--- a/web/src/index.css
+++ b/web/src/index.css
@@ -3,7 +3,9 @@
box-sizing: border-box;
}
-html, body, #root {
+html,
+body,
+#root {
height: 100%;
}
diff --git a/web/src/lib/api/__tests__/client.test.ts b/web/src/lib/api/__tests__/client.test.ts
index b2daf07..c2aa9b7 100644
--- a/web/src/lib/api/__tests__/client.test.ts
+++ b/web/src/lib/api/__tests__/client.test.ts
@@ -57,10 +57,18 @@ vi.mock('axios', () => {
const sessionStorageData: Record = {};
vi.stubGlobal('sessionStorage', {
getItem: vi.fn((key: string) => sessionStorageData[key] ?? null),
- setItem: vi.fn((key: string, value: string) => { sessionStorageData[key] = value; }),
- removeItem: vi.fn((key: string) => { delete sessionStorageData[key]; }),
- clear: vi.fn(() => { Object.keys(sessionStorageData).forEach(k => delete sessionStorageData[k]); }),
- get length() { return Object.keys(sessionStorageData).length; },
+ setItem: vi.fn((key: string, value: string) => {
+ sessionStorageData[key] = value;
+ }),
+ removeItem: vi.fn((key: string) => {
+ delete sessionStorageData[key];
+ }),
+ clear: vi.fn(() => {
+ Object.keys(sessionStorageData).forEach((k) => delete sessionStorageData[k]);
+ }),
+ get length() {
+ return Object.keys(sessionStorageData).length;
+ },
key: vi.fn((index: number) => Object.keys(sessionStorageData)[index] ?? null),
});
@@ -80,7 +88,7 @@ describe('API client interceptors', () => {
beforeEach(() => {
vi.clearAllMocks();
- Object.keys(sessionStorageData).forEach(k => delete sessionStorageData[k]);
+ Object.keys(sessionStorageData).forEach((k) => delete sessionStorageData[k]);
mockLocation.href = '';
});
@@ -250,7 +258,9 @@ describe('API client interceptors', () => {
const promise = interceptors.responseRejected(error);
- await expect(promise).rejects.toThrow('No response from server. Please check your connection.');
+ await expect(promise).rejects.toThrow(
+ 'No response from server. Please check your connection.',
+ );
});
it('passes through non-axios errors unchanged', async () => {
diff --git a/web/src/lib/api/client.ts b/web/src/lib/api/client.ts
index 3e5ffcd..664cc31 100644
--- a/web/src/lib/api/client.ts
+++ b/web/src/lib/api/client.ts
@@ -25,7 +25,7 @@ apiClient.interceptors.request.use(
}
return config;
},
- (error) => Promise.reject(error)
+ (error) => Promise.reject(error),
);
apiClient.interceptors.response.use(
@@ -60,7 +60,7 @@ apiClient.interceptors.response.use(
}
return Promise.reject(error);
- }
+ },
);
export default apiClient;
diff --git a/web/src/lib/auth/__tests__/authStorage.test.ts b/web/src/lib/auth/__tests__/authStorage.test.ts
index cdaa2ca..4e31fd1 100644
--- a/web/src/lib/auth/__tests__/authStorage.test.ts
+++ b/web/src/lib/auth/__tests__/authStorage.test.ts
@@ -14,10 +14,12 @@ import { STORAGE_KEY_TOKEN } from '../../../constants';
function createToken(expOffsetSeconds: number): string {
const header = btoa(JSON.stringify({ alg: 'HS256' }));
- const payload = btoa(JSON.stringify({
- sub: 'test-user',
- exp: Math.floor(Date.now() / 1000) + expOffsetSeconds,
- }));
+ const payload = btoa(
+ JSON.stringify({
+ sub: 'test-user',
+ exp: Math.floor(Date.now() / 1000) + expOffsetSeconds,
+ }),
+ );
return `${header}.${payload}.fake-signature`;
}
@@ -69,7 +71,9 @@ describe('authStorage', () => {
it('isTokenValid accepts a base64url payload (JWT segments are base64url, not base64)', () => {
// '>>>' encodes to 'Pj4-' in base64url — the '-' made the old atob() call throw.
- const payload = btoa(JSON.stringify({ sub: '>>>>>>', exp: Math.floor(Date.now() / 1000) + 3600 }))
+ const payload = btoa(
+ JSON.stringify({ sub: '>>>>>>', exp: Math.floor(Date.now() / 1000) + 3600 }),
+ )
.replace(/\+/g, '-')
.replace(/\//g, '_')
.replace(/=+$/, '');
diff --git a/web/src/lib/format.ts b/web/src/lib/format.ts
index 0d75c6b..66a8aeb 100644
--- a/web/src/lib/format.ts
+++ b/web/src/lib/format.ts
@@ -17,7 +17,11 @@ export function formatDate(iso: string): string {
}
export function formatDateFull(iso: string): string {
- return new Date(iso).toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' });
+ return new Date(iso).toLocaleDateString('en-US', {
+ month: 'short',
+ day: 'numeric',
+ year: 'numeric',
+ });
}
export function formatDateTime(iso: string | null): string {
@@ -58,7 +62,9 @@ export interface GroupedProposal {
baseNumber: string;
}
-export function groupRevisions(rows: T[]): GroupedProposal[] {
+export function groupRevisions(
+ rows: T[],
+): GroupedProposal[] {
const groups = new Map();
for (const row of rows) {
const base = row.proposalNumber.replace(/-R\d+$/, '');
diff --git a/web/src/lib/theme/mui-theme.ts b/web/src/lib/theme/mui-theme.ts
index 28be2c5..ece517d 100644
--- a/web/src/lib/theme/mui-theme.ts
+++ b/web/src/lib/theme/mui-theme.ts
@@ -37,9 +37,18 @@ const radius = parseInt(v('--radius', `${CSS_VAR_FALLBACKS.radius}px`), 10);
const headerEnd = v('--color-header-bg-end', CSS_VAR_FALLBACKS.headerEnd);
const headerText = v('--color-header-text', CSS_VAR_FALLBACKS.headerText);
-const shadowSm = v('--shadow-sm', '0 1px 3px rgba(22, 30, 56, 0.06), 0 1px 2px rgba(22, 30, 56, 0.04)');
-const shadowMd = v('--shadow-md', '0 4px 6px -1px rgba(22, 30, 56, 0.08), 0 2px 4px -2px rgba(22, 30, 56, 0.06)');
-const shadowLg = v('--shadow-lg', '0 10px 15px -3px rgba(22, 30, 56, 0.1), 0 4px 6px -4px rgba(22, 30, 56, 0.08)');
+const shadowSm = v(
+ '--shadow-sm',
+ '0 1px 3px rgba(22, 30, 56, 0.06), 0 1px 2px rgba(22, 30, 56, 0.04)',
+);
+const shadowMd = v(
+ '--shadow-md',
+ '0 4px 6px -1px rgba(22, 30, 56, 0.08), 0 2px 4px -2px rgba(22, 30, 56, 0.06)',
+);
+const shadowLg = v(
+ '--shadow-lg',
+ '0 10px 15px -3px rgba(22, 30, 56, 0.1), 0 4px 6px -4px rgba(22, 30, 56, 0.08)',
+);
const fontSans = '"DM Sans", ui-sans-serif, system-ui, sans-serif';
const fontDisplay = '"Montserrat", ui-sans-serif, system-ui, sans-serif';
@@ -149,7 +158,8 @@ export const muiTheme = createTheme({
textTransform: 'none' as const,
fontWeight: 600,
fontSize: '0.875rem',
- transition: 'background-color var(--duration-fast) var(--ease-default), box-shadow var(--duration-fast) var(--ease-default)',
+ transition:
+ 'background-color var(--duration-fast) var(--ease-default), box-shadow var(--duration-fast) var(--ease-default)',
'&:focus-visible': {
outline: `2px solid ${ring}`,
outlineOffset: 2,
diff --git a/web/src/main.tsx b/web/src/main.tsx
index d27f8f8..d9a5028 100644
--- a/web/src/main.tsx
+++ b/web/src/main.tsx
@@ -26,5 +26,5 @@ ReactDOM.createRoot(document.getElementById('root')!).render(
-
+ ,
);
diff --git a/web/src/pages/admin/customers/CustomerManagementPage.tsx b/web/src/pages/admin/customers/CustomerManagementPage.tsx
index 03ce926..b408d36 100644
--- a/web/src/pages/admin/customers/CustomerManagementPage.tsx
+++ b/web/src/pages/admin/customers/CustomerManagementPage.tsx
@@ -120,10 +120,7 @@ export default function CustomerManagementPage() {
const payload = toCustomerRequest(values, editingCustomer?.addresses ?? []);
if (isEditing) {
- updateMutation.mutate(
- { id: editingCustomer.id, data: payload },
- { onSuccess: closeDialog }
- );
+ updateMutation.mutate({ id: editingCustomer.id, data: payload }, { onSuccess: closeDialog });
} else {
createMutation.mutate(payload, { onSuccess: closeDialog });
}
@@ -135,11 +132,7 @@ export default function CustomerManagementPage() {
Customer Management
- }
- onClick={openCreateDialog}
- >
+ } onClick={openCreateDialog}>
New Customer
@@ -222,10 +215,7 @@ export default function CustomerManagementPage() {
- openEditDialog(customer)}
- >
+ openEditDialog(customer)}>
@@ -289,7 +279,9 @@ export default function CustomerManagementPage() {
type="email"
{...register('contactEmail')}
error={!!errors.contactEmail}
- helperText={errors.contactEmail?.message || 'Optional. Used for proposal PDF delivery.'}
+ helperText={
+ errors.contactEmail?.message || 'Optional. Used for proposal PDF delivery.'
+ }
size="small"
fullWidth
/>
@@ -299,12 +291,14 @@ export default function CustomerManagementPage() {
-
)}
@@ -170,11 +240,21 @@ export default function AdminDashboard({ defaultStatus }: { defaultStatus?: stri
label={PRIORITY_LABELS[row.priority] || row.priority}
size="small"
sx={{ ...PRIORITY_CHIP_STYLES[row.priority], '& .MuiChip-icon': { color: 'inherit' } }}
- icon={row.priority === 'Emergency' ? : row.priority === 'Urgent' ? : undefined}
+ icon={
+ row.priority === 'Emergency' ? (
+
+ ) : row.priority === 'Urgent' ? (
+
+ ) : undefined
+ }
/>
-
+
{formatBidAmount(row.totalBidAmount)}
@@ -183,7 +263,12 @@ export default function AdminDashboard({ defaultStatus }: { defaultStatus?: stri
{row.submittedByName || '-'}
-
+
{formatAge(row.submittedAt)}
@@ -191,7 +276,9 @@ export default function AdminDashboard({ defaultStatus }: { defaultStatus?: stri
return (
- {defaultStatus ? 'Admin Queue' : 'All Proposals'}
+
+ {defaultStatus ? 'Admin Queue' : 'All Proposals'}
+
{/* Fix: WEB-M8 — show error state instead of misleading zeros when stats fetch fails */}
@@ -312,7 +399,9 @@ export default function AdminDashboard({ defaultStatus }: { defaultStatus?: stri
>
{SERVICE_CATEGORIES.map((c) => (
-
+
))}
{PRIORITIES.map((p) => (
-
+
))}
{!showTabs && (
@@ -345,7 +436,9 @@ export default function AdminDashboard({ defaultStatus }: { defaultStatus?: stri
>
{PROPOSAL_STATUSES.map((s) => (
-
+
))}
)}
@@ -357,7 +450,9 @@ export default function AdminDashboard({ defaultStatus }: { defaultStatus?: stri
{err && (
- {err}
+
+ {err}
+
)}
@@ -382,7 +477,9 @@ export default function AdminDashboard({ defaultStatus }: { defaultStatus?: stri
? Array.from({ length: 5 }).map((_, i) => (
{Array.from({ length: COL_COUNT }).map((_, j) => (
-
+
+
+
))}
))
@@ -390,7 +487,13 @@ export default function AdminDashboard({ defaultStatus }: { defaultStatus?: stri
const isExpanded = expanded.has(group.baseNumber);
return (
- {renderRow(group.latest, false, group.hasRevisions, isExpanded, group.baseNumber)}
+ {renderRow(
+ group.latest,
+ false,
+ group.hasRevisions,
+ isExpanded,
+ group.baseNumber,
+ )}
{group.hasRevisions && (
@@ -398,7 +501,7 @@ export default function AdminDashboard({ defaultStatus }: { defaultStatus?: stri
{group.revisions.map((rev) =>
- renderRow(rev, true, false, false, group.baseNumber)
+ renderRow(rev, true, false, false, group.baseNumber),
)}
diff --git a/web/src/pages/admin/pricing-library/PricingLibraryPage.tsx b/web/src/pages/admin/pricing-library/PricingLibraryPage.tsx
index 13581d6..b220b7b 100644
--- a/web/src/pages/admin/pricing-library/PricingLibraryPage.tsx
+++ b/web/src/pages/admin/pricing-library/PricingLibraryPage.tsx
@@ -149,7 +149,7 @@ export default function PricingLibraryPage() {
if (editingItem) {
updateMutation.mutate(
{ id: editingItem.id, data: payload },
- { onSuccess: () => closeDialog() }
+ { onSuccess: () => closeDialog() },
);
} else {
createMutation.mutate(payload, { onSuccess: () => closeDialog() });
@@ -168,11 +168,7 @@ export default function PricingLibraryPage() {
Pricing Library
- }
- onClick={openCreateDialog}
- >
+ } onClick={openCreateDialog}>
New Item
@@ -220,7 +216,11 @@ export default function PricingLibraryPage() {
))
: rows.map((item) => {
- const defaultChip = { bgcolor: '#F1F5F9', color: '#475569', border: '1px solid #CBD5E1' };
+ const defaultChip = {
+ bgcolor: '#F1F5F9',
+ color: '#475569',
+ border: '1px solid #CBD5E1',
+ };
const chipStyle = CATEGORY_CHIP_STYLES[item.serviceCategory] ?? defaultChip;
return (
@@ -239,17 +239,16 @@ export default function PricingLibraryPage() {
/>
{item.unit ?? '—'}
-
+
{formatPrice(item.unitPrice)}
{item.source}
- openEditDialog(item)}
- >
+ openEditDialog(item)}>
@@ -359,7 +358,9 @@ export default function PricingLibraryPage() {
label="Unit Price"
type="number"
error={!!errors.unitPrice}
- helperText={errors.unitPrice?.message || 'Optional. Leave blank if price varies.'}
+ helperText={
+ errors.unitPrice?.message || 'Optional. Leave blank if price varies.'
+ }
size="small"
fullWidth
slotProps={{
@@ -403,12 +404,14 @@ export default function PricingLibraryPage() {
Cancel
-
- {isSaving ? (isEditing ? 'Saving...' : 'Creating...') : isEditing ? 'Save Changes' : 'Create Item'}
+
+ {isSaving
+ ? isEditing
+ ? 'Saving...'
+ : 'Creating...'
+ : isEditing
+ ? 'Save Changes'
+ : 'Create Item'}
@@ -419,7 +422,8 @@ export default function PricingLibraryPage() {
Delete Pricing Item
- Are you sure you want to delete {deleteTarget?.title}? This action cannot be undone.
+ Are you sure you want to delete {deleteTarget?.title}? This action
+ cannot be undone.
diff --git a/web/src/pages/admin/workspace/AdminWorkspace.tsx b/web/src/pages/admin/workspace/AdminWorkspace.tsx
index b7bb83b..d0b2ef4 100644
--- a/web/src/pages/admin/workspace/AdminWorkspace.tsx
+++ b/web/src/pages/admin/workspace/AdminWorkspace.tsx
@@ -105,7 +105,7 @@ export default function AdminWorkspace() {
pricingMode: li.pricingMode,
sortOrder: li.sortOrder,
source: li.source,
- }))
+ })),
);
}
}, [fetchedLineItems]);
@@ -128,14 +128,17 @@ export default function AdminWorkspace() {
const [navBlocked, setNavBlocked] = useState(false);
const pendingNavRef = useRef(null);
- const guardedNavigate = useCallback((to: string) => {
- if (dirty) {
- pendingNavRef.current = to;
- setNavBlocked(true);
- } else {
- navigate(to);
- }
- }, [dirty, navigate]);
+ const guardedNavigate = useCallback(
+ (to: string) => {
+ if (dirty) {
+ pendingNavRef.current = to;
+ setNavBlocked(true);
+ } else {
+ navigate(to);
+ }
+ },
+ [dirty, navigate],
+ );
// Domain mutations: cache invalidation + success/error toasts live in the
// hooks; page-specific side effects (dialog close, dirty reset, window.open)
@@ -152,7 +155,7 @@ export default function AdminWorkspace() {
const handleSave = () => {
saveMutation.mutate(
{ refinedScope, lineItems: toUpdateEntries(lineItems) },
- { onSuccess: () => setDirty(false) }
+ { onSuccess: () => setDirty(false) },
);
};
@@ -227,9 +230,14 @@ export default function AdminWorkspace() {
}
>
- Failed to load proposal{proposalError instanceof Error ? `: ${proposalError.message}` : '.'}
+ Failed to load proposal
+ {proposalError instanceof Error ? `: ${proposalError.message}` : '.'}
- } onClick={() => guardedNavigate('/admin')} sx={{ mt: 2 }}>
+ }
+ onClick={() => guardedNavigate('/admin')}
+ sx={{ mt: 2 }}
+ >
Back to Dashboard
@@ -240,7 +248,11 @@ export default function AdminWorkspace() {
return (
Proposal not found
- } onClick={() => guardedNavigate('/admin')} sx={{ mt: 2 }}>
+ }
+ onClick={() => guardedNavigate('/admin')}
+ sx={{ mt: 2 }}
+ >
Back to Dashboard
@@ -257,16 +269,19 @@ export default function AdminWorkspace() {
const bidTotal = lineItems.reduce((sum, li) => sum + li.totalPrice, 0);
// Tooltip messages for disabled actions
- const approveDisabledReason =
- !isEditable
- ? `Cannot approve: proposal is ${proposal.status}`
- : lineItems.length === 0
- ? 'Add at least one line item before approving'
- : bidTotal <= 0
- ? 'Total bid must be greater than zero'
- : '';
- const sendDisabledReason = !canSend ? `Cannot send: proposal must be Approved (currently ${proposal.status})` : '';
- const reviseDisabledReason = !canRevise ? `Cannot revise: proposal must be Sent (currently ${proposal.status})` : '';
+ const approveDisabledReason = !isEditable
+ ? `Cannot approve: proposal is ${proposal.status}`
+ : lineItems.length === 0
+ ? 'Add at least one line item before approving'
+ : bidTotal <= 0
+ ? 'Total bid must be greater than zero'
+ : '';
+ const sendDisabledReason = !canSend
+ ? `Cannot send: proposal must be Approved (currently ${proposal.status})`
+ : '';
+ const reviseDisabledReason = !canRevise
+ ? `Cannot revise: proposal must be Sent (currently ${proposal.status})`
+ : '';
return (
@@ -282,9 +297,7 @@ export default function AdminWorkspace() {
Admin Queue
-
- {proposal.proposalNumber}
-
+ {proposal.proposalNumber}
@@ -292,25 +305,41 @@ export default function AdminWorkspace() {
{/* Proposal Info Bar */}
-
+
- Customer
- {proposal.customerName}
+
+ Customer
+
+
+ {proposal.customerName}
+
- WO #
- {proposal.workOrderNumber}
+
+ WO #
+
+
+ {proposal.workOrderNumber}
+
- Category
+
+ Category
+
{proposal.serviceCategory}
- Priority
+
+ Priority
+
- Address
+
+ Address
+
{proposal.customerAddress}
@@ -320,28 +349,51 @@ export default function AdminWorkspace() {
{/* Left Panel: Reference */}
-
+
- Submission Details
+
+ Submission Details
+
- Submitted By
- {proposal.submittedByName || '-'}
+
+ Submitted By
+
+
+ {proposal.submittedByName || '-'}
+
- Submitted
+
+ Submitted
+
{new Date(proposal.submittedAt).toLocaleString()}
- Original Scope
-
+
+ Original Scope
+
+
{proposal.scopeOfWork}
{proposal.notes && (
<>
- Notes
+
+ Notes
+
{proposal.notes}
@@ -358,14 +410,19 @@ export default function AdminWorkspace() {
- Refined Scope
+
+ Refined Scope
+
{ setRefinedScope(e.target.value); setDirty(true); }}
+ onChange={(e) => {
+ setRefinedScope(e.target.value);
+ setDirty(true);
+ }}
disabled={!isEditable}
size="small"
sx={{ mb: 2 }}
@@ -374,12 +431,29 @@ export default function AdminWorkspace() {
{/* Fix: WEB-M9 — show loading skeleton while line items are being fetched */}
{lineItemsLoading ? (
-
+
-
+
{Array.from({ length: 4 }).map((_, i) => (
-
+
))}
@@ -404,7 +478,8 @@ export default function AdminWorkspace() {
{
- const maxSort = lineItems.length > 0 ? Math.max(...lineItems.map((i) => i.sortOrder)) : 0;
+ const maxSort =
+ lineItems.length > 0 ? Math.max(...lineItems.map((i) => i.sortOrder)) : 0;
handleLineItemsChange([
...lineItems,
{ ...item, id: null, sortOrder: maxSort + 1, source: 'Historical' },
@@ -419,10 +494,26 @@ export default function AdminWorkspace() {
{/* Action Bar */}
-
-
+
+
{dirty && (
- Unsaved changes
+
+ Unsaved changes
+
)}
{!dirty && }
@@ -437,10 +528,7 @@ export default function AdminWorkspace() {
>
Regenerate
-
+
setNavBlocked(false)}>Stay
- {
- setNavBlocked(false);
- const dest = pendingNavRef.current ?? '/admin';
- pendingNavRef.current = null;
- navigate(dest);
- }}>
+ {
+ setNavBlocked(false);
+ const dest = pendingNavRef.current ?? '/admin';
+ pendingNavRef.current = null;
+ navigate(dest);
+ }}
+ >
Discard & Leave
@@ -549,11 +641,17 @@ export default function AdminWorkspace() {
Line Items: {lineItems.length}
- Total Bid: {new Intl.NumberFormat('en-US', { style: 'currency', currency: 'USD' }).format(bidTotal)}
+ Total Bid:{' '}
+ {new Intl.NumberFormat('en-US', { style: 'currency', currency: 'USD' }).format(
+ bidTotal,
+ )}
{proposal.vendorTotalCost != null && (
- Vendor Cost: {new Intl.NumberFormat('en-US', { style: 'currency', currency: 'USD' }).format(proposal.vendorTotalCost)}
+ Vendor Cost:{' '}
+ {new Intl.NumberFormat('en-US', { style: 'currency', currency: 'USD' }).format(
+ proposal.vendorTotalCost,
+ )}
)}
@@ -576,8 +674,8 @@ export default function AdminWorkspace() {
Mark as Sent
- Mark {proposal.proposalNumber} as sent to {proposal.customerName}?
- This action cannot be undone.
+ Mark {proposal.proposalNumber} as sent to {proposal.customerName}? This
+ action cannot be undone.
{/* PR4-B: surface delivery behavior — server emails PDF if customer has a contact email */}
@@ -602,8 +700,8 @@ export default function AdminWorkspace() {
Create Revision
- Create a new revision of {proposal.proposalNumber}?
- The current version will be marked as revised and a new editable copy will be created.
+ Create a new revision of {proposal.proposalNumber}? The current version
+ will be marked as revised and a new editable copy will be created.
@@ -623,8 +721,8 @@ export default function AdminWorkspace() {
Return to Review
- Return {proposal.proposalNumber} to In Review status?
- This will clear the approval and allow further edits to line items and scope.
+ Return {proposal.proposalNumber} to In Review status? This will clear
+ the approval and allow further edits to line items and scope.
diff --git a/web/src/pages/auth/LoginPage.tsx b/web/src/pages/auth/LoginPage.tsx
index cf3e692..8fad566 100644
--- a/web/src/pages/auth/LoginPage.tsx
+++ b/web/src/pages/auth/LoginPage.tsx
@@ -134,7 +134,9 @@ export default function LoginPage() {
>
SysAdmin
- Full access
+
+ Full access
+
Admin
- Review, price, and approve proposals
+
+ Review, price, and approve proposals
+
Dispatcher
- Submit and track proposals
+
+ Submit and track proposals
+
diff --git a/web/src/pages/dashboard/Dashboard.tsx b/web/src/pages/dashboard/Dashboard.tsx
index d6ed031..16b1221 100644
--- a/web/src/pages/dashboard/Dashboard.tsx
+++ b/web/src/pages/dashboard/Dashboard.tsx
@@ -28,19 +28,55 @@ import { STATUS_CHIP_STYLES } from '../../constants';
import { formatCurrency, formatDate, formatDateFull } from '../../lib/format';
import { useDocumentTitle } from '../../hooks/useDocumentTitle';
-function KpiCard({ icon, label, value, subtitle }: { icon: React.ReactNode; label: string; value: string; subtitle?: string }) {
+function KpiCard({
+ icon,
+ label,
+ value,
+ subtitle,
+}: {
+ icon: React.ReactNode;
+ label: string;
+ value: string;
+ subtitle?: string;
+}) {
return (
-
+
- {icon}
-
+
+ {icon}
+
+
{label}
-
+
{value}
{subtitle && (
-
+
{subtitle}
)}
@@ -53,10 +89,19 @@ export default function Dashboard() {
useDocumentTitle('Dashboard');
const navigate = useNavigate();
- const { data: recentData, isLoading: recentLoading, isError: recentError } = useProposals({ page: 1, pageSize: 5, mine: true });
+ const {
+ data: recentData,
+ isLoading: recentLoading,
+ isError: recentError,
+ } = useProposals({ page: 1, pageSize: 5, mine: true });
// Fix: WEB-M8 — capture error state for stats to show error instead of misleading zeros
- const { data: stats, isLoading: statsLoading, isError: statsError, refetch: refetchStats } = useProposalStats();
+ const {
+ data: stats,
+ isLoading: statsLoading,
+ isError: statsError,
+ refetch: refetchStats,
+ } = useProposalStats();
const proposals = recentData?.items ?? [];
const total = stats?.totalCount ?? 0;
@@ -101,28 +146,48 @@ export default function Dashboard() {
{statsLoading ? (
) : (
- } label="Total Submitted" value={String(total)} subtitle="All time" />
+ }
+ label="Total Submitted"
+ value={String(total)}
+ subtitle="All time"
+ />
)}
{statsLoading ? (
) : (
- } label="In Review" value={String(counts.inReview)} subtitle="Awaiting pricing" />
+ }
+ label="In Review"
+ value={String(counts.inReview)}
+ subtitle="Awaiting pricing"
+ />
)}
{statsLoading ? (
) : (
- } label="Approved" value={String(counts.approved)} subtitle="Ready to send" />
+ }
+ label="Approved"
+ value={String(counts.approved)}
+ subtitle="Ready to send"
+ />
)}
{statsLoading ? (
) : (
- } label="Sent" value={String(counts.sent)} subtitle="Delivered to customer" />
+ }
+ label="Sent"
+ value={String(counts.sent)}
+ subtitle="Delivered to customer"
+ />
)}
>
@@ -131,7 +196,9 @@ export default function Dashboard() {
-
+
Recent Proposals
navigate('/proposals')}>
View All
@@ -155,7 +222,9 @@ export default function Dashboard() {
? Array.from({ length: 5 }).map((_, i) => (
{Array.from({ length: 6 }).map((_, j) => (
-
+
+
+
))}
))
@@ -170,7 +239,11 @@ export default function Dashboard() {
{row.customerName}
{row.serviceCategory}
-
+
{formatCurrency(row.totalBidAmount)}
diff --git a/web/src/pages/proposals/detail/ProposalDetailPage.tsx b/web/src/pages/proposals/detail/ProposalDetailPage.tsx
index a3315e7..6d738e0 100644
--- a/web/src/pages/proposals/detail/ProposalDetailPage.tsx
+++ b/web/src/pages/proposals/detail/ProposalDetailPage.tsx
@@ -73,11 +73,7 @@ export default function ProposalDetailPage() {
{proposal.proposalNumber}
-
+
@@ -87,7 +83,10 @@ export default function ProposalDetailPage() {
{proposal.status === 'Revised' ? (
-
+
) : (
= 0 ? activeStep : 0} alternativeLabel>
diff --git a/web/src/pages/proposals/form/ProposalFormPage.tsx b/web/src/pages/proposals/form/ProposalFormPage.tsx
index 89a59a2..6b0e7a7 100644
--- a/web/src/pages/proposals/form/ProposalFormPage.tsx
+++ b/web/src/pages/proposals/form/ProposalFormPage.tsx
@@ -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({
+ const { register, handleSubmit, control, setValue, watch } = useForm({
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() {
- Job Details
+
+ Job Details
+
-
+
{/* 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() {
- Site & Location
+
+ Site & Location
+
{manualEntry ? (
@@ -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() {
- Work Details
+
+ Work Details
+
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;
}
diff --git a/web/src/pages/proposals/list/ProposalListPage.tsx b/web/src/pages/proposals/list/ProposalListPage.tsx
index 7fde64d..975016a 100644
--- a/web/src/pages/proposals/list/ProposalListPage.tsx
+++ b/web/src/pages/proposals/list/ProposalListPage.tsx
@@ -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() {
/>
- {row.totalBidAmount > 0
- ? formatBidAmount(row.totalBidAmount)
- : Not priced
- }
+ {row.totalBidAmount > 0 ? (
+ formatBidAmount(row.totalBidAmount)
+ ) : (
+
+ Not priced
+
+ )}
- {formatDate(row.submittedAt)}
+
+ {formatDate(row.submittedAt)}
+
))}
diff --git a/web/src/providers/__tests__/AuthProvider.test.tsx b/web/src/providers/__tests__/AuthProvider.test.tsx
index 7271802..4b6787c 100644
--- a/web/src/providers/__tests__/AuthProvider.test.tsx
+++ b/web/src/providers/__tests__/AuthProvider.test.tsx
@@ -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({
- sub: 'test-user',
- exp: Math.floor(Date.now() / 1000) + expOffsetSeconds,
- }));
+ const payload = btoa(
+ JSON.stringify({
+ sub: 'test-user',
+ exp: Math.floor(Date.now() / 1000) + expOffsetSeconds,
+ }),
+ );
return `${header}.${payload}.fake-signature`;
}
diff --git a/web/src/styles/fonts.css b/web/src/styles/fonts.css
index bf759c9..fc786fa 100644
--- a/web/src/styles/fonts.css
+++ b/web/src/styles/fonts.css
@@ -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';
diff --git a/web/src/styles/globals.css b/web/src/styles/globals.css
index db4a57b..09748fd 100644
--- a/web/src/styles/globals.css
+++ b/web/src/styles/globals.css
@@ -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';
diff --git a/web/src/styles/theme.css b/web/src/styles/theme.css
index bb1ce7d..ce7fd1b 100644
--- a/web/src/styles/theme.css
+++ b/web/src/styles/theme.css
@@ -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);
diff --git a/web/src/styles/typography.css b/web/src/styles/typography.css
index c9fb518..3f8ea4a 100644
--- a/web/src/styles/typography.css
+++ b/web/src/styles/typography.css
@@ -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);