mirror of
https://github.com/Sea-Haven-Industries/proposal-system.git
synced 2026-10-07 16:18:57 +00:00
Compare commits
2 commits
efcb13838e
...
4629f7a21a
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4629f7a21a | ||
|
|
d40a794695 |
62 changed files with 1632 additions and 818 deletions
28
.github/workflows/ci.yaml
vendored
28
.github/workflows/ci.yaml
vendored
|
|
@ -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
4
.gitignore
vendored
|
|
@ -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/
|
||||
|
|
|
|||
|
|
@ -38,7 +38,7 @@ proposal-system/
|
|||
| Component | Technologies |
|
||||
|---|---|
|
||||
| API | .NET 8, ASP.NET Core, EF Core + Npgsql, FluentValidation, Cognito JWT, Amazon.Lambda.AspNetCoreServer |
|
||||
| Web | React 19, TypeScript, MUI v9, Vite, Redux Toolkit, TanStack Query, axios |
|
||||
| Web | React 19, TypeScript, MUI v9, Vite, TanStack Query, react-hook-form + zod, axios |
|
||||
| Mobile | React Native CLI 0.86, React 19, React Native Paper, React Navigation, react-native-app-auth (PKCE), amazon-cognito-identity-js (SRP), Keychain, offline draft queue |
|
||||
| Lambdas | Python 3.12, arm64, pdfplumber, reportlab, httpx, boto3 |
|
||||
| Infrastructure | CDK TypeScript (aws-cdk-lib pinned exact, kept current by Dependabot) |
|
||||
|
|
@ -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) |
|
||||
|
|
|
|||
46
web/.claude/skills/verify/SKILL.md
Normal file
46
web/.claude/skills/verify/SKILL.md
Normal file
|
|
@ -0,0 +1,46 @@
|
|||
---
|
||||
name: verify
|
||||
description: Drive the proposal-system web SPA headlessly to verify changes at the browser surface (no API required for auth/shell flows).
|
||||
---
|
||||
|
||||
# Verify web changes (headless, no API)
|
||||
|
||||
Build/launch:
|
||||
|
||||
```bash
|
||||
cd web && npm run dev # vite on http://localhost:5173
|
||||
```
|
||||
|
||||
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 browser = await chromium.launch({
|
||||
executablePath:
|
||||
'/Users/adammoussa/Library/Caches/ms-playwright/chromium_headless_shell-1228/chrome-headless-shell-mac-arm64/chrome-headless-shell',
|
||||
});
|
||||
```
|
||||
|
||||
Fake a session without the API by seeding sessionStorage with a future-exp JWT
|
||||
(AuthUser shape: `{ id, email, displayName, role, token }`, roles: SysAdmin/Admin/Dispatcher):
|
||||
|
||||
```js
|
||||
await ctx.addInitScript((v) => sessionStorage.setItem('proposal_system_token', v), userJson);
|
||||
```
|
||||
|
||||
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
|
||||
without the .NET API running; seed storage instead.
|
||||
- Sidebar drawer width: expanded 244px, collapsed 76px — read
|
||||
`.MuiDrawer-paper` bounding rect to assert toggles.
|
||||
|
||||
Flows worth driving: unauthenticated redirect to /login, seeded session renders
|
||||
shell (user chip + role-gated nav groups), sidebar toggle + localStorage
|
||||
persistence, logout (toast, storage cleared), expired/malformed token treated as
|
||||
unauthenticated, Dispatcher bounced from /admin by RoleGuard.
|
||||
6
web/.prettierignore
Normal file
6
web/.prettierignore
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
dist
|
||||
coverage
|
||||
node_modules
|
||||
package-lock.json
|
||||
playwright-report
|
||||
test-results
|
||||
4
web/.prettierrc
Normal file
4
web/.prettierrc
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
{
|
||||
"singleQuote": true,
|
||||
"printWidth": 100
|
||||
}
|
||||
113
web/e2e/smoke.spec.ts
Normal file
113
web/e2e/smoke.spec.ts
Normal 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();
|
||||
});
|
||||
|
|
@ -1,4 +1,4 @@
|
|||
<!DOCTYPE html>
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
|
|
|
|||
179
web/package-lock.json
generated
179
web/package-lock.json
generated
|
|
@ -17,19 +17,18 @@
|
|||
"@mui/icons-material": "^9.2.0",
|
||||
"@mui/material": "^9.1.1",
|
||||
"@proposal-system/api-contracts": "file:../shared/api-contracts",
|
||||
"@reduxjs/toolkit": "^2.11.2",
|
||||
"@tanstack/react-query": "^5.101.2",
|
||||
"axios": "^1.18.1",
|
||||
"lucide-react": "^1.24.0",
|
||||
"react": "^19.2.7",
|
||||
"react-dom": "^19.2.7",
|
||||
"react-hook-form": "^7.81.0",
|
||||
"react-redux": "^9.2.0",
|
||||
"react-router-dom": "^7.18.1",
|
||||
"react-toastify": "^11.0.5",
|
||||
"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",
|
||||
|
|
@ -37,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"
|
||||
|
|
@ -936,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",
|
||||
|
|
@ -950,32 +966,6 @@
|
|||
"resolved": "../shared/api-contracts",
|
||||
"link": true
|
||||
},
|
||||
"node_modules/@reduxjs/toolkit": {
|
||||
"version": "2.12.0",
|
||||
"resolved": "https://registry.npmjs.org/@reduxjs/toolkit/-/toolkit-2.12.0.tgz",
|
||||
"integrity": "sha512-KiT+RzZbp6mQET+Mg+h2c97+9j1sNflUxQkIHI7Yuzf6Peu+OYpmkn6nbHWmLLWj+1ZODUJFwGZ7gx3L9R9EOw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@standard-schema/spec": "^1.0.0",
|
||||
"@standard-schema/utils": "^0.3.0",
|
||||
"immer": "^11.0.0",
|
||||
"redux": "^5.0.1",
|
||||
"redux-thunk": "^3.1.0",
|
||||
"reselect": "^5.1.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": "^16.9.0 || ^17.0.0 || ^18 || ^19",
|
||||
"react-redux": "^7.2.1 || ^8.1.3 || ^9.0.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"react": {
|
||||
"optional": true
|
||||
},
|
||||
"react-redux": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@rolldown/binding-android-arm64": {
|
||||
"version": "1.1.5",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.1.5.tgz",
|
||||
|
|
@ -1244,6 +1234,7 @@
|
|||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz",
|
||||
"integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@standard-schema/utils": {
|
||||
|
|
@ -1452,12 +1443,6 @@
|
|||
"@types/react": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/use-sync-external-store": {
|
||||
"version": "0.0.6",
|
||||
"resolved": "https://registry.npmjs.org/@types/use-sync-external-store/-/use-sync-external-store-0.0.6.tgz",
|
||||
"integrity": "sha512-zFDAD+tlpf2r4asuHEj0XH6pY6i0g5NeAHPn+15wk3BV6JA69eERFXC1gyGThDkVa1zCyKr5jox1+2LbV/AMLg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@typescript/typescript-aix-ppc64": {
|
||||
"version": "7.0.2",
|
||||
"resolved": "https://registry.npmjs.org/@typescript/typescript-aix-ppc64/-/typescript-aix-ppc64-7.0.2.tgz",
|
||||
|
|
@ -2586,16 +2571,6 @@
|
|||
"node": ">= 6"
|
||||
}
|
||||
},
|
||||
"node_modules/immer": {
|
||||
"version": "11.1.11",
|
||||
"resolved": "https://registry.npmjs.org/immer/-/immer-11.1.11.tgz",
|
||||
"integrity": "sha512-qzXuyXAkPySAGYkfsAwodDPWT8Zm7/Uo5BNt4BjhMhG5WlWyZZ4wQqnWwdS8kjlQ1Cwu6gjw3A6+0gTQwlyYtw==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/immer"
|
||||
}
|
||||
},
|
||||
"node_modules/import-fresh": {
|
||||
"version": "3.3.1",
|
||||
"resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz",
|
||||
|
|
@ -3213,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",
|
||||
|
|
@ -3242,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",
|
||||
|
|
@ -3345,29 +3383,6 @@
|
|||
"integrity": "sha512-kZFnouyVv7eP/Phmrlo9FK+zcAdriZJvzxXHF1Sl1P377WSGe2G/JxVolhTrB/jeV47lKImhNUsijjHAAbcl/A==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/react-redux": {
|
||||
"version": "9.3.0",
|
||||
"resolved": "https://registry.npmjs.org/react-redux/-/react-redux-9.3.0.tgz",
|
||||
"integrity": "sha512-KQopgqFo/p/fgmAs5qz6p5RWaNAzq40WAu7fJIXnQpYxFPbJYtsJPWvGeF2rOBaY/kEuV77AVsX8TsQzKm+A/g==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/use-sync-external-store": "^0.0.6",
|
||||
"use-sync-external-store": "^1.4.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@types/react": "^18.2.25 || ^19",
|
||||
"react": "^18.0 || ^19",
|
||||
"redux": "^5.0.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/react": {
|
||||
"optional": true
|
||||
},
|
||||
"redux": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/react-router": {
|
||||
"version": "7.18.1",
|
||||
"resolved": "https://registry.npmjs.org/react-router/-/react-router-7.18.1.tgz",
|
||||
|
|
@ -3449,21 +3464,6 @@
|
|||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/redux": {
|
||||
"version": "5.0.1",
|
||||
"resolved": "https://registry.npmjs.org/redux/-/redux-5.0.1.tgz",
|
||||
"integrity": "sha512-M9/ELqF6fy8FwmkpnF0S3YKOqMyoWJ4+CS5Efg2ct3oY9daQvd/Pc71FpGZsVsbl3Cpb+IIcjBDUnnyBdQbq4w==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/redux-thunk": {
|
||||
"version": "3.1.0",
|
||||
"resolved": "https://registry.npmjs.org/redux-thunk/-/redux-thunk-3.1.0.tgz",
|
||||
"integrity": "sha512-NW2r5T6ksUKXCabzhL9z+h206HQw/NJkcLm1GPImRQ8IzfXwRGqjVhKJGauHirT0DAuyy6hjdnMZaRoAcy0Klw==",
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"redux": "^5.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/require-from-string": {
|
||||
"version": "2.0.2",
|
||||
"resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz",
|
||||
|
|
@ -3474,12 +3474,6 @@
|
|||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/reselect": {
|
||||
"version": "5.2.0",
|
||||
"resolved": "https://registry.npmjs.org/reselect/-/reselect-5.2.0.tgz",
|
||||
"integrity": "sha512-AgZ3UOZm3YndfrJ4OYjgrT7bmCm/1iqkjvEfH/oYjzh6PD2qw4QuT3jjnXIrpdt4MTpMXclMT3lXbmRY+XRakw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/resolve": {
|
||||
"version": "1.22.12",
|
||||
"resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.12.tgz",
|
||||
|
|
@ -3790,15 +3784,6 @@
|
|||
"node": ">=20.18.1"
|
||||
}
|
||||
},
|
||||
"node_modules/use-sync-external-store": {
|
||||
"version": "1.6.0",
|
||||
"resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.6.0.tgz",
|
||||
"integrity": "sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w==",
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/vite": {
|
||||
"version": "8.1.4",
|
||||
"resolved": "https://registry.npmjs.org/vite/-/vite-8.1.4.tgz",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
@ -21,19 +24,18 @@
|
|||
"@mui/icons-material": "^9.2.0",
|
||||
"@mui/material": "^9.1.1",
|
||||
"@proposal-system/api-contracts": "file:../shared/api-contracts",
|
||||
"@reduxjs/toolkit": "^2.11.2",
|
||||
"@tanstack/react-query": "^5.101.2",
|
||||
"axios": "^1.18.1",
|
||||
"lucide-react": "^1.24.0",
|
||||
"react": "^19.2.7",
|
||||
"react-dom": "^19.2.7",
|
||||
"react-hook-form": "^7.81.0",
|
||||
"react-redux": "^9.2.0",
|
||||
"react-router-dom": "^7.18.1",
|
||||
"react-toastify": "^11.0.5",
|
||||
"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",
|
||||
|
|
@ -41,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
27
web/playwright.config.ts
Normal 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,
|
||||
},
|
||||
});
|
||||
148
web/src/App.tsx
148
web/src/App.tsx
|
|
@ -1,5 +1,7 @@
|
|||
import { useState } from 'react';
|
||||
import { Routes, Route, Navigate } from 'react-router-dom';
|
||||
import { Box, Button, Card, CardContent, Typography } from '@mui/material';
|
||||
import { STORAGE_KEY_SIDEBAR } from './constants';
|
||||
import PeopleIcon from '@mui/icons-material/People';
|
||||
import ProtectedRoute, { RoleGuard } from './components/ProtectedRoute';
|
||||
import Topbar from './components/Topbar';
|
||||
|
|
@ -16,6 +18,16 @@ import CustomerManagementPage from './pages/admin/customers/CustomerManagementPa
|
|||
import PricingLibraryPage from './pages/admin/pricing-library/PricingLibraryPage';
|
||||
|
||||
export default function App() {
|
||||
// SHOC shell pattern (mirrors (protected)/_layout.tsx): sidebar open state is
|
||||
// plain layout state passed down as props, persisted across visits.
|
||||
const [sidebarOpen, setSidebarOpenState] = useState(
|
||||
() => window.localStorage.getItem(STORAGE_KEY_SIDEBAR) !== 'false',
|
||||
);
|
||||
const setSidebarOpen = (open: boolean) => {
|
||||
setSidebarOpenState(open);
|
||||
window.localStorage.setItem(STORAGE_KEY_SIDEBAR, String(open));
|
||||
};
|
||||
|
||||
return (
|
||||
<Routes>
|
||||
<Route path="/login" element={<LoginPage />} />
|
||||
|
|
@ -26,10 +38,27 @@ 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)' }}>
|
||||
<Sidebar />
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', flex: 1, minWidth: 0, height: '100vh', overflow: 'hidden' }}>
|
||||
<Topbar />
|
||||
<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',
|
||||
}}
|
||||
>
|
||||
<Topbar onToggleSidebar={() => setSidebarOpen(!sidebarOpen)} />
|
||||
<Box
|
||||
component="main"
|
||||
sx={{
|
||||
|
|
@ -40,41 +69,84 @@ export default function App() {
|
|||
}}
|
||||
>
|
||||
<Routes>
|
||||
<Route path="/" element={<Dashboard />} />
|
||||
<Route path="/proposals" element={<ProposalListPage />} />
|
||||
<Route path="/proposals/new" element={<ProposalFormPage />} />
|
||||
<Route path="/proposals/:id" element={<ProposalDetailPage />} />
|
||||
<Route path="/" element={<Dashboard />} />
|
||||
<Route path="/proposals" element={<ProposalListPage />} />
|
||||
<Route path="/proposals/new" element={<ProposalFormPage />} />
|
||||
<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={
|
||||
<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>
|
||||
<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.
|
||||
</Typography>
|
||||
<Button
|
||||
variant="outlined"
|
||||
size="large"
|
||||
href="https://us-east-1.console.aws.amazon.com/cognito/v2/idp/user-pools?region=us-east-1"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
Manage in Cognito
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Box>
|
||||
</RoleGuard>
|
||||
} />
|
||||
{/* 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={
|
||||
<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>
|
||||
<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.
|
||||
</Typography>
|
||||
<Button
|
||||
variant="outlined"
|
||||
size="large"
|
||||
href="https://us-east-1.console.aws.amazon.com/cognito/v2/idp/user-pools?region=us-east-1"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
Manage in Cognito
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Box>
|
||||
</RoleGuard>
|
||||
}
|
||||
/>
|
||||
|
||||
<Route path="*" element={<Navigate to="/" replace />} />
|
||||
</Routes>
|
||||
|
|
|
|||
|
|
@ -1,114 +0,0 @@
|
|||
/**
|
||||
* QA-C5: Auth slice tests.
|
||||
*
|
||||
* Tests the Redux auth state management:
|
||||
* - setUser stores user and marks authenticated
|
||||
* - logout clears user and marks unauthenticated
|
||||
* - Expired token sets isAuthenticated to false
|
||||
*/
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import authReducer, { setUser, logout, selectUser, selectIsAuthenticated } from '../slices/authSlice';
|
||||
import type { AuthUser } from '../../lib/api/auth';
|
||||
|
||||
// Mock localStorage
|
||||
const mockStorage: Record<string, string> = {};
|
||||
vi.stubGlobal('localStorage', {
|
||||
getItem: (key: string) => mockStorage[key] ?? null,
|
||||
setItem: (key: string, value: string) => { mockStorage[key] = value; },
|
||||
removeItem: (key: string) => { delete mockStorage[key]; },
|
||||
clear: () => { Object.keys(mockStorage).forEach(k => delete mockStorage[k]); },
|
||||
});
|
||||
|
||||
function createValidToken(): string {
|
||||
// Create a JWT-like token with exp far in the future
|
||||
const header = btoa(JSON.stringify({ alg: 'HS256' }));
|
||||
const payload = btoa(JSON.stringify({
|
||||
sub: 'test-user',
|
||||
exp: Math.floor(Date.now() / 1000) + 3600, // 1 hour from now
|
||||
}));
|
||||
return `${header}.${payload}.fake-signature`;
|
||||
}
|
||||
|
||||
function createExpiredToken(): string {
|
||||
const header = btoa(JSON.stringify({ alg: 'HS256' }));
|
||||
const payload = btoa(JSON.stringify({
|
||||
sub: 'test-user',
|
||||
exp: Math.floor(Date.now() / 1000) - 3600, // 1 hour ago
|
||||
}));
|
||||
return `${header}.${payload}.fake-signature`;
|
||||
}
|
||||
|
||||
describe('authSlice', () => {
|
||||
beforeEach(() => {
|
||||
Object.keys(mockStorage).forEach(k => delete mockStorage[k]);
|
||||
});
|
||||
|
||||
it('QA-C5: setUser stores user and marks authenticated with valid token', () => {
|
||||
const user: AuthUser = {
|
||||
id: 'user-1',
|
||||
email: 'admin@test.com',
|
||||
displayName: 'Admin',
|
||||
role: 'Admin',
|
||||
token: createValidToken(),
|
||||
};
|
||||
|
||||
const initialState = { user: null, isAuthenticated: false, loading: false, error: null };
|
||||
const state = authReducer(initialState, setUser(user));
|
||||
|
||||
expect(state.user).toEqual(user);
|
||||
expect(state.isAuthenticated).toBe(true);
|
||||
expect(state.loading).toBe(false);
|
||||
expect(state.error).toBeNull();
|
||||
});
|
||||
|
||||
it('QA-C5: setUser with expired token sets isAuthenticated to false', () => {
|
||||
const user: AuthUser = {
|
||||
id: 'user-1',
|
||||
email: 'admin@test.com',
|
||||
displayName: 'Admin',
|
||||
role: 'Admin',
|
||||
token: createExpiredToken(),
|
||||
};
|
||||
|
||||
const initialState = { user: null, isAuthenticated: false, loading: false, error: null };
|
||||
const state = authReducer(initialState, setUser(user));
|
||||
|
||||
expect(state.user).toEqual(user);
|
||||
expect(state.isAuthenticated).toBe(false);
|
||||
});
|
||||
|
||||
it('QA-C5: logout clears user and isAuthenticated', () => {
|
||||
const user: AuthUser = {
|
||||
id: 'user-1',
|
||||
email: 'admin@test.com',
|
||||
displayName: 'Admin',
|
||||
role: 'Admin',
|
||||
token: createValidToken(),
|
||||
};
|
||||
|
||||
const loggedInState = { user, isAuthenticated: true, loading: false, error: null };
|
||||
const state = authReducer(loggedInState, logout());
|
||||
|
||||
expect(state.user).toBeNull();
|
||||
expect(state.isAuthenticated).toBe(false);
|
||||
expect(state.error).toBeNull();
|
||||
});
|
||||
|
||||
it('QA-C5: selectUser returns user from state', () => {
|
||||
const user: AuthUser = {
|
||||
id: 'user-1',
|
||||
email: 'admin@test.com',
|
||||
displayName: 'Admin',
|
||||
role: 'Admin',
|
||||
token: createValidToken(),
|
||||
};
|
||||
|
||||
const state = { auth: { user, isAuthenticated: true, loading: false, error: null } };
|
||||
expect(selectUser(state)).toEqual(user);
|
||||
});
|
||||
|
||||
it('QA-C5: selectIsAuthenticated returns false when no user', () => {
|
||||
const state = { auth: { user: null, isAuthenticated: false, loading: false, error: null } };
|
||||
expect(selectIsAuthenticated(state)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
|
@ -1,83 +0,0 @@
|
|||
import { createSlice, PayloadAction } from '@reduxjs/toolkit';
|
||||
import { STORAGE_KEY_TOKEN } from '../../constants';
|
||||
import type { AuthUser } from '../../lib/api/auth';
|
||||
|
||||
interface AuthState {
|
||||
user: AuthUser | null;
|
||||
isAuthenticated: boolean;
|
||||
loading: boolean;
|
||||
error: string | null;
|
||||
}
|
||||
|
||||
// Fix: WEB-C1 — use sessionStorage instead of localStorage to limit JWT exposure.
|
||||
// Tokens are cleared when the browser tab closes, reducing XSS token-theft window.
|
||||
// Follow-up: move to httpOnly cookies once the API supports Set-Cookie auth flow.
|
||||
const getUserFromStorage = (): AuthUser | null => {
|
||||
try {
|
||||
const result = window.sessionStorage.getItem(STORAGE_KEY_TOKEN);
|
||||
return result ? JSON.parse(result) : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
const isTokenValid = (token: string): boolean => {
|
||||
if (!token) return false;
|
||||
try {
|
||||
const part = token.split('.')[1];
|
||||
if (!part) return false;
|
||||
const payload = JSON.parse(atob(part));
|
||||
return new Date(payload.exp * 1000) > new Date();
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
const storedUser = getUserFromStorage();
|
||||
|
||||
const initialState: AuthState = {
|
||||
user: storedUser,
|
||||
isAuthenticated: storedUser?.token ? isTokenValid(storedUser.token) : false,
|
||||
loading: false,
|
||||
error: null,
|
||||
};
|
||||
|
||||
const authSlice = createSlice({
|
||||
name: 'auth',
|
||||
initialState,
|
||||
reducers: {
|
||||
setUser: (state, action: PayloadAction<AuthUser>) => {
|
||||
state.user = action.payload;
|
||||
state.isAuthenticated = action.payload.token ? isTokenValid(action.payload.token) : false;
|
||||
state.loading = false;
|
||||
state.error = null;
|
||||
window.sessionStorage.setItem(STORAGE_KEY_TOKEN, JSON.stringify(action.payload)); // WEB-C1
|
||||
},
|
||||
logout: (state) => {
|
||||
state.user = null;
|
||||
state.isAuthenticated = false;
|
||||
state.error = null;
|
||||
window.sessionStorage.removeItem(STORAGE_KEY_TOKEN); // WEB-C1
|
||||
},
|
||||
setLoading: (state, action: PayloadAction<boolean>) => {
|
||||
state.loading = action.payload;
|
||||
},
|
||||
setError: (state, action: PayloadAction<string>) => {
|
||||
state.error = action.payload;
|
||||
state.loading = false;
|
||||
},
|
||||
clearError: (state) => {
|
||||
state.error = null;
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
export const { setUser, logout, setLoading, setError, clearError } = authSlice.actions;
|
||||
|
||||
export const selectUser = (state: { auth: AuthState }) => state.auth.user;
|
||||
export const selectIsAuthenticated = (state: { auth: AuthState }) => state.auth.isAuthenticated;
|
||||
export const selectAuthLoading = (state: { auth: AuthState }) => state.auth.loading;
|
||||
export const selectAuthError = (state: { auth: AuthState }) => state.auth.error;
|
||||
export const selectToken = (state: { auth: AuthState }) => state.auth.user?.token;
|
||||
|
||||
export default authSlice.reducer;
|
||||
|
|
@ -1,29 +0,0 @@
|
|||
import { createSlice, PayloadAction } from '@reduxjs/toolkit';
|
||||
import { STORAGE_KEY_SIDEBAR } from '../../constants';
|
||||
|
||||
interface UiState {
|
||||
sidebarOpen: boolean;
|
||||
}
|
||||
|
||||
const initialState: UiState = {
|
||||
sidebarOpen: window.localStorage.getItem(STORAGE_KEY_SIDEBAR) !== 'false',
|
||||
};
|
||||
|
||||
const uiSlice = createSlice({
|
||||
name: 'ui',
|
||||
initialState,
|
||||
reducers: {
|
||||
toggleSidebar: (state) => {
|
||||
state.sidebarOpen = !state.sidebarOpen;
|
||||
window.localStorage.setItem(STORAGE_KEY_SIDEBAR, String(state.sidebarOpen));
|
||||
},
|
||||
setSidebarOpen: (state, action: PayloadAction<boolean>) => {
|
||||
state.sidebarOpen = action.payload;
|
||||
window.localStorage.setItem(STORAGE_KEY_SIDEBAR, String(action.payload));
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
export const { toggleSidebar, setSidebarOpen } = uiSlice.actions;
|
||||
export const selectSidebarOpen = (state: { ui: UiState }) => state.ui.sidebarOpen;
|
||||
export default uiSlice.reducer;
|
||||
|
|
@ -1,19 +0,0 @@
|
|||
import { configureStore } from '@reduxjs/toolkit';
|
||||
import authReducer from './slices/authSlice';
|
||||
import uiReducer from './slices/uiSlice';
|
||||
|
||||
export const store = configureStore({
|
||||
reducer: {
|
||||
auth: authReducer,
|
||||
ui: uiReducer,
|
||||
},
|
||||
middleware: (getDefaultMiddleware) =>
|
||||
getDefaultMiddleware({
|
||||
serializableCheck: {
|
||||
ignoredActions: ['auth/setUser'],
|
||||
},
|
||||
}),
|
||||
});
|
||||
|
||||
export type RootState = ReturnType<typeof store.getState>;
|
||||
export type AppDispatch = typeof store.dispatch;
|
||||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -3,11 +3,13 @@ import { Box, CircularProgress } from '@mui/material';
|
|||
import { useAuth } from '../hooks/useAuth';
|
||||
|
||||
export default function ProtectedRoute({ children }: { children: React.ReactNode }) {
|
||||
const { isAuthenticated, loading } = useAuth();
|
||||
const { isAuthenticated, isLoading } = useAuth();
|
||||
|
||||
if (loading) {
|
||||
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>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -2,7 +2,6 @@
|
|||
// Expanded 244px / collapsed 76px; brand header row; grouped nav with SHOC
|
||||
// active treatment (white card bg + shadow + 3px left accent bar).
|
||||
import { useLocation, useNavigate } from 'react-router-dom';
|
||||
import { useSelector, useDispatch } from 'react-redux';
|
||||
import { Drawer, Box, Tooltip, useMediaQuery, useTheme } from '@mui/material';
|
||||
import {
|
||||
LayoutDashboard,
|
||||
|
|
@ -15,9 +14,7 @@ import {
|
|||
Users,
|
||||
type LucideIcon,
|
||||
} from 'lucide-react';
|
||||
import { selectSidebarOpen, setSidebarOpen } from '../app/slices/uiSlice';
|
||||
import { selectUser } from '../app/slices/authSlice';
|
||||
import type { RootState } from '../app/store';
|
||||
import { useAuth } from '../hooks/useAuth';
|
||||
import { DRAWER_WIDTH } from '../constants';
|
||||
import BrandLockup, { BrandMark } from './brand/BrandLockup';
|
||||
|
||||
|
|
@ -38,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;
|
||||
|
|
@ -118,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>
|
||||
)}
|
||||
|
|
@ -135,13 +131,17 @@ function NavItem({
|
|||
return row;
|
||||
}
|
||||
|
||||
export default function Sidebar() {
|
||||
export default function Sidebar({
|
||||
open,
|
||||
onOpenChange,
|
||||
}: {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
}) {
|
||||
const navigate = useNavigate();
|
||||
const location = useLocation();
|
||||
const dispatch = useDispatch();
|
||||
const theme = useTheme();
|
||||
const open = useSelector((state: RootState) => selectSidebarOpen(state));
|
||||
const user = useSelector((state: RootState) => selectUser(state));
|
||||
const { user } = useAuth();
|
||||
|
||||
const isMobile = useMediaQuery(theme.breakpoints.down('md')); // <900px
|
||||
|
||||
|
|
@ -156,7 +156,7 @@ export default function Sidebar() {
|
|||
const handleNavClick = (path: string) => {
|
||||
navigate(path);
|
||||
if (isMobile) {
|
||||
dispatch(setSidebarOpen(false));
|
||||
onOpenChange(false);
|
||||
}
|
||||
};
|
||||
|
||||
|
|
@ -167,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) => (
|
||||
|
|
@ -189,7 +191,7 @@ export default function Sidebar() {
|
|||
<Drawer
|
||||
variant={isMobile ? 'temporary' : 'permanent'}
|
||||
open={isMobile ? open : true}
|
||||
onClose={() => dispatch(setSidebarOpen(false))}
|
||||
onClose={() => onOpenChange(false)}
|
||||
sx={{
|
||||
width: isMobile ? 0 : width,
|
||||
flexShrink: 0,
|
||||
|
|
|
|||
|
|
@ -1,16 +1,18 @@
|
|||
// App shell top bar — SHOC design system port (ADR 0003, mirrors app-topbar.tsx).
|
||||
import { useDispatch } from 'react-redux';
|
||||
import { AppBar, Toolbar, IconButton, Box, Avatar, Typography } from '@mui/material';
|
||||
import { Menu as MenuIcon, LogOut } from 'lucide-react';
|
||||
import { toggleSidebar } from '../app/slices/uiSlice';
|
||||
import { useAuth } from '../hooks/useAuth';
|
||||
|
||||
export default function Topbar() {
|
||||
const dispatch = useDispatch();
|
||||
export default function Topbar({ onToggleSidebar }: { onToggleSidebar: () => void }) {
|
||||
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;
|
||||
|
|
@ -27,9 +29,18 @@ export default function Topbar() {
|
|||
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={() => dispatch(toggleSidebar())}
|
||||
onClick={onToggleSidebar}
|
||||
aria-label="Toggle sidebar menu"
|
||||
sx={{
|
||||
width: 38,
|
||||
|
|
@ -91,10 +102,14 @@ export default function Topbar() {
|
|||
{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>
|
||||
|
|
@ -104,7 +119,10 @@ export default function Topbar() {
|
|||
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} />
|
||||
|
|
|
|||
|
|
@ -72,7 +72,7 @@ describe('RoleGuard', () => {
|
|||
mockUseAuth.mockReturnValue({
|
||||
isAuthenticated: true,
|
||||
user: { id: '1', email: 'admin@test.com', displayName: 'Admin', role: 'Admin', token: 'tok' },
|
||||
loading: false,
|
||||
isLoading: false,
|
||||
});
|
||||
|
||||
renderWithRouter(
|
||||
|
|
@ -87,8 +87,14 @@ 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' },
|
||||
loading: false,
|
||||
user: {
|
||||
id: '2',
|
||||
email: 'dispatch@test.com',
|
||||
displayName: 'Dispatcher',
|
||||
role: 'Dispatcher',
|
||||
token: 'tok',
|
||||
},
|
||||
isLoading: false,
|
||||
});
|
||||
|
||||
renderWithRouter(
|
||||
|
|
@ -105,7 +111,7 @@ describe('RoleGuard', () => {
|
|||
mockUseAuth.mockReturnValue({
|
||||
isAuthenticated: false,
|
||||
user: null,
|
||||
loading: false,
|
||||
isLoading: false,
|
||||
});
|
||||
|
||||
renderWithRouter(
|
||||
|
|
@ -121,8 +127,14 @@ 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' },
|
||||
loading: false,
|
||||
user: {
|
||||
id: '3',
|
||||
email: 'sysadmin@test.com',
|
||||
displayName: 'SysAdmin',
|
||||
role: 'SysAdmin',
|
||||
token: 'tok',
|
||||
},
|
||||
isLoading: false,
|
||||
});
|
||||
|
||||
renderWithRouter(
|
||||
|
|
@ -138,7 +150,7 @@ describe('RoleGuard', () => {
|
|||
mockUseAuth.mockReturnValue({
|
||||
isAuthenticated: true,
|
||||
user: { id: '4', email: 'admin@test.com', displayName: 'Admin', role: 'Admin', token: 'tok' },
|
||||
loading: false,
|
||||
isLoading: false,
|
||||
});
|
||||
|
||||
renderWithRouter(
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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 }}>
|
||||
|
|
|
|||
|
|
@ -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' },
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
||||
|
|
|
|||
|
|
@ -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: {
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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). */
|
||||
|
|
|
|||
|
|
@ -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<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(),
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
|
|
|
|||
|
|
@ -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()}`);
|
||||
|
|
|
|||
|
|
@ -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.`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,26 +1,24 @@
|
|||
import { useSelector, useDispatch } from 'react-redux';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import {
|
||||
selectUser,
|
||||
selectIsAuthenticated,
|
||||
selectAuthLoading,
|
||||
selectAuthError,
|
||||
logout as logoutAction,
|
||||
} from '../app/slices/authSlice';
|
||||
import { toast } from 'react-toastify';
|
||||
import type { RootState } from '../app/store';
|
||||
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 dispatch = useDispatch();
|
||||
const {
|
||||
user,
|
||||
isAuthenticated,
|
||||
isLoading,
|
||||
error,
|
||||
login,
|
||||
logout: clearSession,
|
||||
setError,
|
||||
setLoading,
|
||||
} = useAuthContext();
|
||||
const navigate = useNavigate();
|
||||
|
||||
const user = useSelector((state: RootState) => selectUser(state));
|
||||
const isAuthenticated = useSelector((state: RootState) => selectIsAuthenticated(state));
|
||||
const loading = useSelector((state: RootState) => selectAuthLoading(state));
|
||||
const error = useSelector((state: RootState) => selectAuthError(state));
|
||||
|
||||
const logout = () => {
|
||||
dispatch(logoutAction());
|
||||
clearSession();
|
||||
toast.info('You have been logged out');
|
||||
navigate('/login');
|
||||
};
|
||||
|
|
@ -28,8 +26,11 @@ export function useAuth() {
|
|||
return {
|
||||
user,
|
||||
isAuthenticated,
|
||||
loading,
|
||||
isLoading,
|
||||
error,
|
||||
login,
|
||||
logout,
|
||||
setError,
|
||||
setLoading,
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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]);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3,7 +3,9 @@
|
|||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
html, body, #root {
|
||||
html,
|
||||
body,
|
||||
#root {
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@
|
|||
* Tests that:
|
||||
* - Request interceptor attaches Bearer token from sessionStorage
|
||||
* - Request interceptor handles missing/invalid token data gracefully
|
||||
* - Response interceptor dispatches Redux logout and redirects on 401
|
||||
* - Response interceptor clears the stored session and redirects on 401
|
||||
* - Response interceptor returns friendly error message on 403
|
||||
* - Response interceptor returns friendly error message on 404
|
||||
* - Response interceptor extracts server error detail from response body
|
||||
|
|
@ -14,34 +14,14 @@ import { describe, it, expect, vi, beforeAll, beforeEach } from 'vitest';
|
|||
import type { InternalAxiosRequestConfig } from 'axios';
|
||||
|
||||
// vi.hoisted returns values accessible in both the hoisted mock scope and test scope.
|
||||
const { interceptors, mockDispatch } = vi.hoisted(() => {
|
||||
const { interceptors } = vi.hoisted(() => {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const state: Record<string, any> = {};
|
||||
const dispatch = vi.fn();
|
||||
return {
|
||||
interceptors: state,
|
||||
mockDispatch: dispatch,
|
||||
};
|
||||
});
|
||||
|
||||
// Mock the Redux store
|
||||
vi.mock('../../../app/store', () => ({
|
||||
store: {
|
||||
dispatch: (...args: unknown[]) => mockDispatch(...args),
|
||||
getState: () => ({ auth: { user: null, isAuthenticated: false, loading: false, error: null } }),
|
||||
subscribe: vi.fn(),
|
||||
replaceReducer: vi.fn(),
|
||||
[Symbol.observable]: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
// Mock the authSlice logout action
|
||||
vi.mock('../../../app/slices/authSlice', () => ({
|
||||
logout: () => ({ type: 'auth/logout' }),
|
||||
setUser: (user: unknown) => ({ type: 'auth/setUser', payload: user }),
|
||||
default: (state: unknown) => state,
|
||||
}));
|
||||
|
||||
// Mock axios
|
||||
vi.mock('axios', () => {
|
||||
const mockInstance = {
|
||||
|
|
@ -77,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),
|
||||
});
|
||||
|
||||
|
|
@ -100,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 = '';
|
||||
});
|
||||
|
||||
|
|
@ -140,6 +128,18 @@ describe('API client interceptors', () => {
|
|||
expect(result.headers.Authorization).toBeUndefined();
|
||||
});
|
||||
|
||||
it('does not attach header when the token field is not a string', () => {
|
||||
sessionStorageData['proposal_system_token'] = JSON.stringify({ token: { a: 1 } });
|
||||
|
||||
const config = {
|
||||
headers: {} as Record<string, string>,
|
||||
} as unknown as InternalAxiosRequestConfig;
|
||||
|
||||
const result = interceptors.requestFulfilled(config);
|
||||
|
||||
expect(result.headers.Authorization).toBeUndefined();
|
||||
});
|
||||
|
||||
it('WEB-C1: does not attach header when token field is missing in parsed data', () => {
|
||||
sessionStorageData['proposal_system_token'] = JSON.stringify({ email: 'user@test.com' });
|
||||
|
||||
|
|
@ -161,7 +161,8 @@ describe('API client interceptors', () => {
|
|||
});
|
||||
|
||||
describe('response interceptor', () => {
|
||||
it('WEB-M2: 401 response dispatches Redux logout and redirects to /login', async () => {
|
||||
it('WEB-M2: 401 response clears the stored session and redirects to /login', async () => {
|
||||
sessionStorageData['proposal_system_token'] = JSON.stringify({ token: 'stale-jwt' });
|
||||
const error = {
|
||||
response: {
|
||||
status: 401,
|
||||
|
|
@ -173,11 +174,12 @@ describe('API client interceptors', () => {
|
|||
const promise = interceptors.responseRejected(error);
|
||||
|
||||
await expect(promise).rejects.toThrow('Session expired. Please log in again.');
|
||||
expect(mockDispatch).toHaveBeenCalledWith({ type: 'auth/logout' });
|
||||
expect(sessionStorageData['proposal_system_token']).toBeUndefined();
|
||||
expect(mockLocation.href).toBe('/login');
|
||||
});
|
||||
|
||||
it('WEB-M2: 403 response returns permission error without dispatching logout', async () => {
|
||||
it('WEB-M2: 403 response returns permission error without clearing the session', async () => {
|
||||
sessionStorageData['proposal_system_token'] = JSON.stringify({ token: 'valid-jwt' });
|
||||
const error = {
|
||||
response: {
|
||||
status: 403,
|
||||
|
|
@ -189,7 +191,8 @@ describe('API client interceptors', () => {
|
|||
const promise = interceptors.responseRejected(error);
|
||||
|
||||
await expect(promise).rejects.toThrow('You do not have permission to perform this action.');
|
||||
expect(mockDispatch).not.toHaveBeenCalled();
|
||||
expect(sessionStorageData['proposal_system_token']).toBeDefined();
|
||||
expect(mockLocation.href).toBe('');
|
||||
});
|
||||
|
||||
it('404 response returns resource-not-found error', async () => {
|
||||
|
|
@ -255,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 () => {
|
||||
|
|
|
|||
|
|
@ -1,7 +1,6 @@
|
|||
import axios from 'axios';
|
||||
import { API_URL, STORAGE_KEY_TOKEN } from '../../constants';
|
||||
import { store } from '../../app/store';
|
||||
import { logout } from '../../app/slices/authSlice';
|
||||
import { AUTH_SESSION_CLEARED_EVENT, clearAuth } from '../auth/authStorage';
|
||||
|
||||
const apiClient = axios.create({
|
||||
baseURL: API_URL,
|
||||
|
|
@ -17,7 +16,7 @@ apiClient.interceptors.request.use(
|
|||
if (tokenData) {
|
||||
try {
|
||||
const parsed = JSON.parse(tokenData);
|
||||
if (parsed?.token) {
|
||||
if (typeof parsed?.token === 'string') {
|
||||
config.headers.Authorization = `Bearer ${parsed.token}`;
|
||||
}
|
||||
} catch {
|
||||
|
|
@ -26,7 +25,7 @@ apiClient.interceptors.request.use(
|
|||
}
|
||||
return config;
|
||||
},
|
||||
(error) => Promise.reject(error)
|
||||
(error) => Promise.reject(error),
|
||||
);
|
||||
|
||||
apiClient.interceptors.response.use(
|
||||
|
|
@ -36,8 +35,10 @@ apiClient.interceptors.response.use(
|
|||
const { status, data } = error.response;
|
||||
|
||||
if (status === 401) {
|
||||
// Fix: WEB-M2 — dispatch Redux logout to clear auth state in addition to storage
|
||||
store.dispatch(logout());
|
||||
// Fix: WEB-M2 — terminate the session atomically: clear storage, tell the
|
||||
// AuthProvider to drop in-memory state, then hard-redirect as belt-and-braces.
|
||||
clearAuth();
|
||||
window.dispatchEvent(new Event(AUTH_SESSION_CLEARED_EVENT));
|
||||
window.location.href = '/login';
|
||||
return Promise.reject(new Error('Session expired. Please log in again.'));
|
||||
}
|
||||
|
|
@ -59,7 +60,7 @@ apiClient.interceptors.response.use(
|
|||
}
|
||||
|
||||
return Promise.reject(error);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
export default apiClient;
|
||||
|
|
|
|||
91
web/src/lib/auth/__tests__/authStorage.test.ts
Normal file
91
web/src/lib/auth/__tests__/authStorage.test.ts
Normal file
|
|
@ -0,0 +1,91 @@
|
|||
/**
|
||||
* QA-C5: Auth storage module tests (replaces the Redux authSlice tests).
|
||||
*
|
||||
* Tests the sessionStorage-backed session persistence (WEB-C1):
|
||||
* - setAuthUser/getAuthUser round-trip
|
||||
* - getAuthUser tolerates malformed stored JSON
|
||||
* - clearAuth removes the stored session
|
||||
* - isTokenValid accepts unexpired JWTs and rejects expired/malformed ones
|
||||
*/
|
||||
import { describe, it, expect, beforeEach } from 'vitest';
|
||||
import type { AuthUser } from '@proposal-system/api-contracts';
|
||||
import { getAuthUser, setAuthUser, clearAuth, isTokenValid } from '../authStorage';
|
||||
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,
|
||||
}),
|
||||
);
|
||||
return `${header}.${payload}.fake-signature`;
|
||||
}
|
||||
|
||||
const user: AuthUser = {
|
||||
id: 'user-1',
|
||||
email: 'admin@test.com',
|
||||
displayName: 'Admin',
|
||||
role: 'Admin',
|
||||
token: createToken(3600),
|
||||
};
|
||||
|
||||
describe('authStorage', () => {
|
||||
beforeEach(() => {
|
||||
window.sessionStorage.clear();
|
||||
});
|
||||
|
||||
it('QA-C5: setAuthUser persists to sessionStorage and getAuthUser reads it back', () => {
|
||||
setAuthUser(user);
|
||||
|
||||
expect(window.sessionStorage.getItem(STORAGE_KEY_TOKEN)).not.toBeNull();
|
||||
expect(getAuthUser()).toEqual(user);
|
||||
});
|
||||
|
||||
it('QA-C5: getAuthUser returns null when nothing is stored', () => {
|
||||
expect(getAuthUser()).toBeNull();
|
||||
});
|
||||
|
||||
it('QA-C5: getAuthUser returns null for malformed stored JSON', () => {
|
||||
window.sessionStorage.setItem(STORAGE_KEY_TOKEN, 'not-valid-json{{{');
|
||||
|
||||
expect(getAuthUser()).toBeNull();
|
||||
});
|
||||
|
||||
it('QA-C5: clearAuth removes the stored session', () => {
|
||||
setAuthUser(user);
|
||||
clearAuth();
|
||||
|
||||
expect(window.sessionStorage.getItem(STORAGE_KEY_TOKEN)).toBeNull();
|
||||
expect(getAuthUser()).toBeNull();
|
||||
});
|
||||
|
||||
it('QA-C5: isTokenValid accepts a token expiring in the future', () => {
|
||||
expect(isTokenValid(createToken(3600))).toBe(true);
|
||||
});
|
||||
|
||||
it('QA-C5: isTokenValid rejects an expired token', () => {
|
||||
expect(isTokenValid(createToken(-3600))).toBe(false);
|
||||
});
|
||||
|
||||
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 }),
|
||||
)
|
||||
.replace(/\+/g, '-')
|
||||
.replace(/\//g, '_')
|
||||
.replace(/=+$/, '');
|
||||
expect(payload).toMatch(/[-_]/);
|
||||
|
||||
expect(isTokenValid(`${btoa(JSON.stringify({ alg: 'HS256' }))}.${payload}.sig`)).toBe(true);
|
||||
});
|
||||
|
||||
it('QA-C5: isTokenValid rejects missing or malformed tokens', () => {
|
||||
expect(isTokenValid(null)).toBe(false);
|
||||
expect(isTokenValid(undefined)).toBe(false);
|
||||
expect(isTokenValid('')).toBe(false);
|
||||
expect(isTokenValid('not-a-jwt')).toBe(false);
|
||||
});
|
||||
});
|
||||
40
web/src/lib/auth/authStorage.ts
Normal file
40
web/src/lib/auth/authStorage.ts
Normal file
|
|
@ -0,0 +1,40 @@
|
|||
// Plain auth-storage module — SHOC shape (mirrors lib/auth/auth-storage.ts).
|
||||
// Fix: WEB-C1 — use sessionStorage instead of localStorage to limit JWT exposure.
|
||||
// Tokens are cleared when the browser tab closes, reducing XSS token-theft window.
|
||||
// Follow-up: move to httpOnly cookies once the API supports Set-Cookie auth flow.
|
||||
import type { AuthUser } from '@proposal-system/api-contracts';
|
||||
import { STORAGE_KEY_TOKEN } from '../../constants';
|
||||
|
||||
export function getAuthUser(): AuthUser | null {
|
||||
try {
|
||||
const raw = window.sessionStorage.getItem(STORAGE_KEY_TOKEN);
|
||||
return raw ? JSON.parse(raw) : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function setAuthUser(user: AuthUser): void {
|
||||
window.sessionStorage.setItem(STORAGE_KEY_TOKEN, JSON.stringify(user)); // WEB-C1
|
||||
}
|
||||
|
||||
export function clearAuth(): void {
|
||||
window.sessionStorage.removeItem(STORAGE_KEY_TOKEN); // WEB-C1
|
||||
}
|
||||
|
||||
// Dispatched by non-React code (the 401 interceptor) so AuthProvider can drop
|
||||
// in-memory session state synchronously, keeping storage and context atomic.
|
||||
export const AUTH_SESSION_CLEARED_EVENT = 'auth:session-cleared';
|
||||
|
||||
export function isTokenValid(token: string | null | undefined): boolean {
|
||||
if (!token) return false;
|
||||
try {
|
||||
const part = token.split('.')[1];
|
||||
if (!part) return false;
|
||||
// JWT segments are base64url; atob only accepts standard base64.
|
||||
const payload = JSON.parse(atob(part.replace(/-/g, '+').replace(/_/g, '/')));
|
||||
return new Date(payload.exp * 1000) > new Date();
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
|
@ -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+$/, '');
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -1,21 +1,20 @@
|
|||
import React from 'react';
|
||||
import ReactDOM from 'react-dom/client';
|
||||
import { BrowserRouter } from 'react-router-dom';
|
||||
import { Provider } from 'react-redux';
|
||||
import { QueryClientProvider } from '@tanstack/react-query';
|
||||
import { ThemeProvider, CssBaseline } from '@mui/material';
|
||||
import { ToastContainer } from 'react-toastify';
|
||||
import 'react-toastify/dist/ReactToastify.css';
|
||||
import './styles/globals.css';
|
||||
import './index.css';
|
||||
import { store } from './app/store';
|
||||
import AuthProvider from './providers/AuthProvider';
|
||||
import { queryClient } from './lib/queryClient';
|
||||
import { theme } from './theme';
|
||||
import App from './App';
|
||||
|
||||
ReactDOM.createRoot(document.getElementById('root')!).render(
|
||||
<React.StrictMode>
|
||||
<Provider store={store}>
|
||||
<AuthProvider>
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<ThemeProvider theme={theme}>
|
||||
<CssBaseline />
|
||||
|
|
@ -26,6 +25,6 @@ ReactDOM.createRoot(document.getElementById('root')!).render(
|
|||
</BrowserRouter>
|
||||
</ThemeProvider>
|
||||
</QueryClientProvider>
|
||||
</Provider>
|
||||
</React.StrictMode>
|
||||
</AuthProvider>
|
||||
</React.StrictMode>,
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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) => {
|
||||
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() {
|
|||
</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
|
||||
? `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 (
|
||||
<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={() => {
|
||||
setNavBlocked(false);
|
||||
const dest = pendingNavRef.current ?? '/admin';
|
||||
pendingNavRef.current = null;
|
||||
navigate(dest);
|
||||
}}>
|
||||
<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>
|
||||
|
|
|
|||
|
|
@ -1,13 +1,12 @@
|
|||
import { useEffect, useRef } from 'react';
|
||||
import { useNavigate, useSearchParams } from 'react-router-dom';
|
||||
import { useDispatch } from 'react-redux';
|
||||
import { Box, CircularProgress, Typography } from '@mui/material';
|
||||
import { setUser, setError, setLoading } from '../../app/slices/authSlice';
|
||||
import { useAuth } from '../../hooks/useAuth';
|
||||
import { authApi } from '../../lib/api/auth';
|
||||
|
||||
export default function AuthCallback() {
|
||||
const navigate = useNavigate();
|
||||
const dispatch = useDispatch();
|
||||
const { login, setError, setLoading } = useAuth();
|
||||
const [searchParams] = useSearchParams();
|
||||
const processed = useRef(false);
|
||||
|
||||
|
|
@ -19,29 +18,29 @@ export default function AuthCallback() {
|
|||
const error = searchParams.get('error');
|
||||
|
||||
if (error) {
|
||||
dispatch(setError(error));
|
||||
setError(error);
|
||||
navigate('/login', { replace: true });
|
||||
return;
|
||||
}
|
||||
|
||||
if (!code) {
|
||||
dispatch(setError('No authorization code received'));
|
||||
setError('No authorization code received');
|
||||
navigate('/login', { replace: true });
|
||||
return;
|
||||
}
|
||||
|
||||
dispatch(setLoading(true));
|
||||
setLoading(true);
|
||||
authApi
|
||||
.exchangeCode(code, `${window.location.origin}/callback`)
|
||||
.then((user) => {
|
||||
dispatch(setUser(user));
|
||||
login(user);
|
||||
navigate('/', { replace: true });
|
||||
})
|
||||
.catch((err) => {
|
||||
dispatch(setError(err.message || 'Authentication failed'));
|
||||
setError(err.message || 'Authentication failed');
|
||||
navigate('/login', { replace: true });
|
||||
});
|
||||
}, [searchParams, dispatch, navigate]);
|
||||
}, [searchParams, login, setError, setLoading, navigate]);
|
||||
|
||||
return (
|
||||
<Box
|
||||
|
|
|
|||
|
|
@ -2,7 +2,6 @@
|
|||
// centered 384px card on --color-bg-page, brand lockup, Montserrat heading.
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { useDispatch } from 'react-redux';
|
||||
import { Alert, Box, Button, Typography, Divider, Stack } from '@mui/material';
|
||||
import GoogleIcon from '@mui/icons-material/Google';
|
||||
import AdminPanelSettingsIcon from '@mui/icons-material/AdminPanelSettings';
|
||||
|
|
@ -10,7 +9,6 @@ import EngineeringIcon from '@mui/icons-material/Engineering';
|
|||
import PersonIcon from '@mui/icons-material/Person';
|
||||
import { useAuth } from '../../hooks/useAuth';
|
||||
import { authApi } from '../../lib/api/auth';
|
||||
import { setUser, setError } from '../../app/slices/authSlice';
|
||||
import BrandLockup from '../../components/brand/BrandLockup';
|
||||
|
||||
const COGNITO_DOMAIN = import.meta.env.VITE_COGNITO_DOMAIN || '';
|
||||
|
|
@ -35,8 +33,7 @@ const devButtonSx = {
|
|||
|
||||
export default function LoginPage() {
|
||||
const navigate = useNavigate();
|
||||
const dispatch = useDispatch();
|
||||
const { isAuthenticated, error } = useAuth();
|
||||
const { isAuthenticated, error, login, setError } = useAuth();
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
const isDevMode = !CLIENT_ID;
|
||||
|
|
@ -58,10 +55,10 @@ export default function LoginPage() {
|
|||
try {
|
||||
const dev = devUsers[role] ?? devUsers['SysAdmin']!;
|
||||
const user = await authApi.devLogin(dev.email, dev.name, role);
|
||||
dispatch(setUser(user));
|
||||
login(user);
|
||||
navigate('/', { replace: true });
|
||||
} catch (err) {
|
||||
dispatch(setError(err instanceof Error ? err.message : 'Dev login failed'));
|
||||
setError(err instanceof Error ? err.message : 'Dev login failed');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
|
|
@ -137,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
|
||||
|
|
@ -150,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
|
||||
|
|
@ -163,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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
))}
|
||||
|
|
|
|||
68
web/src/providers/AuthProvider.tsx
Normal file
68
web/src/providers/AuthProvider.tsx
Normal file
|
|
@ -0,0 +1,68 @@
|
|||
// Auth provider — SHOC shape (mirrors providers/auth-provider.tsx).
|
||||
// Session persistence is delegated to lib/auth/authStorage (sessionStorage, WEB-C1);
|
||||
// this provider only owns the in-memory state. Token acquisition (Cognito code
|
||||
// exchange, dev-login) stays in the auth pages — they hand the resolved AuthUser
|
||||
// to login().
|
||||
import { useCallback, useEffect, useMemo, useState, type ReactNode } from 'react';
|
||||
import type { AuthUser } from '@proposal-system/api-contracts';
|
||||
import {
|
||||
AUTH_SESSION_CLEARED_EVENT,
|
||||
clearAuth,
|
||||
getAuthUser,
|
||||
isTokenValid,
|
||||
setAuthUser,
|
||||
} from '../lib/auth/authStorage';
|
||||
import { queryClient } from '../lib/queryClient';
|
||||
import { AuthContext, type AuthContextValue } from './authContext';
|
||||
|
||||
export default function AuthProvider({ children }: { children: ReactNode }) {
|
||||
const [user, setUser] = useState<AuthUser | null>(() => getAuthUser());
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [error, setErrorState] = useState<string | null>(null);
|
||||
|
||||
const login = useCallback((nextUser: AuthUser) => {
|
||||
setAuthUser(nextUser);
|
||||
setUser(nextUser);
|
||||
setErrorState(null);
|
||||
setIsLoading(false);
|
||||
}, []);
|
||||
|
||||
const logout = useCallback(() => {
|
||||
clearAuth();
|
||||
// Drop all principal-scoped state: cached queries must not survive into the
|
||||
// next login in the same tab, or query keys shared across users would serve
|
||||
// the previous principal's data without hitting the server.
|
||||
queryClient.clear();
|
||||
setUser(null);
|
||||
setErrorState(null);
|
||||
}, []);
|
||||
|
||||
// The 401 interceptor clears storage outside React; mirror it here so context
|
||||
// state never outlives the stored session while the redirect commits.
|
||||
useEffect(() => {
|
||||
const onSessionCleared = () => setUser(null);
|
||||
window.addEventListener(AUTH_SESSION_CLEARED_EVENT, onSessionCleared);
|
||||
return () => window.removeEventListener(AUTH_SESSION_CLEARED_EVENT, onSessionCleared);
|
||||
}, []);
|
||||
|
||||
const setError = useCallback((message: string | null) => {
|
||||
setErrorState(message);
|
||||
setIsLoading(false);
|
||||
}, []);
|
||||
|
||||
const value = useMemo<AuthContextValue>(
|
||||
() => ({
|
||||
user,
|
||||
isAuthenticated: isTokenValid(user?.token),
|
||||
isLoading,
|
||||
error,
|
||||
login,
|
||||
logout,
|
||||
setError,
|
||||
setLoading: setIsLoading,
|
||||
}),
|
||||
[user, isLoading, error, login, logout, setError],
|
||||
);
|
||||
|
||||
return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>;
|
||||
}
|
||||
131
web/src/providers/__tests__/AuthProvider.test.tsx
Normal file
131
web/src/providers/__tests__/AuthProvider.test.tsx
Normal file
|
|
@ -0,0 +1,131 @@
|
|||
/**
|
||||
* QA-C5: AuthProvider tests (replaces the Redux authSlice tests).
|
||||
*
|
||||
* Tests the context state management:
|
||||
* - login stores the user and marks authenticated
|
||||
* - login with an expired token leaves isAuthenticated false
|
||||
* - logout clears the user, the error, and the stored session
|
||||
* - state initializes from an existing stored session
|
||||
* - setError records the message and ends loading
|
||||
*/
|
||||
import { act, renderHook } from '@testing-library/react';
|
||||
import { describe, it, expect, beforeEach } from 'vitest';
|
||||
import type { AuthUser } from '@proposal-system/api-contracts';
|
||||
import AuthProvider from '../AuthProvider';
|
||||
import { useAuthContext } from '../authContext';
|
||||
import { AUTH_SESSION_CLEARED_EVENT, setAuthUser } from '../../lib/auth/authStorage';
|
||||
import { queryClient } from '../../lib/queryClient';
|
||||
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,
|
||||
}),
|
||||
);
|
||||
return `${header}.${payload}.fake-signature`;
|
||||
}
|
||||
|
||||
function createUser(expOffsetSeconds = 3600): AuthUser {
|
||||
return {
|
||||
id: 'user-1',
|
||||
email: 'admin@test.com',
|
||||
displayName: 'Admin',
|
||||
role: 'Admin',
|
||||
token: createToken(expOffsetSeconds),
|
||||
};
|
||||
}
|
||||
|
||||
function renderAuth() {
|
||||
return renderHook(() => useAuthContext(), { wrapper: AuthProvider });
|
||||
}
|
||||
|
||||
describe('AuthProvider', () => {
|
||||
beforeEach(() => {
|
||||
window.sessionStorage.clear();
|
||||
});
|
||||
|
||||
it('QA-C5: login stores the user and marks authenticated with a valid token', () => {
|
||||
const user = createUser();
|
||||
const { result } = renderAuth();
|
||||
|
||||
act(() => result.current.login(user));
|
||||
|
||||
expect(result.current.user).toEqual(user);
|
||||
expect(result.current.isAuthenticated).toBe(true);
|
||||
expect(result.current.error).toBeNull();
|
||||
expect(window.sessionStorage.getItem(STORAGE_KEY_TOKEN)).not.toBeNull();
|
||||
});
|
||||
|
||||
it('QA-C5: login with an expired token leaves isAuthenticated false', () => {
|
||||
const user = createUser(-3600);
|
||||
const { result } = renderAuth();
|
||||
|
||||
act(() => result.current.login(user));
|
||||
|
||||
expect(result.current.user).toEqual(user);
|
||||
expect(result.current.isAuthenticated).toBe(false);
|
||||
});
|
||||
|
||||
it('QA-C5: logout clears the user, the error, and the stored session', () => {
|
||||
const { result } = renderAuth();
|
||||
|
||||
act(() => result.current.login(createUser()));
|
||||
act(() => result.current.logout());
|
||||
|
||||
expect(result.current.user).toBeNull();
|
||||
expect(result.current.isAuthenticated).toBe(false);
|
||||
expect(result.current.error).toBeNull();
|
||||
expect(window.sessionStorage.getItem(STORAGE_KEY_TOKEN)).toBeNull();
|
||||
});
|
||||
|
||||
it('logout clears the react-query cache so the next principal cannot read cached data', () => {
|
||||
queryClient.setQueryData(['proposals', 'list'], [{ id: 'p1' }]);
|
||||
const { result } = renderAuth();
|
||||
|
||||
act(() => result.current.login(createUser()));
|
||||
act(() => result.current.logout());
|
||||
|
||||
expect(queryClient.getQueryData(['proposals', 'list'])).toBeUndefined();
|
||||
});
|
||||
|
||||
it('drops in-memory session state when the 401 interceptor broadcasts session-cleared', () => {
|
||||
const { result } = renderAuth();
|
||||
|
||||
act(() => result.current.login(createUser()));
|
||||
act(() => {
|
||||
window.dispatchEvent(new Event(AUTH_SESSION_CLEARED_EVENT));
|
||||
});
|
||||
|
||||
expect(result.current.user).toBeNull();
|
||||
expect(result.current.isAuthenticated).toBe(false);
|
||||
});
|
||||
|
||||
it('QA-C5: initializes from an existing stored session', () => {
|
||||
const user = createUser();
|
||||
setAuthUser(user);
|
||||
|
||||
const { result } = renderAuth();
|
||||
|
||||
expect(result.current.user).toEqual(user);
|
||||
expect(result.current.isAuthenticated).toBe(true);
|
||||
});
|
||||
|
||||
it('QA-C5: setError records the message and ends loading', () => {
|
||||
const { result } = renderAuth();
|
||||
|
||||
act(() => result.current.setLoading(true));
|
||||
act(() => result.current.setError('Authentication failed'));
|
||||
|
||||
expect(result.current.error).toBe('Authentication failed');
|
||||
expect(result.current.isLoading).toBe(false);
|
||||
});
|
||||
|
||||
it('QA-C5: useAuthContext throws outside AuthProvider', () => {
|
||||
expect(() => renderHook(() => useAuthContext())).toThrow(
|
||||
'useAuthContext must be used within AuthProvider',
|
||||
);
|
||||
});
|
||||
});
|
||||
27
web/src/providers/authContext.ts
Normal file
27
web/src/providers/authContext.ts
Normal file
|
|
@ -0,0 +1,27 @@
|
|||
// Auth context — SHOC shape (mirrors providers/auth-context.ts).
|
||||
// Context/provider split keeps this file component-free for fast refresh.
|
||||
import { createContext, useContext } from 'react';
|
||||
import type { AuthUser } from '@proposal-system/api-contracts';
|
||||
|
||||
export interface AuthContextValue {
|
||||
user: AuthUser | null;
|
||||
isAuthenticated: boolean;
|
||||
isLoading: boolean;
|
||||
error: string | null;
|
||||
/** Store a resolved session (Cognito code exchange or dev-login) and mark it active. */
|
||||
login: (user: AuthUser) => void;
|
||||
/** Clear the stored session. Navigation/toast live in useAuth. */
|
||||
logout: () => void;
|
||||
setError: (message: string | null) => void;
|
||||
setLoading: (loading: boolean) => void;
|
||||
}
|
||||
|
||||
export const AuthContext = createContext<AuthContextValue | null>(null);
|
||||
|
||||
export function useAuthContext(): AuthContextValue {
|
||||
const context = useContext(AuthContext);
|
||||
if (!context) {
|
||||
throw new Error('useAuthContext must be used within AuthProvider');
|
||||
}
|
||||
return context;
|
||||
}
|
||||
|
|
@ -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';
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue