From 924bfdd8f6a1c1a723e5fd073955b8317682a234 Mon Sep 17 00:00:00 2001 From: Adam Moussa Date: Mon, 13 Jul 2026 17:47:05 -0400 Subject: [PATCH 01/10] feat(web): domain-layer conventions doc + react-hook-form deps (Phase 4 prep) --- web/package-lock.json | 30 +++++++++++++++++++++++++ web/package.json | 2 ++ web/src/domain/README.md | 47 ++++++++++++++++++++++++++++++++++++++++ 3 files changed, 79 insertions(+) create mode 100644 web/src/domain/README.md diff --git a/web/package-lock.json b/web/package-lock.json index 5d716c3..686754c 100644 --- a/web/package-lock.json +++ b/web/package-lock.json @@ -13,6 +13,7 @@ "@fontsource/dm-sans": "^5.2.8", "@fontsource/jetbrains-mono": "^5.2.8", "@fontsource/montserrat": "^5.2.8", + "@hookform/resolvers": "^5.4.0", "@mui/icons-material": "^9.2.0", "@mui/material": "^9.1.1", "@proposal-system/api-contracts": "file:../shared/api-contracts", @@ -22,6 +23,7 @@ "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", @@ -625,6 +627,18 @@ "url": "https://github.com/sponsors/ayuhito" } }, + "node_modules/@hookform/resolvers": { + "version": "5.4.0", + "resolved": "https://registry.npmjs.org/@hookform/resolvers/-/resolvers-5.4.0.tgz", + "integrity": "sha512-EIsqr/t/qbinPIhGjMdtvutIN1Kk4uwbROE9/UQ93CAVGR7GkA7Y92+fX80OzXi/OB67jVFYwKGO1WzkxmkFZw==", + "license": "MIT", + "dependencies": { + "@standard-schema/utils": "^0.3.0" + }, + "peerDependencies": { + "react-hook-form": "^7.55.0" + } + }, "node_modules/@jridgewell/gen-mapping": { "version": "0.3.13", "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", @@ -3309,6 +3323,22 @@ "react": "^19.2.7" } }, + "node_modules/react-hook-form": { + "version": "7.81.0", + "resolved": "https://registry.npmjs.org/react-hook-form/-/react-hook-form-7.81.0.tgz", + "integrity": "sha512-ocbmr2p5KBMoAfj4WCUvped33lVi1Kd5DuDUvQDnB6VEAacOjPI/jMbtDdbhco4y9ct4xUuCmMY0b/C9L0QHjw==", + "license": "MIT", + "engines": { + "node": ">=18.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/react-hook-form" + }, + "peerDependencies": { + "react": "^16.8.0 || ^17 || ^18 || ^19" + } + }, "node_modules/react-is": { "version": "19.2.7", "resolved": "https://registry.npmjs.org/react-is/-/react-is-19.2.7.tgz", diff --git a/web/package.json b/web/package.json index 321cf87..8a4317c 100644 --- a/web/package.json +++ b/web/package.json @@ -16,6 +16,7 @@ "@fontsource/dm-sans": "^5.2.8", "@fontsource/jetbrains-mono": "^5.2.8", "@fontsource/montserrat": "^5.2.8", + "@hookform/resolvers": "^5.4.0", "@mui/icons-material": "^9.2.0", "@mui/material": "^9.1.1", "@proposal-system/api-contracts": "file:../shared/api-contracts", @@ -25,6 +26,7 @@ "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", diff --git a/web/src/domain/README.md b/web/src/domain/README.md new file mode 100644 index 0000000..79266db --- /dev/null +++ b/web/src/domain/README.md @@ -0,0 +1,47 @@ +# Frontend domain layer — conventions + +SHOC-alignment Phase 4 (mirrors shoc-frontend-new dev's `src/domain//` layering). +Every agent/contributor working in this tree builds to THIS spec. + +## Structure + +``` +src/domain// +├── api.ts # HTTP calls only: axios via lib/api/client, path constants, no hooks +├── types.ts # re-exports from @proposal-system/api-contracts + view-only types +├── schemas.ts # re-exports from @proposal-system/api-contracts/schemas + form schemas +└── use-cases.ts # TanStack Query hooks + this domain's query keys (the ONLY public surface) +``` + +Domains: `proposals`, `lineItems`, `customers`, `pricingLibrary`, `admin`, `sites`. +(`auth` arrives with the separate auth-storage PR — do NOT create it here.) + +## Rules + +1. **Pages import ONLY from `domain//use-cases` (and `types`)** — never from + `lib/api/*`, never `apiClient` directly, never `useQuery`/`useMutation` inline + in a page. Pages are thin callers. +2. **Query keys live in the domain**: each `use-cases.ts` exports + `export const Keys = { all: [''] as const, detail: (id: string) => ['', id] as const, ... }` + (TanStack hierarchical-key convention). `src/constants/queryKeys.ts` is + deleted at integration — do not add imports of it. +3. **Invalidation** uses the domain key objects across domains where needed + (e.g. approving a proposal invalidates `proposalsKeys.all`). +4. **Forms**: react-hook-form + `zodResolver` (from `@hookform/resolvers/zod`). + Form schemas live in the domain's `schemas.ts`, derived from the shared + contract schemas (`@proposal-system/api-contracts/schemas`) via + `.pick/.extend/.omit` — never hand-written duplicates. Form field state maps + to the request type at submit (a `toCreateRequest(formValues)` mapper in + `schemas.ts` when non-trivial). +5. **`lib/api/client.ts` stays** — the single axios instance (interceptors, + 401 handling). Domain `api.ts` files import it. The old `lib/api/.ts` + modules are deleted once no page imports them (integration step) — + `lib/api/auth.ts` stays until the auth PR. +6. **Mutations**: toast on error stays in the hook (matching current UX), + success invalidation in the hook; page-specific side effects (navigate, + dialog close) via the mutation's callbacks at the call site. +7. **No new state managers, no context** — server state = TanStack Query, + existing Redux auth/ui slices untouched (auth refactor is a separate PR). +8. **Styling**: tokens only (`var(--...)`, theme) — no hardcoded hexes. +9. Verify before returning/committing: `npx tsc --noEmit`, `npm test -- --run`, + `npm run build` — all green, no skipped tests, no `@ts-ignore`. From e6e2c60c2f726c3061a8ca90b6d866db885e9315 Mon Sep 17 00:00:00 2001 From: Adam Moussa Date: Mon, 13 Jul 2026 17:58:19 -0400 Subject: [PATCH 02/10] feat(web): scaffold domain module layer (proposals, lineItems, customers, pricingLibrary, admin, sites) Additive-only: pages still use lib/api/* and constants/queryKeys.ts until the page-migration agents run. Each domain ships api.ts (HTTP moved from lib/api), types.ts (contract re-exports + view types), schemas.ts (contract schema re-exports + form schemas with toRequest mappers), and use-cases.ts (TanStack Query v5 hooks + hierarchical query keys, mirroring current page invalidations and toast-on-error behavior). Adds an explicit vite/vitest alias for the @proposal-system/api-contracts/schemas subpath (package has no exports map) plus a schema/mapper smoke test suite. --- web/src/domain/__tests__/schemas.test.ts | 157 ++++++++++++++++++ web/src/domain/admin/api.ts | 62 +++++++ web/src/domain/admin/schemas.ts | 7 + web/src/domain/admin/types.ts | 19 +++ web/src/domain/admin/use-cases.ts | 179 +++++++++++++++++++++ web/src/domain/customers/api.ts | 40 +++++ web/src/domain/customers/schemas.ts | 48 ++++++ web/src/domain/customers/types.ts | 11 ++ web/src/domain/customers/use-cases.ts | 59 +++++++ web/src/domain/lineItems/api.ts | 24 +++ web/src/domain/lineItems/schemas.ts | 61 +++++++ web/src/domain/lineItems/types.ts | 9 ++ web/src/domain/lineItems/use-cases.ts | 64 ++++++++ web/src/domain/pricingLibrary/api.ts | 37 +++++ web/src/domain/pricingLibrary/schemas.ts | 50 ++++++ web/src/domain/pricingLibrary/types.ts | 11 ++ web/src/domain/pricingLibrary/use-cases.ts | 66 ++++++++ web/src/domain/proposals/api.ts | 91 +++++++++++ web/src/domain/proposals/schemas.ts | 68 ++++++++ web/src/domain/proposals/types.ts | 32 ++++ web/src/domain/proposals/use-cases.ts | 98 +++++++++++ web/src/domain/sites/api.ts | 10 ++ web/src/domain/sites/schemas.ts | 3 + web/src/domain/sites/types.ts | 2 + web/src/domain/sites/use-cases.ts | 18 +++ web/vite.config.ts | 9 ++ web/vitest.config.ts | 11 ++ 27 files changed, 1246 insertions(+) create mode 100644 web/src/domain/__tests__/schemas.test.ts create mode 100644 web/src/domain/admin/api.ts create mode 100644 web/src/domain/admin/schemas.ts create mode 100644 web/src/domain/admin/types.ts create mode 100644 web/src/domain/admin/use-cases.ts create mode 100644 web/src/domain/customers/api.ts create mode 100644 web/src/domain/customers/schemas.ts create mode 100644 web/src/domain/customers/types.ts create mode 100644 web/src/domain/customers/use-cases.ts create mode 100644 web/src/domain/lineItems/api.ts create mode 100644 web/src/domain/lineItems/schemas.ts create mode 100644 web/src/domain/lineItems/types.ts create mode 100644 web/src/domain/lineItems/use-cases.ts create mode 100644 web/src/domain/pricingLibrary/api.ts create mode 100644 web/src/domain/pricingLibrary/schemas.ts create mode 100644 web/src/domain/pricingLibrary/types.ts create mode 100644 web/src/domain/pricingLibrary/use-cases.ts create mode 100644 web/src/domain/proposals/api.ts create mode 100644 web/src/domain/proposals/schemas.ts create mode 100644 web/src/domain/proposals/types.ts create mode 100644 web/src/domain/proposals/use-cases.ts create mode 100644 web/src/domain/sites/api.ts create mode 100644 web/src/domain/sites/schemas.ts create mode 100644 web/src/domain/sites/types.ts create mode 100644 web/src/domain/sites/use-cases.ts diff --git a/web/src/domain/__tests__/schemas.test.ts b/web/src/domain/__tests__/schemas.test.ts new file mode 100644 index 0000000..ff344ba --- /dev/null +++ b/web/src/domain/__tests__/schemas.test.ts @@ -0,0 +1,157 @@ +// Domain-layer form schemas: derived from the shared contract schemas, with +// string-typed numeric inputs and toXxxRequest() mappers to the wire types. +// Also serves as the resolution smoke test for the +// '@proposal-system/api-contracts/schemas' subpath entrypoint. +import { describe, it, expect } from 'vitest'; +import { + createProposalFormSchema, + toCreateProposalRequest, + MIN_SCOPE_LENGTH, +} from '../proposals/schemas'; +import { customerFormSchema, toCustomerRequest } from '../customers/schemas'; +import { + pricingLibraryItemFormSchema, + toPricingLibraryItemRequest, +} from '../pricingLibrary/schemas'; +import { lineItemRowFormSchema, toUpdateLineItemEntry } from '../lineItems/schemas'; + +describe('createProposalFormSchema', () => { + const valid = { + workOrderNumber: 'WO-1001', + poNumber: '', + customerName: 'Acme Corp', + customerAddress: '1 Main St, Islip, NY 11751', + scopeOfWork: 'Replace rooftop HVAC unit and rebalance airflow', + serviceCategory: 'HVAC' as const, + priority: 'Standard' as const, + notes: '', + customCategory: '', + }; + + it('accepts a complete form', () => { + expect(createProposalFormSchema.safeParse(valid).success).toBe(true); + }); + + it('rejects scope of work shorter than the minimum', () => { + const result = createProposalFormSchema.safeParse({ + ...valid, + scopeOfWork: 'x'.repeat(MIN_SCOPE_LENGTH - 1), + }); + expect(result.success).toBe(false); + }); + + it('requires customCategory when serviceCategory is Other', () => { + expect( + createProposalFormSchema.safeParse({ ...valid, serviceCategory: 'Other' }).success + ).toBe(false); + expect( + createProposalFormSchema.safeParse({ + ...valid, + serviceCategory: 'Other', + customCategory: 'Roofing', + }).success + ).toBe(true); + }); + + it('maps to the wire request, prefixing notes with the custom category', () => { + const request = toCreateProposalRequest({ + ...valid, + serviceCategory: 'Other', + customCategory: 'Roofing', + notes: 'existing note', + }); + expect(request.notes).toBe('[Category: Roofing]\nexisting note'); + expect(request.poNumber).toBeUndefined(); + expect(toCreateProposalRequest(valid).notes).toBeUndefined(); + }); +}); + +describe('customerFormSchema', () => { + const valid = { name: 'Acme Corp', address: '1 Main St', contactEmail: '' }; + + it('accepts a form with empty email', () => { + expect(customerFormSchema.safeParse(valid).success).toBe(true); + }); + + 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); + }); + + it('maps to addresses[] preserving extra existing addresses', () => { + const request = toCustomerRequest( + { name: ' Acme ', address: ' 2 New St ', contactEmail: '' }, + ['1 Old St', '3 Extra Ave'] + ); + expect(request).toEqual({ name: 'Acme', addresses: ['2 New St', '3 Extra Ave'] }); + }); +}); + +describe('pricingLibraryItemFormSchema', () => { + const valid = { + title: 'Condenser coil cleaning', + serviceCategory: 'HVAC' as const, + unit: '', + unitPrice: '', + keywords: '', + description: '', + }; + + 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); + }); + + 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 }); + const priced = toPricingLibraryItemRequest({ ...valid, unitPrice: '12.50', unit: 'each' }); + expect(priced.unitPrice).toBe(12.5); + expect(priced.unit).toBe('each'); + }); +}); + +describe('lineItemRowFormSchema', () => { + const valid = { + description: 'Install thermostat', + unit: 'each', + pricingMode: 'UnitPrice' as const, + quantity: '2', + unitPrice: '150', + totalPrice: '300', + }; + + it('accepts numeric strings and an empty unitPrice', () => { + expect(lineItemRowFormSchema.safeParse(valid).success).toBe(true); + expect(lineItemRowFormSchema.safeParse({ ...valid, unitPrice: '' }).success).toBe(true); + expect(lineItemRowFormSchema.safeParse({ ...valid, quantity: '' }).success).toBe(false); + expect(lineItemRowFormSchema.safeParse({ ...valid, totalPrice: '-1' }).success).toBe(false); + }); + + it('maps to the wire entry with editor-managed identity', () => { + const entry = toUpdateLineItemEntry(valid, { id: null, sortOrder: 3, source: 'Manual' }); + expect(entry).toEqual({ + id: null, + description: 'Install thermostat', + quantity: 2, + unit: 'each', + unitPrice: 150, + totalPrice: 300, + pricingMode: 'UnitPrice', + sortOrder: 3, + source: 'Manual', + }); + const noUnitPrice = toUpdateLineItemEntry( + { ...valid, unitPrice: '' }, + { id: 'li-1', sortOrder: 0, source: 'AI' } + ); + expect(noUnitPrice.unitPrice).toBeNull(); + }); +}); diff --git a/web/src/domain/admin/api.ts b/web/src/domain/admin/api.ts new file mode 100644 index 0000000..472f339 --- /dev/null +++ b/web/src/domain/admin/api.ts @@ -0,0 +1,62 @@ +// Admin domain — HTTP calls only (no hooks). Moved from lib/api/admin.ts. +import apiClient from '../../lib/api/client'; +import type { ProposalDetail } from '../proposals/types'; +import type { DashboardStats, UpdateProposalRequest, AuditEntry, SimilarProposal } from './types'; + +export const adminApi = { + getDashboard: async (): Promise => { + const res = await apiClient.get('/admin/dashboard'); + return res.data; + }, + + updateProposal: async (id: string, data: UpdateProposalRequest): Promise => { + await apiClient.put(`/proposals/${id}`, data); + }, + + approveProposal: async (id: string): Promise => { + await apiClient.post(`/proposals/${id}/approve`); + }, + + returnToReview: async (id: string): Promise => { + await apiClient.post(`/proposals/${id}/return-to-review`); + }, + + sendProposal: async (id: string): Promise => { + await apiClient.post(`/proposals/${id}/send`); + }, + + reviseProposal: async (id: string): Promise => { + await apiClient.post(`/proposals/${id}/revise`); + }, + + getHistory: async (id: string): Promise => { + const res = await apiClient.get(`/proposals/${id}/history`); + return res.data; + }, + + getAudit: async (id: string): Promise => { + const res = await apiClient.get(`/proposals/${id}/audit`); + return res.data; + }, + + generateSuggestions: async (id: string): Promise => { + await apiClient.post(`/proposals/${id}/generate-suggestions`); + }, + + getSimilar: async (id: string): Promise => { + const res = await apiClient.get(`/proposals/${id}/similar`); + return res.data; + }, + + getPdf: async (id: string, regenerate = false): Promise<{ downloadUrl: string } | null> => { + const params = regenerate ? '?regenerate=true' : ''; + const res = await apiClient.get(`/proposals/${id}/pdf${params}`, { + responseType: 'blob', + validateStatus: (status) => status < 500, + }); + if (res.status === 202) return null; + const blob = res.data as Blob; + const downloadUrl = URL.createObjectURL(blob); + return { downloadUrl }; + }, +}; diff --git a/web/src/domain/admin/schemas.ts b/web/src/domain/admin/schemas.ts new file mode 100644 index 0000000..e789fa1 --- /dev/null +++ b/web/src/domain/admin/schemas.ts @@ -0,0 +1,7 @@ +// Admin domain — contract schema re-exports. No admin-specific forms yet +// (the workspace edits proposals/line items via their own domain schemas). +export { + dashboardStatsSchema, + updateProposalRequestSchema, + auditEntrySchema, +} from '@proposal-system/api-contracts/schemas'; diff --git a/web/src/domain/admin/types.ts b/web/src/domain/admin/types.ts new file mode 100644 index 0000000..5fa57ae --- /dev/null +++ b/web/src/domain/admin/types.ts @@ -0,0 +1,19 @@ +// Admin domain — contract type re-exports + view-only types. +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). */ +export interface SimilarProposal { + proposalNumber: string; + customerName: string; + serviceCategory: string; + similarityScore: number; + totalBidAmount: number; + lineItems: Array<{ + description: string; + quantity: number; + unit: string; + unitPrice: number | null; + totalPrice: number; + }>; +} diff --git a/web/src/domain/admin/use-cases.ts b/web/src/domain/admin/use-cases.ts new file mode 100644 index 0000000..dadadb6 --- /dev/null +++ b/web/src/domain/admin/use-cases.ts @@ -0,0 +1,179 @@ +// Admin domain — TanStack Query hooks + query keys (the only public surface). +// State-transition mutations invalidate the proposals/lineItems domain keys +// (rule 3 — cross-domain invalidation via the sibling key objects) and carry +// the same toasts the AdminWorkspace page shows today. Page-specific side +// effects (dialog close, dirty reset, window.open, navigate) stay at the +// call site via the mutation's callbacks. +import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; +import { toast } from 'react-toastify'; +import { adminApi } from './api'; +import { lineItemsApi } from '../lineItems/api'; +import { proposalsKeys } from '../proposals/use-cases'; +import { lineItemsKeys } from '../lineItems/use-cases'; +import type { UpdateProposalRequest } from './types'; +import type { UpdateLineItemEntry } from '../lineItems/types'; + +export const adminKeys = { + all: ['admin'] as const, + dashboard: () => [...adminKeys.all, 'dashboard'] as const, + similar: (proposalId: string) => [...adminKeys.all, 'similar', proposalId] as const, + history: (proposalId: string) => [...adminKeys.all, 'history', proposalId] as const, + audit: (proposalId: string) => [...adminKeys.all, 'audit', proposalId] as const, +}; + +/** Admin dashboard KPIs (AdminDashboard). */ +export function useAdminDashboard() { + return useQuery({ + queryKey: adminKeys.dashboard(), + queryFn: adminApi.getDashboard, + }); +} + +/** RAG similarity results (AdminWorkspace, SimilarProposalsPanel). */ +export function useSimilarProposals(proposalId: string | undefined) { + return useQuery({ + queryKey: adminKeys.similar(proposalId ?? ''), + queryFn: () => adminApi.getSimilar(proposalId!), + enabled: !!proposalId, + }); +} + +export function useProposalHistory(proposalId: string | undefined) { + return useQuery({ + queryKey: adminKeys.history(proposalId ?? ''), + queryFn: () => adminApi.getHistory(proposalId!), + enabled: !!proposalId, + }); +} + +export function useProposalAudit(proposalId: string | undefined) { + return useQuery({ + queryKey: adminKeys.audit(proposalId ?? ''), + queryFn: () => adminApi.getAudit(proposalId!), + enabled: !!proposalId, + }); +} + +export interface SaveWorkspaceVariables { + refinedScope: string; + lineItems: UpdateLineItemEntry[]; +} + +/** + * The workspace "Save" action: persist the refined scope, then replace the + * proposal's line items (mirrors AdminWorkspace saveMutation exactly). + */ +export function useSaveProposalWorkspace(proposalId: string) { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: async ({ refinedScope, lineItems }: SaveWorkspaceVariables) => { + await adminApi.updateProposal(proposalId, { refinedScope }); + await lineItemsApi.bulkUpdate(proposalId, lineItems); + }, + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: lineItemsKeys.byProposal(proposalId) }); + queryClient.invalidateQueries({ queryKey: proposalsKeys.detail(proposalId) }); + toast.success('Changes saved'); + }, + onError: (error: Error) => { + toast.error(`Save failed: ${error.message}`); + }, + }); +} + +export function useUpdateProposal(proposalId: string) { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: (data: UpdateProposalRequest) => adminApi.updateProposal(proposalId, data), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: proposalsKeys.detail(proposalId) }); + }, + onError: (error: Error) => { + toast.error(`Save failed: ${error.message}`); + }, + }); +} + +export function useApproveProposal(proposalId: string) { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: () => adminApi.approveProposal(proposalId), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: proposalsKeys.detail(proposalId) }); + queryClient.invalidateQueries({ queryKey: lineItemsKeys.byProposal(proposalId) }); + toast.success('Proposal approved'); + }, + onError: (error: Error) => { + toast.error(`Approval failed: ${error.message}`); + }, + }); +} + +export function useSendProposal(proposalId: string) { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: () => adminApi.sendProposal(proposalId), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: proposalsKeys.detail(proposalId) }); + toast.success('Proposal marked as sent'); + }, + onError: (error: Error) => { + toast.error(`Send failed: ${error.message}`); + }, + }); +} + +export function useReviseProposal(proposalId: string) { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: () => adminApi.reviseProposal(proposalId), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: proposalsKeys.detail(proposalId) }); + toast.success('Revision created'); + }, + onError: (error: Error) => { + toast.error(`Revision failed: ${error.message}`); + }, + }); +} + +export function useReturnToReview(proposalId: string) { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: () => adminApi.returnToReview(proposalId), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: proposalsKeys.detail(proposalId) }); + queryClient.invalidateQueries({ queryKey: lineItemsKeys.byProposal(proposalId) }); + toast.success('Proposal returned to review'); + }, + onError: (error: Error) => { + toast.error(`Return to review failed: ${error.message}`); + }, + }); +} + +export function useGenerateSuggestions(proposalId: string) { + return useMutation({ + mutationFn: () => adminApi.generateSuggestions(proposalId), + onSuccess: () => { + toast.info('AI suggestion generation started'); + }, + onError: (error: Error) => { + toast.error(`Regeneration failed: ${error.message}`); + }, + }); +} + +/** + * Fetch (or queue generation of) the branded PDF. Resolves null while + * generation is queued (202). Success handling — open the download and + * toast — is page behavior and belongs at the call site. + */ +export function useProposalPdf(proposalId: string) { + return useMutation({ + mutationFn: (regenerate?: boolean) => adminApi.getPdf(proposalId, regenerate ?? false), + onError: () => { + toast.error('Failed to generate PDF'); + }, + }); +} diff --git a/web/src/domain/customers/api.ts b/web/src/domain/customers/api.ts new file mode 100644 index 0000000..695872b --- /dev/null +++ b/web/src/domain/customers/api.ts @@ -0,0 +1,40 @@ +// Customers domain — HTTP calls only (no hooks). Moved from lib/api/customers.ts. +import apiClient from '../../lib/api/client'; +import type { + Customer, + CustomerListParams, + CreateCustomerRequest, + UpdateCustomerRequest, + PagedCustomerResponse, +} from './types'; + +export const customersApi = { + /** GET /api/customers?query= — search autocomplete (unchanged). */ + search: async (query?: string): Promise => { + const params = query ? `?query=${encodeURIComponent(query)}` : ''; + const res = await apiClient.get(`/customers${params}`); + return res.data; + }, + + /** GET /api/customers/list?page=&pageSize= — paginated management list (admin/sysadmin). */ + list: async (params: CustomerListParams = {}): Promise => { + const qs = new URLSearchParams(); + if (params.page) qs.append('page', String(params.page)); + if (params.pageSize) qs.append('pageSize', String(params.pageSize)); + if (params.search) qs.append('search', params.search); + const res = await apiClient.get(`/customers/list?${qs.toString()}`); + return res.data; + }, + + /** POST /api/customers — create a new customer. */ + create: async (data: CreateCustomerRequest): Promise => { + const res = await apiClient.post('/customers', data); + return res.data; + }, + + /** PUT /api/customers/{id} — update an existing customer (admin/sysadmin). */ + update: async (id: string, data: UpdateCustomerRequest): Promise => { + const res = await apiClient.put(`/customers/${id}`, data); + return res.data; + }, +}; diff --git a/web/src/domain/customers/schemas.ts b/web/src/domain/customers/schemas.ts new file mode 100644 index 0000000..fe5f233 --- /dev/null +++ b/web/src/domain/customers/schemas.ts @@ -0,0 +1,48 @@ +// Customers domain — contract schema re-exports + customer form schema. +import { z } from 'zod'; +import { createCustomerRequestSchema } from '@proposal-system/api-contracts/schemas'; +import type { CreateCustomerRequest, UpdateCustomerRequest } from './types'; + +export { + customerSchema, + createCustomerRequestSchema, + updateCustomerRequestSchema, +} from '@proposal-system/api-contracts/schemas'; + +/** Simple email format check (client-side only) — mirrors CustomerManagementPage. */ +const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; + +/** + * Customer create/edit form. Derived from createCustomerRequestSchema — the + * 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 type CustomerFormValues = z.infer; + +/** + * Map validated form values to the create/update request. The form edits the + * primary address; any additional existing addresses are preserved + * (mirrors the current CustomerManagementPage submit mapping). + */ +export function toCustomerRequest( + values: CustomerFormValues, + existingAddresses: string[] = [] +): CreateCustomerRequest & UpdateCustomerRequest { + return { + name: values.name.trim(), + addresses: [values.address.trim(), ...existingAddresses.slice(1)], + ...(values.contactEmail.trim() ? { contactEmail: values.contactEmail.trim() } : {}), + }; +} diff --git a/web/src/domain/customers/types.ts b/web/src/domain/customers/types.ts new file mode 100644 index 0000000..2deb9a0 --- /dev/null +++ b/web/src/domain/customers/types.ts @@ -0,0 +1,11 @@ +// Customers domain — contract type re-exports + view-only types. +import type { Customer, PagedResponse } from '@proposal-system/api-contracts'; + +export type { + Customer, + CustomerListParams, + CreateCustomerRequest, + UpdateCustomerRequest, +} from '@proposal-system/api-contracts'; + +export type PagedCustomerResponse = PagedResponse; diff --git a/web/src/domain/customers/use-cases.ts b/web/src/domain/customers/use-cases.ts new file mode 100644 index 0000000..ab37d6b --- /dev/null +++ b/web/src/domain/customers/use-cases.ts @@ -0,0 +1,59 @@ +// Customers domain — TanStack Query hooks + query keys (the only public surface). +import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; +import { toast } from 'react-toastify'; +import { customersApi } from './api'; +import type { CreateCustomerRequest, CustomerListParams, UpdateCustomerRequest } from './types'; + +export const customersKeys = { + all: ['customers'] as const, + lists: () => [...customersKeys.all, 'list'] as const, + list: (params: CustomerListParams) => [...customersKeys.lists(), params] as const, + search: (query: string) => [...customersKeys.all, 'search', query] as const, +}; + +/** Name autocomplete (ProposalFormPage). Call with the debounced input; + * disabled below 2 characters, matching current page behavior. */ +export function useCustomerSearch(query: string) { + return useQuery({ + queryKey: customersKeys.search(query), + queryFn: () => customersApi.search(query), + enabled: query.trim().length >= 2, + }); +} + +/** Paginated management list (CustomerManagementPage). */ +export function useCustomerList(params: CustomerListParams = {}) { + return useQuery({ + queryKey: customersKeys.list(params), + queryFn: () => customersApi.list(params), + }); +} + +export function useCreateCustomer() { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: (data: CreateCustomerRequest) => customersApi.create(data), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: customersKeys.lists() }); + toast.success('Customer created'); + }, + onError: (error: Error) => { + toast.error(`Failed to create customer: ${error.message}`); + }, + }); +} + +export function useUpdateCustomer() { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: ({ id, data }: { id: string; data: UpdateCustomerRequest }) => + customersApi.update(id, data), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: customersKeys.lists() }); + toast.success('Customer updated'); + }, + onError: (error: Error) => { + toast.error(`Failed to update customer: ${error.message}`); + }, + }); +} diff --git a/web/src/domain/lineItems/api.ts b/web/src/domain/lineItems/api.ts new file mode 100644 index 0000000..6d18f1d --- /dev/null +++ b/web/src/domain/lineItems/api.ts @@ -0,0 +1,24 @@ +// Line items domain — HTTP calls only (no hooks). Moved from lib/api/lineItems.ts. +import apiClient from '../../lib/api/client'; +import type { LineItem, CreateLineItemRequest, UpdateLineItemEntry } from './types'; + +export const lineItemsApi = { + getAll: async (proposalId: string): Promise => { + const res = await apiClient.get(`/proposals/${proposalId}/line-items`); + return res.data; + }, + + create: async (proposalId: string, data: CreateLineItemRequest): Promise => { + const res = await apiClient.post(`/proposals/${proposalId}/line-items`, data); + return res.data; + }, + + bulkUpdate: async (proposalId: string, lineItems: UpdateLineItemEntry[]): Promise => { + const res = await apiClient.put(`/proposals/${proposalId}/line-items`, { lineItems }); + return res.data; + }, + + delete: async (proposalId: string, itemId: string): Promise => { + await apiClient.delete(`/proposals/${proposalId}/line-items/${itemId}`); + }, +}; diff --git a/web/src/domain/lineItems/schemas.ts b/web/src/domain/lineItems/schemas.ts new file mode 100644 index 0000000..f573810 --- /dev/null +++ b/web/src/domain/lineItems/schemas.ts @@ -0,0 +1,61 @@ +// Line items domain — contract schema re-exports + line-item row form schema. +import { z } from 'zod'; +import { updateLineItemEntrySchema } from '@proposal-system/api-contracts/schemas'; +import type { UpdateLineItemEntry } from './types'; + +export { + pricingModeSchema, + lineItemSourceSchema, + lineItemSchema, + createLineItemRequestSchema, + updateLineItemEntrySchema, + bulkUpdateLineItemsRequestSchema, +} from '@proposal-system/api-contracts/schemas'; + +/** Required numeric text input (>= 0). */ +const numericString = z + .string() + .refine((v) => v.trim() !== '' && !Number.isNaN(Number(v)) && Number(v) >= 0, { + message: 'Must be a number >= 0', + }); + +/** Optional numeric text input — empty maps to null on the wire. */ +const optionalNumericString = z + .string() + .refine((v) => v.trim() === '' || (!Number.isNaN(Number(v)) && Number(v) >= 0), { + message: 'Must be a number >= 0', + }); + +/** + * A single editable line-item row. Derived from updateLineItemEntrySchema — + * numeric inputs are strings in form state; toUpdateLineItemEntry() converts + * to the wire type. Row identity (id/sortOrder/source) is editor-managed + * state, not user input, so it is supplied to the mapper instead. + */ +export const lineItemRowFormSchema = updateLineItemEntrySchema + .pick({ description: true, unit: true, pricingMode: true }) + .extend({ + quantity: numericString, + unitPrice: optionalNumericString, + totalPrice: numericString, + }); + +export type LineItemRowFormValues = z.infer; + +/** Map a validated row to the bulk-update wire entry. */ +export function toUpdateLineItemEntry( + values: LineItemRowFormValues, + meta: { id: string | null; sortOrder: number; source: UpdateLineItemEntry['source'] } +): UpdateLineItemEntry { + return { + id: meta.id, + description: values.description, + quantity: Number(values.quantity), + unit: values.unit, + unitPrice: values.unitPrice.trim() !== '' ? Number(values.unitPrice) : null, + totalPrice: Number(values.totalPrice), + pricingMode: values.pricingMode, + sortOrder: meta.sortOrder, + source: meta.source, + }; +} diff --git a/web/src/domain/lineItems/types.ts b/web/src/domain/lineItems/types.ts new file mode 100644 index 0000000..202c3b5 --- /dev/null +++ b/web/src/domain/lineItems/types.ts @@ -0,0 +1,9 @@ +// Line items domain — contract type re-exports. +export type { + PricingMode, + LineItemSource, + LineItem, + CreateLineItemRequest, + UpdateLineItemEntry, + BulkUpdateLineItemsRequest, +} from '@proposal-system/api-contracts'; diff --git a/web/src/domain/lineItems/use-cases.ts b/web/src/domain/lineItems/use-cases.ts new file mode 100644 index 0000000..01c02a6 --- /dev/null +++ b/web/src/domain/lineItems/use-cases.ts @@ -0,0 +1,64 @@ +// Line items domain — TanStack Query hooks + query keys (the only public surface). +import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; +import { toast } from 'react-toastify'; +import { lineItemsApi } from './api'; +import { proposalsKeys } from '../proposals/use-cases'; +import type { CreateLineItemRequest, UpdateLineItemEntry } from './types'; + +export const lineItemsKeys = { + all: ['lineItems'] as const, + byProposal: (proposalId: string) => [...lineItemsKeys.all, proposalId] as const, +}; + +/** Line items for a proposal (AdminWorkspace editor). */ +export function useLineItems(proposalId: string | undefined) { + return useQuery({ + queryKey: lineItemsKeys.byProposal(proposalId ?? ''), + queryFn: () => lineItemsApi.getAll(proposalId!), + enabled: !!proposalId, + }); +} + +export function useCreateLineItem(proposalId: string) { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: (data: CreateLineItemRequest) => lineItemsApi.create(proposalId, data), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: lineItemsKeys.byProposal(proposalId) }); + queryClient.invalidateQueries({ queryKey: proposalsKeys.detail(proposalId) }); + }, + onError: (error: Error) => { + toast.error(`Failed to add line item: ${error.message}`); + }, + }); +} + +/** Replace-all update of a proposal's line items. Totals change, so the + * proposal detail is invalidated alongside the line items. */ +export function useBulkUpdateLineItems(proposalId: string) { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: (lineItems: UpdateLineItemEntry[]) => lineItemsApi.bulkUpdate(proposalId, lineItems), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: lineItemsKeys.byProposal(proposalId) }); + queryClient.invalidateQueries({ queryKey: proposalsKeys.detail(proposalId) }); + }, + onError: (error: Error) => { + toast.error(`Save failed: ${error.message}`); + }, + }); +} + +export function useDeleteLineItem(proposalId: string) { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: (itemId: string) => lineItemsApi.delete(proposalId, itemId), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: lineItemsKeys.byProposal(proposalId) }); + queryClient.invalidateQueries({ queryKey: proposalsKeys.detail(proposalId) }); + }, + onError: (error: Error) => { + toast.error(`Failed to delete line item: ${error.message}`); + }, + }); +} diff --git a/web/src/domain/pricingLibrary/api.ts b/web/src/domain/pricingLibrary/api.ts new file mode 100644 index 0000000..c9f7974 --- /dev/null +++ b/web/src/domain/pricingLibrary/api.ts @@ -0,0 +1,37 @@ +// Pricing library domain — HTTP calls only (no hooks). Moved from lib/api/pricingLibrary.ts. +import apiClient from '../../lib/api/client'; +import type { + PricingLibraryItem, + PricingLibraryListParams, + CreatePricingLibraryItemRequest, + UpdatePricingLibraryItemRequest, + PagedPricingLibraryResponse, +} from './types'; + +export const pricingLibraryApi = { + /** GET /api/pricing-library/list?page=&pageSize= — paginated list (admin/sysadmin). */ + list: async (params: PricingLibraryListParams = {}): Promise => { + const qs = new URLSearchParams(); + if (params.page) qs.append('page', String(params.page)); + if (params.pageSize) qs.append('pageSize', String(params.pageSize)); + const res = await apiClient.get(`/pricing-library/list?${qs.toString()}`); + return res.data; + }, + + /** POST /api/pricing-library — create a new pricing library item. */ + create: async (data: CreatePricingLibraryItemRequest): Promise => { + const res = await apiClient.post('/pricing-library', data); + return res.data; + }, + + /** PUT /api/pricing-library/{id} — update an existing pricing library item. */ + update: async (id: string, data: UpdatePricingLibraryItemRequest): Promise => { + const res = await apiClient.put(`/pricing-library/${id}`, data); + return res.data; + }, + + /** DELETE /api/pricing-library/{id} — delete a pricing library item. */ + remove: async (id: string): Promise => { + await apiClient.delete(`/pricing-library/${id}`); + }, +}; diff --git a/web/src/domain/pricingLibrary/schemas.ts b/web/src/domain/pricingLibrary/schemas.ts new file mode 100644 index 0000000..434ce75 --- /dev/null +++ b/web/src/domain/pricingLibrary/schemas.ts @@ -0,0 +1,50 @@ +// Pricing library domain — contract schema re-exports + item form schema. +import { z } from 'zod'; +import { createPricingLibraryItemRequestSchema } from '@proposal-system/api-contracts/schemas'; +import type { CreatePricingLibraryItemRequest, UpdatePricingLibraryItemRequest } from './types'; + +export { + pricingLibraryItemSchema, + createPricingLibraryItemRequestSchema, + updatePricingLibraryItemRequestSchema, +} from '@proposal-system/api-contracts/schemas'; + +/** + * Pricing-library item create/edit form. Derived from + * createPricingLibraryItemRequestSchema — unitPrice is a string in form state + * (numeric text input, empty allowed); toPricingLibraryItemRequest() converts + * to the wire type. Validation mirrors the current PricingLibraryPage rules. + */ +export const pricingLibraryItemFormSchema = createPricingLibraryItemRequestSchema + .pick({ title: true, serviceCategory: true }) + .extend({ + title: z.string().trim().min(1, 'Title is required'), + unit: z.string(), + unitPrice: z + .string() + .refine((v) => v.trim() === '' || (!Number.isNaN(parseFloat(v)) && parseFloat(v) >= 0), { + message: 'Must be a number >= 0', + }), + keywords: z.string(), + description: z.string(), + }); + +export type PricingLibraryItemFormValues = z.infer; + +/** + * Map validated form values to the create/update request (mirrors the current + * PricingLibraryPage submit mapping: optional strings dropped when empty, + * empty unitPrice sent as null). + */ +export function toPricingLibraryItemRequest( + values: PricingLibraryItemFormValues +): CreatePricingLibraryItemRequest & UpdatePricingLibraryItemRequest { + return { + title: values.title.trim(), + serviceCategory: values.serviceCategory, + ...(values.unit.trim() ? { unit: values.unit.trim() } : {}), + unitPrice: values.unitPrice.trim() !== '' ? parseFloat(values.unitPrice) : null, + ...(values.keywords.trim() ? { keywords: values.keywords.trim() } : {}), + ...(values.description.trim() ? { description: values.description.trim() } : {}), + }; +} diff --git a/web/src/domain/pricingLibrary/types.ts b/web/src/domain/pricingLibrary/types.ts new file mode 100644 index 0000000..2fc8209 --- /dev/null +++ b/web/src/domain/pricingLibrary/types.ts @@ -0,0 +1,11 @@ +// Pricing library domain — contract type re-exports + view-only types. +import type { PagedResponse, PricingLibraryItem } from '@proposal-system/api-contracts'; + +export type { + PricingLibraryItem, + PricingLibraryListParams, + CreatePricingLibraryItemRequest, + UpdatePricingLibraryItemRequest, +} from '@proposal-system/api-contracts'; + +export type PagedPricingLibraryResponse = PagedResponse; diff --git a/web/src/domain/pricingLibrary/use-cases.ts b/web/src/domain/pricingLibrary/use-cases.ts new file mode 100644 index 0000000..e237877 --- /dev/null +++ b/web/src/domain/pricingLibrary/use-cases.ts @@ -0,0 +1,66 @@ +// Pricing library domain — TanStack Query hooks + query keys (the only public surface). +import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; +import { toast } from 'react-toastify'; +import { pricingLibraryApi } from './api'; +import type { + CreatePricingLibraryItemRequest, + PricingLibraryListParams, + UpdatePricingLibraryItemRequest, +} from './types'; + +export const pricingLibraryKeys = { + all: ['pricingLibrary'] as const, + lists: () => [...pricingLibraryKeys.all, 'list'] as const, + list: (params: PricingLibraryListParams) => [...pricingLibraryKeys.lists(), params] as const, +}; + +/** Paginated pricing library list (PricingLibraryPage). */ +export function usePricingLibraryList(params: PricingLibraryListParams = {}) { + return useQuery({ + queryKey: pricingLibraryKeys.list(params), + queryFn: () => pricingLibraryApi.list(params), + }); +} + +export function useCreatePricingLibraryItem() { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: (data: CreatePricingLibraryItemRequest) => pricingLibraryApi.create(data), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: pricingLibraryKeys.lists() }); + toast.success('Pricing item created'); + }, + onError: (error: Error) => { + toast.error(`Failed to create item: ${error.message}`); + }, + }); +} + +export function useUpdatePricingLibraryItem() { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: ({ id, data }: { id: string; data: UpdatePricingLibraryItemRequest }) => + pricingLibraryApi.update(id, data), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: pricingLibraryKeys.lists() }); + toast.success('Pricing item updated'); + }, + onError: (error: Error) => { + toast.error(`Failed to update item: ${error.message}`); + }, + }); +} + +export function useDeletePricingLibraryItem() { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: (id: string) => pricingLibraryApi.remove(id), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: pricingLibraryKeys.lists() }); + toast.success('Pricing item deleted'); + }, + onError: (error: Error) => { + toast.error(`Failed to delete item: ${error.message}`); + }, + }); +} diff --git a/web/src/domain/proposals/api.ts b/web/src/domain/proposals/api.ts new file mode 100644 index 0000000..61567cf --- /dev/null +++ b/web/src/domain/proposals/api.ts @@ -0,0 +1,91 @@ +// Proposals domain — HTTP calls only (no hooks). Moved from lib/api/proposals.ts. +import apiClient from '../../lib/api/client'; +import type { + CreateProposalRequest, + ProposalDetail, + ProposalListItem, + ProposalFilters, + ProposalStats, + PagedResponse, + PdfVersion, + PresignedUpload, + VendorProposal, +} from './types'; + +export const proposalsApi = { + create: async (data: CreateProposalRequest): Promise => { + const res = await apiClient.post('/proposals', data); + return res.data; + }, + + getAll: async (filters: ProposalFilters = {}): Promise> => { + const params = new URLSearchParams(); + if (filters.search) params.append('search', filters.search); + if (filters.page) params.append('page', String(filters.page)); + if (filters.pageSize) params.append('pageSize', String(filters.pageSize)); + if (filters.status) params.append('status', filters.status); + if (filters.serviceCategory) params.append('serviceCategory', filters.serviceCategory); + if (filters.priority) params.append('priority', filters.priority); + if (filters.fromDate) params.append('fromDate', filters.fromDate); + if (filters.toDate) params.append('toDate', filters.toDate); + if (filters.mine) params.append('mine', 'true'); + + const res = await apiClient.get(`/proposals?${params.toString()}`); + return res.data; + }, + + getById: async (id: string): Promise => { + const res = await apiClient.get(`/proposals/${id}`); + return res.data; + }, + + uploadAttachment: async (proposalId: string, fileName: string, vendorName?: string): Promise => { + const params = new URLSearchParams({ fileName }); + if (vendorName) params.append('vendorName', vendorName); + const res = await apiClient.post(`/proposals/${proposalId}/attachments?${params.toString()}`); + return res.data; + }, + + confirmUpload: async (proposalId: string, vendorProposalId: string): Promise => { + await apiClient.post(`/proposals/${proposalId}/attachments/${vendorProposalId}/confirm`); + }, + + getStats: async (): Promise => { + const res = await apiClient.get('/proposals/stats'); + return res.data; + }, + + /** GET /api/proposals/{id}/vendors — vendor proposals attached to a proposal + * (previously an inline apiClient call in VendorDataPanel). */ + getVendors: async (proposalId: string): Promise => { + const res = await apiClient.get(`/proposals/${proposalId}/vendors`); + return res.data; + }, + + getPdf: async (id: string): Promise<{ downloadUrl: string } | null> => { + const res = await apiClient.get(`/proposals/${id}/pdf`, { + responseType: 'blob', + validateStatus: (status) => status < 500, + }); + if (res.status === 404) return null; + const blob = res.data as Blob; + const downloadUrl = URL.createObjectURL(blob); + return { downloadUrl }; + }, + + getPdfVersions: async (id: string): Promise => { + const res = await apiClient.get(`/proposals/${id}/pdf/versions`); + return res.data; + }, + + getPdfRevision: async (id: string, revision: number): Promise<{ downloadUrl: string } | null> => { + const res = await apiClient.get(`/proposals/${id}/pdf/${revision}`, { + responseType: 'blob', + validateStatus: (status) => status < 500, + }); + if (res.status === 404) return null; + const blob = res.data as Blob; + const downloadUrl = URL.createObjectURL(blob); + return { downloadUrl }; + }, +}; diff --git a/web/src/domain/proposals/schemas.ts b/web/src/domain/proposals/schemas.ts new file mode 100644 index 0000000..ca7f63a --- /dev/null +++ b/web/src/domain/proposals/schemas.ts @@ -0,0 +1,68 @@ +// Proposals domain — contract schema re-exports + form schemas. +// Form schemas are derived from the shared contract schemas (rule 4) — form +// fields hold strings for free-text inputs; toCreateProposalRequest() maps +// validated form values to the wire type at submit. +import { z } from 'zod'; +import { createProposalRequestSchema } from '@proposal-system/api-contracts/schemas'; +import type { CreateProposalRequest } from './types'; + +export { + proposalStatusSchema, + serviceCategorySchema, + prioritySchema, + proposalListItemSchema, + proposalDetailSchema, + createProposalRequestSchema, + updateProposalRequestSchema, + proposalStatsSchema, + pdfVersionSchema, + presignedUploadSchema, +} from '@proposal-system/api-contracts/schemas'; + +// Fix: WEB-M3 — minimum length for scope of work to ensure meaningful descriptions +export const MIN_SCOPE_LENGTH = 10; + +/** + * Create-proposal form. Derived from createProposalRequestSchema: + * optional wire fields (poNumber, notes) are plain strings in the form, and + * `customCategory` is a form-only field required when serviceCategory=Other. + */ +export const createProposalFormSchema = createProposalRequestSchema + .extend({ + workOrderNumber: z.string().trim().min(1, 'Work Order Number is required'), + poNumber: z.string(), + customerName: z.string().trim().min(1, 'Customer name is required'), + customerAddress: z.string().trim().min(1, 'Site address is required'), + scopeOfWork: z + .string() + .trim() + .min(MIN_SCOPE_LENGTH, `Scope of Work must be at least ${MIN_SCOPE_LENGTH} characters`), + notes: z.string(), + customCategory: z.string(), + }) + .refine((v) => v.serviceCategory !== 'Other' || v.customCategory.trim().length > 0, { + message: 'Specify category', + path: ['customCategory'], + }); + +export type CreateProposalFormValues = z.infer; + +/** Map validated form values to the wire request (mirrors the current + * ProposalFormPage submit mapping, including the `[Category: X]` notes prefix). */ +export function toCreateProposalRequest(values: CreateProposalFormValues): CreateProposalRequest { + const notes = + values.serviceCategory === 'Other' && values.customCategory.trim() + ? `[Category: ${values.customCategory.trim()}]\n${values.notes || ''}`.trim() + : values.notes || undefined; + + return { + workOrderNumber: values.workOrderNumber, + ...(values.poNumber.trim() ? { poNumber: values.poNumber.trim() } : {}), + customerName: values.customerName, + customerAddress: values.customerAddress, + scopeOfWork: values.scopeOfWork, + serviceCategory: values.serviceCategory, + priority: values.priority, + notes, + }; +} diff --git a/web/src/domain/proposals/types.ts b/web/src/domain/proposals/types.ts new file mode 100644 index 0000000..5b8c74f --- /dev/null +++ b/web/src/domain/proposals/types.ts @@ -0,0 +1,32 @@ +// Proposals domain — contract type re-exports + view-only types. +export type { + ProposalStatus, + ServiceCategory, + Priority, + CreateProposalRequest, + ProposalDetail, + ProposalListItem, + ProposalFilters, + ProposalStats, + PagedResponse, + PdfVersion, + PresignedUpload, +} from '@proposal-system/api-contracts'; + +/** View-only shape of GET /proposals/{id}/vendors (no contract DTO yet; + * previously declared inline in VendorDataPanel). */ +export interface VendorProposal { + id: string; + vendorName: string; + fileName: string; + totalVendorCost: number; + processingStatus: string; + extractedData: { + lineItems?: Array<{ + description: string; + quantity?: number; + unitPrice?: number; + total?: number; + }>; + } | null; +} diff --git a/web/src/domain/proposals/use-cases.ts b/web/src/domain/proposals/use-cases.ts new file mode 100644 index 0000000..ba2ac5d --- /dev/null +++ b/web/src/domain/proposals/use-cases.ts @@ -0,0 +1,98 @@ +// Proposals domain — TanStack Query hooks + query keys (the only public surface). +import { keepPreviousData, useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; +import { toast } from 'react-toastify'; +import { proposalsApi } from './api'; +import type { CreateProposalRequest, ProposalDetail, ProposalFilters } from './types'; + +export const proposalsKeys = { + all: ['proposals'] as const, + lists: () => [...proposalsKeys.all, 'list'] as const, + list: (filters: ProposalFilters) => [...proposalsKeys.lists(), filters] as const, + detail: (id: string) => [...proposalsKeys.all, id] as const, + stats: () => [...proposalsKeys.all, 'stats'] as const, + vendors: (id: string) => [...proposalsKeys.all, id, 'vendors'] as const, +}; + +/** Paginated/filtered proposal list (ProposalListPage, AdminDashboard queue, + * Dashboard recent). keepPreviousData mirrors usePaginatedList's + * keep-rows-while-loading behavior across page changes. */ +export function useProposals(filters: ProposalFilters = {}) { + return useQuery({ + queryKey: proposalsKeys.list(filters), + queryFn: () => proposalsApi.getAll(filters), + placeholderData: keepPreviousData, + }); +} + +export function useProposal(id: string | undefined) { + return useQuery({ + queryKey: proposalsKeys.detail(id ?? ''), + queryFn: () => proposalsApi.getById(id!), + enabled: !!id, + }); +} + +export function useProposalStats() { + return useQuery({ + queryKey: proposalsKeys.stats(), + queryFn: () => proposalsApi.getStats(), + }); +} + +/** Vendor proposals attached to a proposal (VendorDataPanel). */ +export function useVendorProposals(proposalId: string | undefined) { + return useQuery({ + queryKey: proposalsKeys.vendors(proposalId ?? ''), + queryFn: () => proposalsApi.getVendors(proposalId!), + enabled: !!proposalId, + }); +} + +export interface CreateProposalVariables { + request: CreateProposalRequest; + /** Optional vendor PDF uploaded alongside the proposal. */ + vendorFile?: File | null; +} + +/** + * Create a proposal, optionally uploading a vendor PDF via presigned URL. + * Mirrors ProposalFormPage: a failed attachment upload warns but does not + * fail the mutation; success invalidates all proposal queries and toasts. + * Page-specific side effects (navigate) belong in the call site's onSuccess. + */ +export function useCreateProposal() { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: async ({ request, vendorFile }: CreateProposalVariables): Promise => { + const proposal = await proposalsApi.create(request); + + if (vendorFile) { + try { + const { uploadUrl, vendorProposalId } = await proposalsApi.uploadAttachment( + proposal.id, + vendorFile.name + ); + await fetch(uploadUrl, { + method: 'PUT', + body: vendorFile, + headers: { 'Content-Type': vendorFile.type || 'application/pdf' }, + }); + 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.` + ); + } + } + + return proposal; + }, + onSuccess: (proposal) => { + queryClient.invalidateQueries({ queryKey: proposalsKeys.all }); + toast.success(`Proposal ${proposal.proposalNumber} submitted`); + }, + onError: (error: Error) => { + toast.error(error.message || 'Failed to submit proposal'); + }, + }); +} diff --git a/web/src/domain/sites/api.ts b/web/src/domain/sites/api.ts new file mode 100644 index 0000000..2e45cfb --- /dev/null +++ b/web/src/domain/sites/api.ts @@ -0,0 +1,10 @@ +// Sites domain — HTTP calls only (no hooks). Moved from lib/api/sites.ts. +import apiClient from '../../lib/api/client'; +import type { Site } from './types'; + +export const sitesApi = { + search: async (query: string): Promise => { + const res = await apiClient.get(`/sites?query=${encodeURIComponent(query)}`); + return res.data; + }, +}; diff --git a/web/src/domain/sites/schemas.ts b/web/src/domain/sites/schemas.ts new file mode 100644 index 0000000..6c29cdb --- /dev/null +++ b/web/src/domain/sites/schemas.ts @@ -0,0 +1,3 @@ +// Sites domain — contract schema re-exports. Sites are read-only (search +// autocomplete), so there are no form schemas here. +export { siteSchema } from '@proposal-system/api-contracts/schemas'; diff --git a/web/src/domain/sites/types.ts b/web/src/domain/sites/types.ts new file mode 100644 index 0000000..ab6a9ce --- /dev/null +++ b/web/src/domain/sites/types.ts @@ -0,0 +1,2 @@ +// Sites domain — contract type re-exports. +export type { Site } from '@proposal-system/api-contracts'; diff --git a/web/src/domain/sites/use-cases.ts b/web/src/domain/sites/use-cases.ts new file mode 100644 index 0000000..1e82af7 --- /dev/null +++ b/web/src/domain/sites/use-cases.ts @@ -0,0 +1,18 @@ +// Sites domain — TanStack Query hooks + query keys (the only public surface). +import { useQuery } from '@tanstack/react-query'; +import { sitesApi } from './api'; + +export const sitesKeys = { + all: ['sites'] as const, + search: (query: string) => [...sitesKeys.all, 'search', query] as const, +}; + +/** Site autocomplete (ProposalFormPage). Call with the debounced input; + * disabled below 2 characters, matching current page behavior. */ +export function useSiteSearch(query: string) { + return useQuery({ + queryKey: sitesKeys.search(query), + queryFn: () => sitesApi.search(query), + enabled: query.trim().length >= 2, + }); +} diff --git a/web/vite.config.ts b/web/vite.config.ts index 782b661..5de237b 100644 --- a/web/vite.config.ts +++ b/web/vite.config.ts @@ -1,3 +1,4 @@ +import { fileURLToPath } from 'node:url'; import { defineConfig } from 'vite'; import react from '@vitejs/plugin-react'; @@ -8,6 +9,14 @@ export default defineConfig({ // 'zod' import resolves from web/node_modules in CI (shared has no // installed node_modules there). preserveSymlinks: true, + alias: { + // The package has no `exports` map (main points at src/index.ts), so + // the /schemas subpath entrypoint needs an explicit alias (mirrors the + // tsconfig `paths` mapping; vitest.config.ts carries the same alias). + '@proposal-system/api-contracts/schemas': fileURLToPath( + new URL('../shared/api-contracts/src/schemas.ts', import.meta.url) + ), + }, }, server: { port: 5173, diff --git a/web/vitest.config.ts b/web/vitest.config.ts index e47388f..e14846c 100644 --- a/web/vitest.config.ts +++ b/web/vitest.config.ts @@ -1,8 +1,19 @@ +import { fileURLToPath } from 'node:url'; import { defineConfig } from 'vitest/config'; import react from '@vitejs/plugin-react'; export default defineConfig({ plugins: [react()], + resolve: { + alias: { + // @proposal-system/api-contracts has no `exports` map (main points at + // src/index.ts), so the /schemas subpath entrypoint needs an explicit + // alias for vitest's resolver (mirrors the tsconfig `paths` mapping). + '@proposal-system/api-contracts/schemas': fileURLToPath( + new URL('../shared/api-contracts/src/schemas.ts', import.meta.url) + ), + }, + }, test: { environment: 'jsdom', globals: true, From 97fa7f2b75f3d044aaab555b130cd505368e9106 Mon Sep 17 00:00:00 2001 From: Adam Moussa Date: Mon, 13 Jul 2026 18:07:34 -0400 Subject: [PATCH 03/10] refactor(web): proposal pages on domain layer, proposal form on react-hook-form --- .../proposals/detail/ProposalDetailPage.tsx | 9 +- .../pages/proposals/form/ProposalFormPage.tsx | 312 ++++++++---------- .../pages/proposals/list/ProposalListPage.tsx | 48 ++- 3 files changed, 167 insertions(+), 202 deletions(-) diff --git a/web/src/pages/proposals/detail/ProposalDetailPage.tsx b/web/src/pages/proposals/detail/ProposalDetailPage.tsx index 41a923a..a3315e7 100644 --- a/web/src/pages/proposals/detail/ProposalDetailPage.tsx +++ b/web/src/pages/proposals/detail/ProposalDetailPage.tsx @@ -1,5 +1,4 @@ import { useParams, useNavigate } from 'react-router-dom'; -import { useQuery } from '@tanstack/react-query'; import { Box, Typography, @@ -15,7 +14,7 @@ import { StepLabel, } from '@mui/material'; import ArrowBackIcon from '@mui/icons-material/ArrowBack'; -import { proposalsApi, type ProposalDetail } from '../../../lib/api/proposals'; +import { useProposal } from '../../../domain/proposals/use-cases'; import { STATUS_CHIP_STYLES } from '../../../constants'; import { formatCurrency, formatDateTime } from '../../../lib/format'; import { useDocumentTitle } from '../../../hooks/useDocumentTitle'; @@ -37,11 +36,7 @@ export default function ProposalDetailPage() { const { id } = useParams<{ id: string }>(); const navigate = useNavigate(); - const { data: proposal, isLoading, error } = useQuery({ - queryKey: ['proposals', id], - queryFn: () => proposalsApi.getById(id!), - enabled: !!id, - }); + const { data: proposal, isLoading, error } = useProposal(id); useDocumentTitle(proposal?.proposalNumber ?? 'Proposal'); diff --git a/web/src/pages/proposals/form/ProposalFormPage.tsx b/web/src/pages/proposals/form/ProposalFormPage.tsx index 2d1d6b9..041cb71 100644 --- a/web/src/pages/proposals/form/ProposalFormPage.tsx +++ b/web/src/pages/proposals/form/ProposalFormPage.tsx @@ -1,6 +1,7 @@ -import { useState, useCallback, useRef, useEffect, useMemo } from 'react'; +import { useState, useRef, useEffect, useMemo } from 'react'; import { useNavigate } from 'react-router-dom'; -import { useMutation } from '@tanstack/react-query'; +import { Controller, useForm } from 'react-hook-form'; +import { zodResolver } from '@hookform/resolvers/zod'; import { Box, Typography, @@ -15,44 +16,45 @@ import { } from '@mui/material'; import SendIcon from '@mui/icons-material/Send'; import { toast } from 'react-toastify'; -import { proposalsApi, type CreateProposalRequest, type ServiceCategory, type Priority } from '../../../lib/api/proposals'; -import { sitesApi, type Site } from '../../../lib/api/sites'; -import { customersApi } from '../../../lib/api/customers'; +import { useCreateProposal } from '../../../domain/proposals/use-cases'; +import { + createProposalFormSchema, + toCreateProposalRequest, + MIN_SCOPE_LENGTH, + type CreateProposalFormValues, +} from '../../../domain/proposals/schemas'; +import { useSiteSearch } from '../../../domain/sites/use-cases'; +import { useCustomerSearch } from '../../../domain/customers/use-cases'; import { SERVICE_CATEGORIES, PRIORITIES, PRIORITY_LABELS } from '../../../constants'; -import { queryClient } from '../../../lib/queryClient'; - -interface ProposalFormState { - workOrderNumber: string; - poNumber?: string; - customerName: string; - customerAddress: string; - scopeOfWork: string; - serviceCategory: ServiceCategory | ''; - priority: Priority; - notes: string; -} const MAX_FILE_SIZE_BYTES = 25 * 1024 * 1024; const ALLOWED_MIME_TYPES = ['application/pdf']; -// Fix: WEB-M3 — minimum length for scope of work to ensure meaningful descriptions -const MIN_SCOPE_LENGTH = 10; export default function ProposalFormPage() { const navigate = useNavigate(); - const [form, setForm] = useState({ - workOrderNumber: '', - customerName: '', - customerAddress: '', - scopeOfWork: '', - serviceCategory: '', - priority: 'Standard', - notes: '', + const { + register, + handleSubmit, + control, + setValue, + watch, + } = useForm({ + resolver: zodResolver(createProposalFormSchema), + defaultValues: { + workOrderNumber: '', + poNumber: '', + customerName: '', + customerAddress: '', + scopeOfWork: '', + priority: 'Standard', + notes: '', + customCategory: '', + }, }); const [vendorFile, setVendorFile] = useState(null); - const [siteOptions, setSiteOptions] = useState([]); - const [siteLoading, setSiteLoading] = useState(false); + const [siteQuery, setSiteQuery] = useState(''); const [siteInputValue, setSiteInputValue] = useState(''); const [manualEntry, setManualEntry] = useState(false); const [manualSiteCode, setManualSiteCode] = useState(''); @@ -60,15 +62,22 @@ export default function ProposalFormPage() { const [manualCity, setManualCity] = useState(''); const [manualState, setManualState] = useState(''); const [manualZip, setManualZip] = useState(''); - const [customCategory, setCustomCategory] = useState(''); const siteDebounceRef = useRef | null>(null); - // PR4: customer search state — freeSolo Autocomplete backed by customersApi.search - const [customerOptions, setCustomerOptions] = useState([]); - const [customerLoading, setCustomerLoading] = useState(false); + // PR4: customer search state — freeSolo Autocomplete backed by the customers domain + const [customerQuery, setCustomerQuery] = useState(''); const [customerInputValue, setCustomerInputValue] = useState(''); const customerDebounceRef = useRef | null>(null); + // Domain search hooks (disabled below 2 characters, matching prior behavior). + const siteSearch = useSiteSearch(siteQuery); + const siteOptions = siteSearch.data ?? []; + const siteLoading = siteSearch.isFetching; + + const customerSearch = useCustomerSearch(customerQuery); + const customerOptions = (customerSearch.data ?? []).map((c) => c.name); + const customerLoading = customerSearch.isFetching; + useEffect(() => { return () => { if (siteDebounceRef.current) clearTimeout(siteDebounceRef.current); @@ -88,116 +97,52 @@ export default function ProposalFormPage() { manualSiteCode && address ? `${manualSiteCode} — ${address}` : manualSiteCode || address; - setForm((prev) => ({ ...prev, customerAddress: display })); - }, [manualEntry, manualSiteCode, manualStreet, manualCity, manualState, manualZip]); + setValue('customerAddress', display); + }, [manualEntry, manualSiteCode, manualStreet, manualCity, manualState, manualZip, setValue]); - const handleChange = (field: keyof ProposalFormState, value: string) => { - setForm((prev) => ({ ...prev, [field]: value })); - }; + const createMutation = useCreateProposal(); - const searchSites = useCallback(async (query: string) => { - if (query.length < 2) { - setSiteOptions([]); - return; - } - setSiteLoading(true); - try { - const results = await sitesApi.search(query); - setSiteOptions(results); - } catch { - setSiteOptions([]); - } finally { - setSiteLoading(false); - } - }, []); - - // PR4: search customers by name, debounced from the Autocomplete input - const searchCustomers = useCallback(async (query: string) => { - if (query.length < 2) { - setCustomerOptions([]); - return; - } - setCustomerLoading(true); - try { - const results = await customersApi.search(query); - setCustomerOptions(results.map((c) => c.name)); - } catch { - setCustomerOptions([]); - } finally { - setCustomerLoading(false); - } - }, []); - - const createMutation = useMutation({ - mutationFn: async () => { - const notes = form.serviceCategory === 'Other' && customCategory.trim() - ? `[Category: ${customCategory.trim()}]\n${form.notes || ''}`.trim() - : form.notes || undefined; - const request: CreateProposalRequest = { - workOrderNumber: form.workOrderNumber, - customerName: form.customerName, - customerAddress: form.customerAddress, - scopeOfWork: form.scopeOfWork, - serviceCategory: form.serviceCategory as ServiceCategory, - priority: form.priority, - notes, - }; - const proposal = await proposalsApi.create(request); - - if (vendorFile) { - try { - const { uploadUrl, vendorProposalId } = await proposalsApi.uploadAttachment(proposal.id, vendorFile.name); - await fetch(uploadUrl, { - method: 'PUT', - body: vendorFile, - headers: { 'Content-Type': vendorFile.type || 'application/pdf' }, - }); - 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.`); - } + const onSubmit = (values: CreateProposalFormValues) => { + createMutation.mutate( + { request: toCreateProposalRequest(values), vendorFile }, + { + onSuccess: (proposal) => { + navigate(`/proposals/${proposal.id}`); + }, } - - return proposal; - }, - onSuccess: (proposal) => { - queryClient.invalidateQueries({ queryKey: ['proposals'] }); - toast.success(`Proposal ${proposal.proposalNumber} submitted`); - navigate(`/proposals/${proposal.id}`); - }, - onError: (error: Error) => { - toast.error(error.message || 'Failed to submit proposal'); - }, - }); - - const handleSubmit = (e: React.FormEvent) => { - e.preventDefault(); - createMutation.mutate(); + ); }; + const workOrderNumber = watch('workOrderNumber'); + const customerName = watch('customerName'); + const customerAddress = watch('customerAddress'); + const scopeOfWork = watch('scopeOfWork'); + const serviceCategory = watch('serviceCategory'); + const customCategory = watch('customCategory'); + // Fix: WEB-M3 — enforce minimum scope length - const scopeTouched = form.scopeOfWork.length > 0; - const scopeTooShort = scopeTouched && form.scopeOfWork.trim().length < MIN_SCOPE_LENGTH; + const scopeTouched = scopeOfWork.length > 0; + const scopeTooShort = scopeTouched && scopeOfWork.trim().length < MIN_SCOPE_LENGTH; const isValid = - form.workOrderNumber.trim() && - form.customerName.trim() && - form.customerAddress.trim() && - form.scopeOfWork.trim().length >= MIN_SCOPE_LENGTH && - form.serviceCategory && - (form.serviceCategory !== 'Other' || customCategory.trim()); + workOrderNumber.trim() && + customerName.trim() && + customerAddress.trim() && + scopeOfWork.trim().length >= MIN_SCOPE_LENGTH && + serviceCategory && + (serviceCategory !== 'Other' || customCategory.trim()); const missingFields = useMemo(() => { const missing: string[] = []; - if (!form.workOrderNumber.trim()) missing.push('Work Order Number'); - if (!form.customerAddress.trim()) missing.push('Site address'); - if (!form.serviceCategory) missing.push('Service Category'); - if (form.serviceCategory === 'Other' && !customCategory.trim()) missing.push('Specify category'); + if (!workOrderNumber.trim()) missing.push('Work Order Number'); + if (!customerAddress.trim()) missing.push('Site address'); + if (!serviceCategory) missing.push('Service Category'); + if (serviceCategory === 'Other' && !customCategory.trim()) missing.push('Specify category'); // Fix: WEB-M3 — show minimum length requirement in missing fields summary - if (!form.scopeOfWork.trim()) missing.push('Scope of Work'); - else if (form.scopeOfWork.trim().length < MIN_SCOPE_LENGTH) missing.push(`Scope of Work (min ${MIN_SCOPE_LENGTH} chars)`); + if (!scopeOfWork.trim()) missing.push('Scope of Work'); + else if (scopeOfWork.trim().length < MIN_SCOPE_LENGTH) missing.push(`Scope of Work (min ${MIN_SCOPE_LENGTH} chars)`); return missing; - }, [form.workOrderNumber, form.customerAddress, form.serviceCategory, form.scopeOfWork, customCategory]); + }, [workOrderNumber, customerAddress, serviceCategory, scopeOfWork, customCategory]); return ( @@ -205,7 +150,7 @@ export default function ProposalFormPage() { New Proposal Request - + Job Details @@ -215,16 +160,14 @@ export default function ProposalFormPage() { label="Work Order Number" required fullWidth - value={form.workOrderNumber} - onChange={(e) => handleChange('workOrderNumber', e.target.value)} + {...register('workOrderNumber')} /> handleChange('poNumber', e.target.value)} + {...register('poNumber')} /> {/* PR4: freeSolo customer search — dispatchers can pick an existing @@ -239,18 +182,18 @@ export default function ProposalFormPage() { onInputChange={(_, value, reason) => { if (reason === 'input') { setCustomerInputValue(value); - handleChange('customerName', value); + setValue('customerName', value); if (customerDebounceRef.current) clearTimeout(customerDebounceRef.current); - customerDebounceRef.current = setTimeout(() => searchCustomers(value), 300); + customerDebounceRef.current = setTimeout(() => setCustomerQuery(value), 300); } else if (reason === 'clear') { setCustomerInputValue(''); - handleChange('customerName', ''); - setCustomerOptions([]); + setValue('customerName', ''); + setCustomerQuery(''); } }} onChange={(_, value) => { const selected = typeof value === 'string' ? value : value ?? ''; - handleChange('customerName', selected); + setValue('customerName', selected); setCustomerInputValue(selected); }} noOptionsText={ @@ -283,20 +226,26 @@ export default function ProposalFormPage() { /> - handleChange('priority', e.target.value)} - > - {PRIORITIES.map((p) => ( - - {PRIORITY_LABELS[p] || p} - - ))} - + ( + field.onChange(e.target.value)} + > + {PRIORITIES.map((p) => ( + + {PRIORITY_LABELS[p] || p} + + ))} + + )} + /> @@ -367,7 +316,7 @@ export default function ProposalFormPage() { setManualState(''); setManualZip(''); setSiteInputValue(''); - handleChange('customerAddress', ''); + setValue('customerAddress', ''); }} > Search sites instead @@ -389,7 +338,7 @@ export default function ProposalFormPage() { if (reason === 'input') { setSiteInputValue(value); if (siteDebounceRef.current) clearTimeout(siteDebounceRef.current); - siteDebounceRef.current = setTimeout(() => searchSites(value), 300); + siteDebounceRef.current = setTimeout(() => setSiteQuery(value), 300); } }} onChange={(_, value) => { @@ -397,10 +346,10 @@ export default function ProposalFormPage() { const display = value.fullAddress ? `${value.siteCode} — ${value.fullAddress}` : value.siteCode; - handleChange('customerAddress', display); + setValue('customerAddress', display); setSiteInputValue(display); } else { - handleChange('customerAddress', ''); + setValue('customerAddress', ''); setSiteInputValue(''); } }} @@ -450,30 +399,35 @@ export default function ProposalFormPage() { Work Details - handleChange('serviceCategory', e.target.value)} - > - {SERVICE_CATEGORIES.map((cat) => ( - - {cat} - - ))} - + ( + field.onChange(e.target.value)} + > + {SERVICE_CATEGORIES.map((cat) => ( + + {cat} + + ))} + + )} + /> - {form.serviceCategory === 'Other' ? ( + {serviceCategory === 'Other' ? ( setCustomCategory(e.target.value)} + {...register('customCategory')} placeholder="e.g. Fire Protection" /> @@ -489,13 +443,12 @@ export default function ProposalFormPage() { fullWidth multiline minRows={4} - value={form.scopeOfWork} - onChange={(e) => handleChange('scopeOfWork', e.target.value)} + {...register('scopeOfWork')} placeholder="Describe the work needed..." error={scopeTooShort} helperText={ scopeTooShort - ? `Scope must be at least ${MIN_SCOPE_LENGTH} characters (currently ${form.scopeOfWork.trim().length})` + ? `Scope must be at least ${MIN_SCOPE_LENGTH} characters (currently ${scopeOfWork.trim().length})` : undefined } /> @@ -507,8 +460,7 @@ export default function ProposalFormPage() { fullWidth multiline minRows={2} - value={form.notes} - onChange={(e) => handleChange('notes', e.target.value)} + {...register('notes')} /> diff --git a/web/src/pages/proposals/list/ProposalListPage.tsx b/web/src/pages/proposals/list/ProposalListPage.tsx index ac2e252..3ab2aa6 100644 --- a/web/src/pages/proposals/list/ProposalListPage.tsx +++ b/web/src/pages/proposals/list/ProposalListPage.tsx @@ -1,3 +1,4 @@ +import { useEffect, useRef, useState } from 'react'; import { useNavigate } from 'react-router-dom'; import { Box, @@ -19,28 +20,45 @@ import { } from '@mui/material'; import SearchIcon from '@mui/icons-material/Search'; import AddCircleIcon from '@mui/icons-material/AddCircle'; -import { usePaginatedList } from '../../../hooks/usePaginatedList'; -import { proposalsApi, type ProposalListItem } from '../../../lib/api/proposals'; -import { STATUS_CHIP_STYLES } from '../../../constants'; +import { useProposals } from '../../../domain/proposals/use-cases'; +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'; export default function ProposalListPage() { useDocumentTitle('My Proposals'); const navigate = useNavigate(); - const mineParams = { mine: true }; - const { - rows, - search, - setSearch, + + const [search, setSearch] = useState(''); + const [debouncedSearch, setDebouncedSearch] = useState(''); + const [page, setPage] = useState(DEFAULT_PAGE); + const [pageSize, setPageSize] = useState(DEFAULT_PAGE_SIZE); + const debounceRef = useRef | null>(null); + + // Debounced search resets to page 1 (mirrors usePaginatedList). + useEffect(() => { + if (debounceRef.current) clearTimeout(debounceRef.current); + debounceRef.current = setTimeout(() => { + setDebouncedSearch(search); + setPage(1); + }, DEBOUNCE_SEARCH); + return () => { + if (debounceRef.current) clearTimeout(debounceRef.current); + }; + }, [search]); + + const { data, isLoading, isPlaceholderData, error } = useProposals({ + search: debouncedSearch, page, - setPage, pageSize, - setPageSize, - totalCount, - loading, - err, - } = usePaginatedList(proposalsApi.getAll, mineParams); + mine: true, + }); + + const rows = data?.items ?? []; + const totalCount = data?.totalCount ?? 0; + // Skeletons on first load and while fetching a new page/search (placeholder rows shown). + const loading = isLoading || isPlaceholderData; + const err = error ? error.message || 'Failed to load' : ''; return ( @@ -127,7 +145,7 @@ export default function ProposalListPage() { {row.totalBidAmount > 0 ? formatBidAmount(row.totalBidAmount) - : Not priced + : Not priced } From f1087aa82d5b877f5f4bfc41c7c4afbb1c59e6b8 Mon Sep 17 00:00:00 2001 From: Adam Moussa Date: Mon, 13 Jul 2026 18:05:29 -0400 Subject: [PATCH 04/10] refactor(web): admin workspace on domain layer --- .../pages/admin/workspace/AdminWorkspace.tsx | 230 ++++++++---------- 1 file changed, 97 insertions(+), 133 deletions(-) diff --git a/web/src/pages/admin/workspace/AdminWorkspace.tsx b/web/src/pages/admin/workspace/AdminWorkspace.tsx index 87729e8..b73a3a5 100644 --- a/web/src/pages/admin/workspace/AdminWorkspace.tsx +++ b/web/src/pages/admin/workspace/AdminWorkspace.tsx @@ -1,6 +1,5 @@ import { useState, useEffect, useCallback, useRef } from 'react'; import { useParams, useNavigate } from 'react-router-dom'; -import { useQuery, useMutation } from '@tanstack/react-query'; import { Box, Typography, @@ -31,16 +30,39 @@ import HistoryIcon from '@mui/icons-material/History'; import PictureAsPdfIcon from '@mui/icons-material/PictureAsPdf'; import UndoIcon from '@mui/icons-material/Undo'; import { toast } from 'react-toastify'; -import { proposalsApi, type ProposalDetail } from '../../../lib/api/proposals'; -import { lineItemsApi, type LineItem } from '../../../lib/api/lineItems'; -import { adminApi } from '../../../lib/api/admin'; -import { queryClient } from '../../../lib/queryClient'; -import { QUERY_KEYS } from '../../../constants/queryKeys'; +import { useProposal } from '../../../domain/proposals/use-cases'; +import { useLineItems } from '../../../domain/lineItems/use-cases'; +import type { UpdateLineItemEntry } from '../../../domain/lineItems/types'; +import { + useSimilarProposals, + useSaveProposalWorkspace, + useApproveProposal, + useSendProposal, + useReviseProposal, + useReturnToReview, + useGenerateSuggestions, + useProposalPdf, +} from '../../../domain/admin/use-cases'; import LineItemEditor, { type EditableLineItem } from '../../../components/admin/LineItemEditor'; import VendorDataPanel from '../../../components/admin/VendorDataPanel'; import SimilarProposalsPanel from '../../../components/admin/SimilarProposalsPanel'; import { STATUS_CHIP_STYLES } from '../../../constants'; +/** Map the editor's rows to the bulk-update wire entries. */ +function toUpdateEntries(items: EditableLineItem[]): UpdateLineItemEntry[] { + return items.map((li) => ({ + id: li.id, + description: li.description, + quantity: li.quantity, + unit: li.unit, + unitPrice: li.unitPrice, + totalPrice: li.totalPrice, + pricingMode: li.pricingMode, + sortOrder: li.sortOrder, + source: li.source, + })); +} + export default function AdminWorkspace() { const { id } = useParams<{ id: string }>(); const navigate = useNavigate(); @@ -55,24 +77,18 @@ export default function AdminWorkspace() { const [dirty, setDirty] = useState(false); // Fix: WEB-M7 — capture error state for failed fetch - const { data: proposal, isLoading, isError: isProposalError, error: proposalError, refetch: refetchProposal } = useQuery({ - queryKey: [QUERY_KEYS.proposals, id], - queryFn: () => proposalsApi.getById(id!), - enabled: !!id, - }); + const { + data: proposal, + isLoading, + isError: isProposalError, + error: proposalError, + refetch: refetchProposal, + } = useProposal(id); // Fix: WEB-M9 — capture loading state for line items skeleton - const { data: fetchedLineItems, isLoading: lineItemsLoading } = useQuery({ - queryKey: [QUERY_KEYS.proposalLineItems, id], - queryFn: () => lineItemsApi.getAll(id!), - enabled: !!id, - }); + const { data: fetchedLineItems, isLoading: lineItemsLoading } = useLineItems(id); - const { data: similarProposals } = useQuery({ - queryKey: ['similarProposals', id], - queryFn: () => adminApi.getSimilar(id!), - enabled: !!id, - }); + const { data: similarProposals } = useSimilarProposals(id); const hasSimilar = similarProposals && similarProposals.length > 0; @@ -121,119 +137,67 @@ export default function AdminWorkspace() { } }, [dirty, navigate]); - const saveMutation = useMutation({ - mutationFn: async () => { - await adminApi.updateProposal(id!, { refinedScope }); - await lineItemsApi.bulkUpdate( - id!, - lineItems.map((li) => ({ - id: li.id, - description: li.description, - quantity: li.quantity, - unit: li.unit, - unitPrice: li.unitPrice, - totalPrice: li.totalPrice, - pricingMode: li.pricingMode, - sortOrder: li.sortOrder, - source: li.source, - })) - ); - }, - onSuccess: () => { - queryClient.invalidateQueries({ queryKey: [QUERY_KEYS.proposalLineItems, id] }); - queryClient.invalidateQueries({ queryKey: [QUERY_KEYS.proposals, id] }); - setDirty(false); - toast.success('Changes saved'); - }, - onError: (error: Error) => { - toast.error(`Save failed: ${error.message}`); - }, - }); + // Domain mutations: cache invalidation + success/error toasts live in the + // hooks; page-specific side effects (dialog close, dirty reset, window.open) + // are applied here via the call-site callbacks (domain README rule 6). + const saveMutation = useSaveProposalWorkspace(id ?? ''); + const approveMutation = useApproveProposal(id ?? ''); + const sendMutation = useSendProposal(id ?? ''); + const reviseMutation = useReviseProposal(id ?? ''); + const regenerateMutation = useGenerateSuggestions(id ?? ''); + // Fix: WEB-M11 — wire returnToReview API method to UI + const returnToReviewMutation = useReturnToReview(id ?? ''); + const pdfMutation = useProposalPdf(id ?? ''); - const approveMutation = useMutation({ - mutationFn: async () => { + const handleSave = () => { + saveMutation.mutate( + { refinedScope, lineItems: toUpdateEntries(lineItems) }, + { onSuccess: () => setDirty(false) } + ); + }; + + const handleConfirmApprove = async () => { + try { + // Persist any unsaved workspace edits before approving (behavior parity + // with the previous approve mutation, which saved first when dirty). if (dirty) { - await saveMutation.mutateAsync(); + await saveMutation.mutateAsync({ refinedScope, lineItems: toUpdateEntries(lineItems) }); } - await adminApi.approveProposal(id!); - }, - onSuccess: () => { - queryClient.invalidateQueries({ queryKey: [QUERY_KEYS.proposals, id] }); - queryClient.invalidateQueries({ queryKey: [QUERY_KEYS.proposalLineItems, id] }); + await approveMutation.mutateAsync(); setApproveDialogOpen(false); setDirty(false); - toast.success('Proposal approved'); - }, - onError: (error: Error) => { - toast.error(`Approval failed: ${error.message}`); - }, - }); + } catch { + // error toasts are handled by the domain hooks + } + }; + const approvePending = approveMutation.isPending || saveMutation.isPending; - const sendMutation = useMutation({ - mutationFn: () => adminApi.sendProposal(id!), - onSuccess: () => { - queryClient.invalidateQueries({ queryKey: [QUERY_KEYS.proposals, id] }); - setSendDialogOpen(false); - toast.success('Proposal marked as sent'); - }, - // Fix: WEB-H5 — add missing onError handler - onError: (error: Error) => { - toast.error(`Send failed: ${error.message}`); - }, - }); + const handleConfirmSend = () => { + sendMutation.mutate(undefined, { onSuccess: () => setSendDialogOpen(false) }); + }; - const reviseMutation = useMutation({ - mutationFn: () => adminApi.reviseProposal(id!), - onSuccess: () => { - queryClient.invalidateQueries({ queryKey: [QUERY_KEYS.proposals, id] }); - setReviseDialogOpen(false); - toast.success('Revision created'); - }, - // Fix: WEB-H6 — add missing onError handler - onError: (error: Error) => { - toast.error(`Revision failed: ${error.message}`); - }, - }); + const handleConfirmRevise = () => { + reviseMutation.mutate(undefined, { onSuccess: () => setReviseDialogOpen(false) }); + }; - const regenerateMutation = useMutation({ - mutationFn: () => adminApi.generateSuggestions(id!), - onSuccess: () => { - toast.info('AI suggestion generation started'); - }, - // Fix: WEB-H5 — add missing onError handler - onError: (error: Error) => { - toast.error(`Regeneration failed: ${error.message}`); - }, - }); + const handleConfirmReturnToReview = () => { + returnToReviewMutation.mutate(undefined, { + onSuccess: () => setReturnToReviewDialogOpen(false), + }); + }; - // Fix: WEB-M11 — wire returnToReview API method to UI - const returnToReviewMutation = useMutation({ - mutationFn: () => adminApi.returnToReview(id!), - onSuccess: () => { - queryClient.invalidateQueries({ queryKey: [QUERY_KEYS.proposals, id] }); - queryClient.invalidateQueries({ queryKey: [QUERY_KEYS.proposalLineItems, id] }); - setReturnToReviewDialogOpen(false); - toast.success('Proposal returned to review'); - }, - onError: (error: Error) => { - toast.error(`Return to review failed: ${error.message}`); - }, - }); - - const pdfMutation = useMutation({ - mutationFn: () => adminApi.getPdf(id!), - onSuccess: (data) => { - if (data?.downloadUrl) { - window.open(data.downloadUrl, '_blank'); - toast.success('PDF ready — downloading'); - } else { - toast.info('PDF generation queued — try again in a few seconds'); - } - }, - onError: () => { - toast.error('Failed to generate PDF'); - }, - }); + const handleGeneratePdf = () => { + pdfMutation.mutate(undefined, { + onSuccess: (data) => { + if (data?.downloadUrl) { + window.open(data.downloadUrl, '_blank'); + toast.success('PDF ready — downloading'); + } else { + toast.info('PDF generation queued — try again in a few seconds'); + } + }, + }); + }; const handleLineItemsChange = (updated: EditableLineItem[]) => { setLineItems(updated); @@ -478,7 +442,7 @@ export default function AdminWorkspace() { @@ -622,7 +586,7 @@ export default function AdminWorkspace() { - - +
+ {isEditing ? 'Edit Customer' : 'New Customer'} + + + + + + + + + + + +
); From b7c6386acaa21cf952d5742c91048e37c23d7ef7 Mon Sep 17 00:00:00 2001 From: Adam Moussa Date: Mon, 13 Jul 2026 18:05:47 -0400 Subject: [PATCH 06/10] refactor(web): pricing library on domain layer + react-hook-form --- .../pricing-library/PricingLibraryPage.tsx | 364 ++++++++---------- 1 file changed, 162 insertions(+), 202 deletions(-) diff --git a/web/src/pages/admin/pricing-library/PricingLibraryPage.tsx b/web/src/pages/admin/pricing-library/PricingLibraryPage.tsx index ea200c6..13581d6 100644 --- a/web/src/pages/admin/pricing-library/PricingLibraryPage.tsx +++ b/web/src/pages/admin/pricing-library/PricingLibraryPage.tsx @@ -1,5 +1,6 @@ import { useState } from 'react'; -import { useQuery, useMutation } from '@tanstack/react-query'; +import { Controller, useForm, type DefaultValues } from 'react-hook-form'; +import { zodResolver } from '@hookform/resolvers/zod'; import { Box, Typography, @@ -29,17 +30,18 @@ import AddIcon from '@mui/icons-material/Add'; import EditIcon from '@mui/icons-material/Edit'; import DeleteIcon from '@mui/icons-material/Delete'; import ErrorOutlineIcon from '@mui/icons-material/ErrorOutlined'; -import { toast } from 'react-toastify'; import { - pricingLibraryApi, - type PricingLibraryItem, - type PagedPricingLibraryResponse, - type CreatePricingLibraryItemRequest, - type UpdatePricingLibraryItemRequest, -} from '../../../lib/api/pricingLibrary'; -import type { ServiceCategory } from '@proposal-system/api-contracts'; -import { queryClient } from '../../../lib/queryClient'; -import { QUERY_KEYS } from '../../../constants/queryKeys'; + usePricingLibraryList, + useCreatePricingLibraryItem, + useUpdatePricingLibraryItem, + useDeletePricingLibraryItem, +} from '../../../domain/pricingLibrary/use-cases'; +import type { PricingLibraryItem } from '../../../domain/pricingLibrary/types'; +import { + pricingLibraryItemFormSchema, + toPricingLibraryItemRequest, + type PricingLibraryItemFormValues, +} from '../../../domain/pricingLibrary/schemas'; import { DEFAULT_PAGE, DEFAULT_PAGE_SIZE, SERVICE_CATEGORIES } from '../../../constants'; import { useDocumentTitle } from '../../../hooks/useDocumentTitle'; @@ -55,18 +57,12 @@ const CATEGORY_CHIP_STYLES: Record = { title: '', - serviceCategory: '', unit: '', unitPrice: '', keywords: '', @@ -86,15 +82,7 @@ export default function PricingLibraryPage() { const [page, setPage] = useState(DEFAULT_PAGE); const [pageSize, setPageSize] = useState(DEFAULT_PAGE_SIZE); - const { - data, - isLoading, - isError, - refetch, - } = useQuery({ - queryKey: [QUERY_KEYS.pricingLibraryList, page, pageSize], - queryFn: () => pricingLibraryApi.list({ page, pageSize }), - }); + const { data, isLoading, isError, refetch } = usePricingLibraryList({ page, pageSize }); const rows = data?.items ?? []; const totalCount = data?.totalCount ?? 0; @@ -102,21 +90,28 @@ export default function PricingLibraryPage() { // --- Create/Edit dialog state --- const [dialogOpen, setDialogOpen] = useState(false); const [editingItem, setEditingItem] = useState(null); - const [form, setForm] = useState(EMPTY_FORM); - const [formErrors, setFormErrors] = useState>>({}); const isEditing = editingItem !== null; + const { + control, + handleSubmit, + reset, + formState: { errors }, + } = useForm({ + resolver: zodResolver(pricingLibraryItemFormSchema), + defaultValues: EMPTY_FORM_VALUES, + }); + const openCreateDialog = () => { setEditingItem(null); - setForm(EMPTY_FORM); - setFormErrors({}); + reset(EMPTY_FORM_VALUES); setDialogOpen(true); }; const openEditDialog = (item: PricingLibraryItem) => { setEditingItem(item); - setForm({ + reset({ title: item.title, serviceCategory: item.serviceCategory, unit: item.unit ?? '', @@ -124,40 +119,13 @@ export default function PricingLibraryPage() { keywords: item.keywords ?? '', description: item.description ?? '', }); - setFormErrors({}); setDialogOpen(true); }; const closeDialog = () => { setDialogOpen(false); setEditingItem(null); - setForm(EMPTY_FORM); - setFormErrors({}); - }; - - const validateForm = (): boolean => { - const errors: Partial> = {}; - if (!form.title.trim()) errors.title = 'Title is required'; - if (!form.serviceCategory) errors.serviceCategory = 'Service category is required'; - if (form.unitPrice.trim() !== '') { - const parsed = parseFloat(form.unitPrice); - if (isNaN(parsed) || parsed < 0) { - errors.unitPrice = 'Must be a number >= 0'; - } - } - setFormErrors(errors); - return Object.keys(errors).length === 0; - }; - - const handleFieldChange = (field: keyof ItemFormState, value: string) => { - setForm((prev) => ({ ...prev, [field]: value })); - if (formErrors[field]) { - setFormErrors((prev) => { - const next = { ...prev }; - delete next[field]; - return next; - }); - } + reset(EMPTY_FORM_VALUES); }; // --- Delete confirm dialog state --- @@ -171,67 +139,26 @@ export default function PricingLibraryPage() { setDeleteTarget(null); }; - // --- Mutations --- - const createMutation = useMutation({ - mutationFn: (data: CreatePricingLibraryItemRequest) => pricingLibraryApi.create(data), - onSuccess: () => { - queryClient.invalidateQueries({ queryKey: [QUERY_KEYS.pricingLibraryList] }); - closeDialog(); - toast.success('Pricing item created'); - }, - onError: (error: Error) => { - toast.error(`Failed to create item: ${error.message}`); - }, - }); + // --- Mutations (domain hooks own invalidation + toasts; dialog close stays here) --- + const createMutation = useCreatePricingLibraryItem(); + const updateMutation = useUpdatePricingLibraryItem(); + const deleteMutation = useDeletePricingLibraryItem(); - const updateMutation = useMutation({ - mutationFn: ({ id, data }: { id: string; data: UpdatePricingLibraryItemRequest }) => - pricingLibraryApi.update(id, data), - onSuccess: () => { - queryClient.invalidateQueries({ queryKey: [QUERY_KEYS.pricingLibraryList] }); - closeDialog(); - toast.success('Pricing item updated'); - }, - onError: (error: Error) => { - toast.error(`Failed to update item: ${error.message}`); - }, - }); - - const deleteMutation = useMutation({ - mutationFn: (id: string) => pricingLibraryApi.remove(id), - onSuccess: () => { - queryClient.invalidateQueries({ queryKey: [QUERY_KEYS.pricingLibraryList] }); - closeDeleteDialog(); - toast.success('Pricing item deleted'); - }, - onError: (error: Error) => { - toast.error(`Failed to delete item: ${error.message}`); - }, - }); - - const handleSubmit = () => { - if (!validateForm()) return; - if (!form.serviceCategory) return; // validateForm guarantees this; narrows '' out of the union - - const payload = { - title: form.title.trim(), - serviceCategory: form.serviceCategory, - ...(form.unit.trim() ? { unit: form.unit.trim() } : {}), - unitPrice: form.unitPrice.trim() !== '' ? parseFloat(form.unitPrice) : null, - ...(form.keywords.trim() ? { keywords: form.keywords.trim() } : {}), - ...(form.description.trim() ? { description: form.description.trim() } : {}), - }; - - if (isEditing) { - updateMutation.mutate({ id: editingItem.id, data: payload }); + const onSubmit = handleSubmit((values) => { + const payload = toPricingLibraryItemRequest(values); + if (editingItem) { + updateMutation.mutate( + { id: editingItem.id, data: payload }, + { onSuccess: () => closeDialog() } + ); } else { - createMutation.mutate(payload); + createMutation.mutate(payload, { onSuccess: () => closeDialog() }); } - }; + }); const handleDeleteConfirm = () => { if (deleteTarget) { - deleteMutation.mutate(deleteTarget.id); + deleteMutation.mutate(deleteTarget.id, { onSuccess: () => closeDeleteDialog() }); } }; @@ -368,90 +295,123 @@ export default function PricingLibraryPage() { {/* Create / Edit Dialog */} {isEditing ? 'Edit Pricing Item' : 'New Pricing Item'} - - - handleFieldChange('title', e.target.value)} - error={!!formErrors.title} - helperText={formErrors.title} - size="small" - required - fullWidth - autoFocus - /> - handleFieldChange('serviceCategory', e.target.value)} - error={!!formErrors.serviceCategory} - helperText={formErrors.serviceCategory} - size="small" - required - fullWidth - select +
+ + + ( + + )} + /> + ( + + {SERVICE_CATEGORIES.map((cat) => ( + + {cat} + + ))} + + )} + /> + ( + + )} + /> + ( + + )} + /> + ( + + )} + /> + ( + + )} + /> + + + + + - - + {isSaving ? (isEditing ? 'Saving...' : 'Creating...') : isEditing ? 'Save Changes' : 'Create Item'} + + +
{/* Delete Confirm Dialog */} From d1c7e812f6d7a8b79e2ed5734e71406157dc9f0e Mon Sep 17 00:00:00 2001 From: Adam Moussa Date: Mon, 13 Jul 2026 18:03:33 -0400 Subject: [PATCH 07/10] refactor(web): dashboards on domain layer --- .../pages/admin/dashboard/AdminDashboard.tsx | 69 ++++++++++++------- web/src/pages/dashboard/Dashboard.tsx | 14 +--- 2 files changed, 49 insertions(+), 34 deletions(-) diff --git a/web/src/pages/admin/dashboard/AdminDashboard.tsx b/web/src/pages/admin/dashboard/AdminDashboard.tsx index b664728..d0d0bce 100644 --- a/web/src/pages/admin/dashboard/AdminDashboard.tsx +++ b/web/src/pages/admin/dashboard/AdminDashboard.tsx @@ -1,6 +1,5 @@ -import { useMemo, useState, Fragment } from 'react'; +import { useEffect, useMemo, useState, Fragment } from 'react'; import { useNavigate } from 'react-router-dom'; -import { useQuery } from '@tanstack/react-query'; import { Box, Typography, @@ -37,11 +36,10 @@ import ReportIcon from '@mui/icons-material/Report'; import ErrorOutlineIcon from '@mui/icons-material/ErrorOutlined'; import KeyboardArrowDownIcon from '@mui/icons-material/KeyboardArrowDown'; import KeyboardArrowRightIcon from '@mui/icons-material/KeyboardArrowRight'; -import { usePaginatedList } from '../../../hooks/usePaginatedList'; -import { proposalsApi, type ProposalListItem } from '../../../lib/api/proposals'; -import { adminApi, type DashboardStats } from '../../../lib/api/admin'; -import { QUERY_KEYS } from '../../../constants/queryKeys'; -import { SERVICE_CATEGORIES, PRIORITIES, PROPOSAL_STATUSES, STATUS_CHIP_STYLES, STATUS_LABELS, PRIORITY_LABELS, PRIORITY_CHIP_STYLES, PRIORITY_ROW_SX } from '../../../constants'; +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 { useDocumentTitle } from '../../../hooks/useDocumentTitle'; @@ -75,33 +73,48 @@ 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 } = useQuery({ - queryKey: [QUERY_KEYS.dashboard], - queryFn: adminApi.getDashboard, - }); + const { data: stats, isLoading: statsLoading, isError: statsError, refetch: refetchStats } = useAdminDashboard(); const [statusFilter, setStatusFilter] = useState(defaultStatus ?? ''); const [categoryFilter, setCategoryFilter] = useState(''); const [priorityFilter, setPriorityFilter] = useState(''); const [expanded, setExpanded] = useState>(new Set()); + const [search, setSearch] = useState(''); + const [debouncedSearch, setDebouncedSearch] = useState(''); + const [page, setPage] = useState(DEFAULT_PAGE); + const [pageSize, setPageSize] = useState(DEFAULT_PAGE_SIZE); + + // Debounced search resets to page 1 (mirrors the old usePaginatedList behavior). + useEffect(() => { + const handle = setTimeout(() => { + setDebouncedSearch(search); + setPage(1); + }, DEBOUNCE_SEARCH); + return () => clearTimeout(handle); + }, [search]); + const { - rows, - search, - setSearch, + data: listData, + isPending: listPending, + isPlaceholderData: listIsPlaceholder, + error: listError, + } = useProposals({ + search: debouncedSearch, page, - setPage, pageSize, - setPageSize, - totalCount, - loading, - err, - } = usePaginatedList(proposalsApi.getAll, { ...(statusFilter && { status: statusFilter }), ...(categoryFilter && { serviceCategory: categoryFilter }), ...(priorityFilter && { priority: priorityFilter }), }); + const rows = useMemo(() => listData?.items ?? [], [listData]); + const totalCount = listData?.totalCount ?? 0; + // Show skeletons on initial load and while previous-page placeholder data is + // displayed for a new filter/page/search key (matches the old loading flag). + const loading = listPending || listIsPlaceholder; + const err = listError ? listError.message || 'Failed to load' : ''; + const grouped = useMemo(() => groupRevisions(rows), [rows]); const toggleExpand = (baseNumber: string) => { @@ -127,6 +140,7 @@ export default function AdminDashboard({ defaultStatus }: { defaultStatus?: stri setCategoryFilter(''); setPriorityFilter(''); setSearch(''); + setPage(1); }; const renderRow = (row: ProposalListItem, indent: boolean, showExpandIcon: boolean, isExpanded: boolean, baseNumber: string) => ( @@ -297,7 +311,10 @@ export default function AdminDashboard({ defaultStatus }: { defaultStatus?: stri label="Category" value={categoryFilter} sx={{ width: 160 }} - onChange={(e) => setCategoryFilter(e.target.value)} + onChange={(e) => { + setCategoryFilter(e.target.value); + setPage(1); + }} > All {SERVICE_CATEGORIES.map((c) => ( @@ -310,7 +327,10 @@ export default function AdminDashboard({ defaultStatus }: { defaultStatus?: stri label="Priority" value={priorityFilter} sx={{ width: 200 }} - onChange={(e) => setPriorityFilter(e.target.value)} + onChange={(e) => { + setPriorityFilter(e.target.value); + setPage(1); + }} > All {PRIORITIES.map((p) => ( @@ -324,7 +344,10 @@ export default function AdminDashboard({ defaultStatus }: { defaultStatus?: stri label="Status" value={statusFilter} sx={{ width: 140 }} - onChange={(e) => setStatusFilter(e.target.value)} + onChange={(e) => { + setStatusFilter(e.target.value); + setPage(1); + }} > All {PROPOSAL_STATUSES.map((s) => ( diff --git a/web/src/pages/dashboard/Dashboard.tsx b/web/src/pages/dashboard/Dashboard.tsx index 56931a9..d6ed031 100644 --- a/web/src/pages/dashboard/Dashboard.tsx +++ b/web/src/pages/dashboard/Dashboard.tsx @@ -1,5 +1,4 @@ import { useNavigate } from 'react-router-dom'; -import { useQuery } from '@tanstack/react-query'; import { Box, Typography, @@ -24,8 +23,7 @@ import CheckCircleIcon from '@mui/icons-material/CheckCircle'; import SendIcon from '@mui/icons-material/Send'; import ErrorOutlineIcon from '@mui/icons-material/ErrorOutlined'; import Tooltip from '@mui/material/Tooltip'; -import { proposalsApi, type ProposalListItem, type ProposalStats } from '../../lib/api/proposals'; -import { QUERY_KEYS } from '../../constants/queryKeys'; +import { useProposals, useProposalStats } from '../../domain/proposals/use-cases'; import { STATUS_CHIP_STYLES } from '../../constants'; import { formatCurrency, formatDate, formatDateFull } from '../../lib/format'; import { useDocumentTitle } from '../../hooks/useDocumentTitle'; @@ -55,16 +53,10 @@ export default function Dashboard() { useDocumentTitle('Dashboard'); const navigate = useNavigate(); - const { data: recentData, isLoading: recentLoading, error: recentError } = useQuery<{ items: ProposalListItem[]; totalCount: number }>({ - queryKey: [QUERY_KEYS.proposals, 'dashboard-recent'], - queryFn: () => proposalsApi.getAll({ 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 } = useQuery({ - queryKey: [QUERY_KEYS.proposals, 'stats'], - queryFn: () => proposalsApi.getStats(), - }); + const { data: stats, isLoading: statsLoading, isError: statsError, refetch: refetchStats } = useProposalStats(); const proposals = recentData?.items ?? []; const total = stats?.totalCount ?? 0; From f456006c30c6f9a20a4f7e5a690f506c026e1403 Mon Sep 17 00:00:00 2001 From: Adam Moussa Date: Mon, 13 Jul 2026 18:05:40 -0400 Subject: [PATCH 08/10] test(web): domain use-case hook coverage --- .../domain/__tests__/admin.use-cases.test.tsx | 87 ++++++++++++ .../__tests__/customers.use-cases.test.tsx | 98 ++++++++++++++ web/src/domain/__tests__/hookTestUtils.tsx | 18 +++ .../__tests__/lineItems.use-cases.test.tsx | 74 ++++++++++ .../__tests__/proposals.use-cases.test.tsx | 126 ++++++++++++++++++ 5 files changed, 403 insertions(+) create mode 100644 web/src/domain/__tests__/admin.use-cases.test.tsx create mode 100644 web/src/domain/__tests__/customers.use-cases.test.tsx create mode 100644 web/src/domain/__tests__/hookTestUtils.tsx create mode 100644 web/src/domain/__tests__/lineItems.use-cases.test.tsx create mode 100644 web/src/domain/__tests__/proposals.use-cases.test.tsx diff --git a/web/src/domain/__tests__/admin.use-cases.test.tsx b/web/src/domain/__tests__/admin.use-cases.test.tsx new file mode 100644 index 0000000..617ee08 --- /dev/null +++ b/web/src/domain/__tests__/admin.use-cases.test.tsx @@ -0,0 +1,87 @@ +// Admin domain use-case hooks: state transitions must cross-domain +// invalidate the proposals/lineItems keys (domain README rule 3). The domain +// api modules are mocked — no axios traffic. +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 { useApproveProposal } from '../admin/use-cases'; +import { proposalsKeys } from '../proposals/use-cases'; +import { lineItemsKeys } from '../lineItems/use-cases'; +import { adminApi } from '../admin/api'; + +vi.mock('../admin/api', () => ({ + adminApi: { + getDashboard: vi.fn(), + updateProposal: vi.fn(), + approveProposal: vi.fn(), + sendProposal: vi.fn(), + reviseProposal: vi.fn(), + returnToReview: vi.fn(), + generateSuggestions: vi.fn(), + getSimilar: vi.fn(), + getHistory: vi.fn(), + getAudit: vi.fn(), + getPdf: vi.fn(), + }, +})); + +// useSaveProposalWorkspace pulls lineItemsApi directly; mock it so no test +// path can reach axios. +vi.mock('../lineItems/api', () => ({ + lineItemsApi: { + getAll: vi.fn(), + create: vi.fn(), + bulkUpdate: vi.fn(), + delete: vi.fn(), + }, +})); + +vi.mock('react-toastify', () => ({ + toast: { success: vi.fn(), error: vi.fn(), warning: vi.fn(), info: vi.fn() }, +})); + +const PROPOSAL_ID = 'p-7'; + +beforeEach(() => { + vi.clearAllMocks(); +}); + +describe('useApproveProposal', () => { + it('invalidates the proposal detail and its line items keys and toasts on success', async () => { + vi.mocked(adminApi.approveProposal).mockResolvedValue(undefined); + const { queryClient, wrapper } = createQueryHarness(); + const invalidateSpy = vi.spyOn(queryClient, 'invalidateQueries'); + + const { result } = renderHook(() => useApproveProposal(PROPOSAL_ID), { wrapper }); + result.current.mutate(); + + await waitFor(() => expect(result.current.isSuccess).toBe(true)); + expect(adminApi.approveProposal).toHaveBeenCalledWith(PROPOSAL_ID); + // Approval changes proposal status AND locks/reprices line items — both + // domains' keys must be refetched (cross-domain invalidation, rule 3). + expect(invalidateSpy).toHaveBeenCalledWith({ + queryKey: proposalsKeys.detail(PROPOSAL_ID), + }); + expect(invalidateSpy).toHaveBeenCalledWith({ + queryKey: lineItemsKeys.byProposal(PROPOSAL_ID), + }); + expect(toast.success).toHaveBeenCalledWith('Proposal approved'); + }); + + it('toasts the failure and skips invalidation when approval is rejected', async () => { + vi.mocked(adminApi.approveProposal).mockRejectedValue( + new Error('proposal is not in review') + ); + const { queryClient, wrapper } = createQueryHarness(); + const invalidateSpy = vi.spyOn(queryClient, 'invalidateQueries'); + + const { result } = renderHook(() => useApproveProposal(PROPOSAL_ID), { wrapper }); + result.current.mutate(); + + await waitFor(() => expect(result.current.isError).toBe(true)); + expect(toast.error).toHaveBeenCalledWith('Approval failed: proposal is not in review'); + expect(invalidateSpy).not.toHaveBeenCalled(); + expect(toast.success).not.toHaveBeenCalled(); + }); +}); diff --git a/web/src/domain/__tests__/customers.use-cases.test.tsx b/web/src/domain/__tests__/customers.use-cases.test.tsx new file mode 100644 index 0000000..65b0e66 --- /dev/null +++ b/web/src/domain/__tests__/customers.use-cases.test.tsx @@ -0,0 +1,98 @@ +// Customers domain use-case hooks: create/update must invalidate the list +// key family (customersKeys.lists()) and toast. The domain api module is +// mocked — no axios traffic. +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 { customersApi } from '../customers/api'; +import type { Customer } from '../customers/types'; + +vi.mock('../customers/api', () => ({ + customersApi: { + search: vi.fn(), + list: vi.fn(), + create: vi.fn(), + update: vi.fn(), + }, +})); + +vi.mock('react-toastify', () => ({ + toast: { success: vi.fn(), error: vi.fn(), warning: vi.fn(), info: vi.fn() }, +})); + +const customer = { id: 'c-1', name: 'Acme Corp' } as unknown as Customer; + +beforeEach(() => { + vi.clearAllMocks(); +}); + +describe('customersKeys', () => { + it('list keys nest under lists() so one invalidation hits every page/filter', () => { + expect(customersKeys.all).toEqual(['customers']); + expect(customersKeys.lists()).toEqual(['customers', 'list']); + expect(customersKeys.list({ page: 3 })).toEqual(['customers', 'list', { page: 3 }]); + expect(customersKeys.search('acme')).toEqual(['customers', 'search', 'acme']); + }); +}); + +describe('useCreateCustomer', () => { + it('invalidates customersKeys.lists() and toasts on success', async () => { + vi.mocked(customersApi.create).mockResolvedValue(customer); + const { queryClient, wrapper } = createQueryHarness(); + const invalidateSpy = vi.spyOn(queryClient, 'invalidateQueries'); + + const { result } = renderHook(() => useCreateCustomer(), { wrapper }); + result.current.mutate({ name: 'Acme Corp' } as never); + + await waitFor(() => expect(result.current.isSuccess).toBe(true)); + expect(invalidateSpy).toHaveBeenCalledWith({ queryKey: customersKeys.lists() }); + expect(toast.success).toHaveBeenCalledWith('Customer created'); + }); + + it('toasts the failure and skips invalidation', async () => { + vi.mocked(customersApi.create).mockRejectedValue(new Error('duplicate name')); + const { queryClient, wrapper } = createQueryHarness(); + const invalidateSpy = vi.spyOn(queryClient, 'invalidateQueries'); + + const { result } = renderHook(() => useCreateCustomer(), { wrapper }); + result.current.mutate({ name: 'Acme Corp' } as never); + + await waitFor(() => expect(result.current.isError).toBe(true)); + expect(toast.error).toHaveBeenCalledWith('Failed to create customer: duplicate name'); + expect(invalidateSpy).not.toHaveBeenCalled(); + }); +}); + +describe('useUpdateCustomer', () => { + it('passes id + payload through and invalidates customersKeys.lists() on success', async () => { + vi.mocked(customersApi.update).mockResolvedValue(customer); + const { queryClient, wrapper } = createQueryHarness(); + const invalidateSpy = vi.spyOn(queryClient, 'invalidateQueries'); + + const { result } = renderHook(() => useUpdateCustomer(), { wrapper }); + const data = { name: 'Acme Corp (Renamed)' } as never; + result.current.mutate({ id: 'c-1', data }); + + await waitFor(() => expect(result.current.isSuccess).toBe(true)); + expect(customersApi.update).toHaveBeenCalledWith('c-1', data); + expect(invalidateSpy).toHaveBeenCalledWith({ queryKey: customersKeys.lists() }); + expect(toast.success).toHaveBeenCalledWith('Customer updated'); + }); + + it('toasts the failure on update error', async () => { + vi.mocked(customersApi.update).mockRejectedValue(new Error('not found')); + const { wrapper } = createQueryHarness(); + + const { result } = renderHook(() => useUpdateCustomer(), { wrapper }); + result.current.mutate({ id: 'c-404', data: { name: 'x' } as never }); + + await waitFor(() => expect(result.current.isError).toBe(true)); + expect(toast.error).toHaveBeenCalledWith('Failed to update customer: not found'); + }); +}); diff --git a/web/src/domain/__tests__/hookTestUtils.tsx b/web/src/domain/__tests__/hookTestUtils.tsx new file mode 100644 index 0000000..09a4044 --- /dev/null +++ b/web/src/domain/__tests__/hookTestUtils.tsx @@ -0,0 +1,18 @@ +// Shared harness for domain use-case hook tests: a fresh QueryClient per +// test (no retries, so error paths settle immediately) plus the provider +// wrapper renderHook needs. Not a test file — no .test suffix. +import type { ReactNode } from 'react'; +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; + +export function createQueryHarness() { + const queryClient = new QueryClient({ + defaultOptions: { + queries: { retry: false }, + mutations: { retry: false }, + }, + }); + const wrapper = ({ children }: { children: ReactNode }) => ( + {children} + ); + return { queryClient, wrapper }; +} diff --git a/web/src/domain/__tests__/lineItems.use-cases.test.tsx b/web/src/domain/__tests__/lineItems.use-cases.test.tsx new file mode 100644 index 0000000..6b32dfa --- /dev/null +++ b/web/src/domain/__tests__/lineItems.use-cases.test.tsx @@ -0,0 +1,74 @@ +// Line items domain use-case hooks: bulk update must invalidate BOTH the +// proposal's line-items key and the proposal detail key (totals change). +// The domain api module is mocked — no axios traffic. +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 { lineItemsKeys, useBulkUpdateLineItems } from '../lineItems/use-cases'; +import { proposalsKeys } from '../proposals/use-cases'; +import { lineItemsApi } from '../lineItems/api'; +import type { UpdateLineItemEntry } from '../lineItems/types'; + +vi.mock('../lineItems/api', () => ({ + lineItemsApi: { + getAll: vi.fn(), + create: vi.fn(), + bulkUpdate: vi.fn(), + delete: vi.fn(), + }, +})); + +vi.mock('react-toastify', () => ({ + toast: { success: vi.fn(), error: vi.fn(), warning: vi.fn(), info: vi.fn() }, +})); + +const PROPOSAL_ID = 'p-42'; +const entries = [ + { description: 'Labor', quantity: 2, unitPrice: 150 }, +] as unknown as UpdateLineItemEntry[]; + +beforeEach(() => { + vi.clearAllMocks(); +}); + +describe('lineItemsKeys', () => { + it('scopes the by-proposal key under ["lineItems"]', () => { + expect(lineItemsKeys.all).toEqual(['lineItems']); + expect(lineItemsKeys.byProposal(PROPOSAL_ID)).toEqual(['lineItems', PROPOSAL_ID]); + }); +}); + +describe('useBulkUpdateLineItems', () => { + it('sends the entries and invalidates the proposal line-items + detail keys on success', async () => { + vi.mocked(lineItemsApi.bulkUpdate).mockResolvedValue([]); + const { queryClient, wrapper } = createQueryHarness(); + const invalidateSpy = vi.spyOn(queryClient, 'invalidateQueries'); + + const { result } = renderHook(() => useBulkUpdateLineItems(PROPOSAL_ID), { wrapper }); + result.current.mutate(entries); + + await waitFor(() => expect(result.current.isSuccess).toBe(true)); + expect(lineItemsApi.bulkUpdate).toHaveBeenCalledWith(PROPOSAL_ID, entries); + expect(invalidateSpy).toHaveBeenCalledWith({ + queryKey: lineItemsKeys.byProposal(PROPOSAL_ID), + }); + // Totals live on the proposal detail — it must be refetched too. + expect(invalidateSpy).toHaveBeenCalledWith({ + queryKey: proposalsKeys.detail(PROPOSAL_ID), + }); + }); + + it('toasts a save failure and skips invalidation', async () => { + vi.mocked(lineItemsApi.bulkUpdate).mockRejectedValue(new Error('409 conflict')); + const { queryClient, wrapper } = createQueryHarness(); + const invalidateSpy = vi.spyOn(queryClient, 'invalidateQueries'); + + const { result } = renderHook(() => useBulkUpdateLineItems(PROPOSAL_ID), { wrapper }); + result.current.mutate(entries); + + await waitFor(() => expect(result.current.isError).toBe(true)); + expect(toast.error).toHaveBeenCalledWith('Save failed: 409 conflict'); + expect(invalidateSpy).not.toHaveBeenCalled(); + }); +}); diff --git a/web/src/domain/__tests__/proposals.use-cases.test.tsx b/web/src/domain/__tests__/proposals.use-cases.test.tsx new file mode 100644 index 0000000..cc57edf --- /dev/null +++ b/web/src/domain/__tests__/proposals.use-cases.test.tsx @@ -0,0 +1,126 @@ +// Proposals domain use-case hooks: query key shape, list caching under the +// domain key, and create-mutation invalidation/toast behavior. The domain +// api module is mocked — no axios traffic. +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 { proposalsApi } from '../proposals/api'; +import type { ProposalDetail, ProposalFilters, ProposalListItem, PagedResponse } from '../proposals/types'; + +vi.mock('../proposals/api', () => ({ + proposalsApi: { + create: vi.fn(), + getAll: vi.fn(), + getById: vi.fn(), + uploadAttachment: vi.fn(), + confirmUpload: vi.fn(), + getStats: vi.fn(), + getVendors: vi.fn(), + }, +})); + +vi.mock('react-toastify', () => ({ + toast: { success: vi.fn(), error: vi.fn(), warning: vi.fn(), info: vi.fn() }, +})); + +const proposal = { + id: 'p-1', + proposalNumber: 'PRO-0001', +} as unknown as ProposalDetail; + +const pagedList: PagedResponse = { + items: [], + page: 2, + pageSize: 25, + totalCount: 0, +} as unknown as PagedResponse; + +beforeEach(() => { + vi.clearAllMocks(); +}); + +describe('proposalsKeys', () => { + it('follows the hierarchical TanStack key convention rooted at ["proposals"]', () => { + expect(proposalsKeys.all).toEqual(['proposals']); + expect(proposalsKeys.lists()).toEqual(['proposals', 'list']); + expect(proposalsKeys.list({ page: 2, status: 'InReview' })).toEqual([ + 'proposals', + 'list', + { page: 2, status: 'InReview' }, + ]); + expect(proposalsKeys.detail('p-1')).toEqual(['proposals', 'p-1']); + expect(proposalsKeys.stats()).toEqual(['proposals', 'stats']); + expect(proposalsKeys.vendors('p-1')).toEqual(['proposals', 'p-1', 'vendors']); + }); +}); + +describe('useProposals', () => { + it('fetches with the given filters and caches under proposalsKeys.list(filters)', async () => { + const filters: ProposalFilters = { page: 2, pageSize: 25 }; + vi.mocked(proposalsApi.getAll).mockResolvedValue(pagedList); + const { queryClient, wrapper } = createQueryHarness(); + + const { result } = renderHook(() => useProposals(filters), { wrapper }); + + await waitFor(() => expect(result.current.isSuccess).toBe(true)); + expect(proposalsApi.getAll).toHaveBeenCalledWith(filters); + // The data must live under the domain's list key — pages and cross-domain + // invalidation both depend on this exact shape. + expect(queryClient.getQueryData(proposalsKeys.list(filters))).toBe(pagedList); + }); +}); + +describe('useCreateProposal', () => { + it('invalidates proposalsKeys.all and toasts on success', async () => { + vi.mocked(proposalsApi.create).mockResolvedValue(proposal); + const { queryClient, wrapper } = createQueryHarness(); + const invalidateSpy = vi.spyOn(queryClient, 'invalidateQueries'); + + const { result } = renderHook(() => useCreateProposal(), { wrapper }); + result.current.mutate({ request: {} as never }); + + await waitFor(() => expect(result.current.isSuccess).toBe(true)); + expect(invalidateSpy).toHaveBeenCalledWith({ queryKey: proposalsKeys.all }); + expect(toast.success).toHaveBeenCalledWith('Proposal PRO-0001 submitted'); + expect(toast.error).not.toHaveBeenCalled(); + }); + + it('toasts the error message and does not invalidate on failure', async () => { + vi.mocked(proposalsApi.create).mockRejectedValue(new Error('boom')); + const { queryClient, wrapper } = createQueryHarness(); + const invalidateSpy = vi.spyOn(queryClient, 'invalidateQueries'); + + const { result } = renderHook(() => useCreateProposal(), { wrapper }); + result.current.mutate({ request: {} as never }); + + await waitFor(() => expect(result.current.isError).toBe(true)); + expect(toast.error).toHaveBeenCalledWith('boom'); + expect(invalidateSpy).not.toHaveBeenCalled(); + }); + + it('still succeeds (warning toast, invalidation) when the vendor PDF upload fails', async () => { + vi.mocked(proposalsApi.create).mockResolvedValue(proposal); + vi.mocked(proposalsApi.uploadAttachment).mockRejectedValue(new Error('presign failed')); + const { queryClient, wrapper } = createQueryHarness(); + const invalidateSpy = vi.spyOn(queryClient, 'invalidateQueries'); + + const { result } = renderHook(() => useCreateProposal(), { wrapper }); + result.current.mutate({ + request: {} as never, + vendorFile: new File(['pdf'], 'vendor.pdf', { type: 'application/pdf' }), + }); + + await waitFor(() => expect(result.current.isSuccess).toBe(true)); + // A failed attachment must not fail the proposal creation. + expect(proposalsApi.confirmUpload).not.toHaveBeenCalled(); + expect(toast.warning).toHaveBeenCalledWith(expect.stringContaining('PRO-0001')); + expect(invalidateSpy).toHaveBeenCalledWith({ queryKey: proposalsKeys.all }); + expect(toast.success).toHaveBeenCalledWith('Proposal PRO-0001 submitted'); + }); +}); From 2e1099a56009dff72f2d6bc73d8b38a970de18cf Mon Sep 17 00:00:00 2001 From: Adam Moussa Date: Mon, 13 Jul 2026 18:11:05 -0400 Subject: [PATCH 09/10] =?UTF-8?q?refactor(web):=20finish=20domain-layer=20?= =?UTF-8?q?integration=20=E2=80=94=20migrate=20straggler=20components,=20d?= =?UTF-8?q?elete=20legacy=20api=20modules?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - SimilarProposalsPanel -> useSimilarProposals (domain/admin); inline SimilarProposal type replaced by domain/admin/types (identical shape); query key joins the admin hierarchical key space - LineItemEditor type imports -> domain/lineItems/types - Delete now-orphaned lib/api/{proposals,lineItems,customers, pricingLibrary,admin,sites}.ts, constants/queryKeys.ts, hooks/usePaginatedList.ts (lib/api/client.ts + auth.ts stay per domain README rule 5) Verify: tsc clean, vitest 52/52, build OK, Playwright smoke of the authed shell renders on domain hooks. --- web/src/components/admin/LineItemEditor.tsx | 2 +- .../admin/SimilarProposalsPanel.tsx | 24 +---- web/src/constants/queryKeys.ts | 11 --- web/src/hooks/usePaginatedList.ts | 76 --------------- web/src/lib/api/admin.ts | 63 ------------ web/src/lib/api/customers.ts | 51 ---------- web/src/lib/api/lineItems.ts | 32 ------ web/src/lib/api/pricingLibrary.ts | 46 --------- web/src/lib/api/proposals.ts | 97 ------------------- web/src/lib/api/sites.ts | 12 --- 10 files changed, 3 insertions(+), 411 deletions(-) delete mode 100644 web/src/constants/queryKeys.ts delete mode 100644 web/src/hooks/usePaginatedList.ts delete mode 100644 web/src/lib/api/admin.ts delete mode 100644 web/src/lib/api/customers.ts delete mode 100644 web/src/lib/api/lineItems.ts delete mode 100644 web/src/lib/api/pricingLibrary.ts delete mode 100644 web/src/lib/api/proposals.ts delete mode 100644 web/src/lib/api/sites.ts diff --git a/web/src/components/admin/LineItemEditor.tsx b/web/src/components/admin/LineItemEditor.tsx index 42e0674..0f7b7fb 100644 --- a/web/src/components/admin/LineItemEditor.tsx +++ b/web/src/components/admin/LineItemEditor.tsx @@ -19,7 +19,7 @@ import ContentCopyIcon from '@mui/icons-material/ContentCopy'; import DeleteIcon from '@mui/icons-material/Delete'; import ArrowUpwardIcon from '@mui/icons-material/ArrowUpward'; import ArrowDownwardIcon from '@mui/icons-material/ArrowDownward'; -import type { PricingMode, LineItemSource } from '../../lib/api/lineItems'; +import type { PricingMode, LineItemSource } from '../../domain/lineItems/types'; export interface EditableLineItem { id: string | null; diff --git a/web/src/components/admin/SimilarProposalsPanel.tsx b/web/src/components/admin/SimilarProposalsPanel.tsx index 6dfad4c..fec0149 100644 --- a/web/src/components/admin/SimilarProposalsPanel.tsx +++ b/web/src/components/admin/SimilarProposalsPanel.tsx @@ -1,4 +1,3 @@ -import { useQuery } from '@tanstack/react-query'; import { Box, Typography, @@ -11,25 +10,10 @@ import { } from '@mui/material'; import ExpandMoreIcon from '@mui/icons-material/ExpandMore'; import ContentCopyIcon from '@mui/icons-material/ContentCopy'; -import { adminApi } from '../../lib/api/admin'; +import { useSimilarProposals } from '../../domain/admin/use-cases'; import { formatCurrency } from '../../lib/format'; import type { EditableLineItem } from './LineItemEditor'; -interface SimilarProposal { - proposalNumber: string; - customerName: string; - serviceCategory: string; - similarityScore: number; - totalBidAmount: number; - lineItems: Array<{ - description: string; - quantity: number; - unit: string; - unitPrice: number | null; - totalPrice: number; - }>; -} - interface SimilarProposalsPanelProps { proposalId: string; onPullLineItem: (item: Omit) => void; @@ -37,11 +21,7 @@ interface SimilarProposalsPanelProps { } export default function SimilarProposalsPanel({ proposalId, onPullLineItem, disabled }: SimilarProposalsPanelProps) { - const { data: similar, isLoading } = useQuery({ - queryKey: ['similarProposals', proposalId], - queryFn: () => adminApi.getSimilar(proposalId) as Promise, - enabled: !!proposalId, - }); + const { data: similar, isLoading } = useSimilarProposals(proposalId); if (isLoading) { return ( diff --git a/web/src/constants/queryKeys.ts b/web/src/constants/queryKeys.ts deleted file mode 100644 index 943ec10..0000000 --- a/web/src/constants/queryKeys.ts +++ /dev/null @@ -1,11 +0,0 @@ -export const QUERY_KEYS = { - proposals: 'proposals', - proposal: 'proposal', - proposalLineItems: 'proposalLineItems', - customers: 'customers', - customerList: 'customerList', - users: 'users', - dashboard: 'dashboard', - auditTrail: 'auditTrail', - pricingLibraryList: 'pricingLibraryList', -} as const; diff --git a/web/src/hooks/usePaginatedList.ts b/web/src/hooks/usePaginatedList.ts deleted file mode 100644 index f560c0c..0000000 --- a/web/src/hooks/usePaginatedList.ts +++ /dev/null @@ -1,76 +0,0 @@ -import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; -import { DEBOUNCE_SEARCH, DEFAULT_PAGE, DEFAULT_PAGE_SIZE } from '../constants'; - -interface PaginatedResult { - items: T[]; - totalCount: number; -} - -type FetchFn = (params: Record) => Promise>; - -export function usePaginatedList(fetchFn: FetchFn, extraParams: Record = {}) { - const [rows, setRows] = useState([]); - const [search, setSearch] = useState(''); - const [debouncedSearch, setDebouncedSearch] = useState(''); - const [page, setPage] = useState(DEFAULT_PAGE); - const [pageSize, setPageSize] = useState(DEFAULT_PAGE_SIZE); - const [totalCount, setTotalCount] = useState(0); - const [loading, setLoading] = useState(true); - const [err, setErr] = useState(''); - const debounceRef = useRef | null>(null); - - const extraKey = JSON.stringify(extraParams); - - useEffect(() => { - if (debounceRef.current) clearTimeout(debounceRef.current); - debounceRef.current = setTimeout(() => { - setDebouncedSearch(search); - setPage(1); - }, DEBOUNCE_SEARCH); - return () => { - if (debounceRef.current) clearTimeout(debounceRef.current); - }; - }, [search]); - - const prevExtraKey = useRef(extraKey); - useEffect(() => { - if (prevExtraKey.current !== extraKey) { - prevExtraKey.current = extraKey; - setPage(1); - } - }, [extraKey]); - - const reload = useCallback(() => { - setLoading(true); - setErr(''); - fetchFn({ search: debouncedSearch, page, pageSize, ...extraParams }) - .then(({ items, totalCount }) => { - setRows(items); - setTotalCount(totalCount); - }) - .catch((e: Error) => setErr(e.message || 'Failed to load')) - .finally(() => setLoading(false)); - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [fetchFn, debouncedSearch, page, pageSize, extraKey]); - - useEffect(() => { - reload(); - }, [reload]); - - return useMemo( - () => ({ - rows, - search, - setSearch, - page, - setPage, - pageSize, - setPageSize, - totalCount, - loading, - err, - reload, - }), - [rows, search, page, pageSize, totalCount, loading, err, reload] - ); -} diff --git a/web/src/lib/api/admin.ts b/web/src/lib/api/admin.ts deleted file mode 100644 index 918524c..0000000 --- a/web/src/lib/api/admin.ts +++ /dev/null @@ -1,63 +0,0 @@ -import apiClient from './client'; -// Fix: WEB-M5 — types from the shared contracts package. -import type { ProposalDetail, UpdateProposalRequest, DashboardStats, AuditEntry } from '@proposal-system/api-contracts'; - -export type { DashboardStats, UpdateProposalRequest, AuditEntry } from '@proposal-system/api-contracts'; - -export const adminApi = { - getDashboard: async (): Promise => { - const res = await apiClient.get('/admin/dashboard'); - return res.data; - }, - - updateProposal: async (id: string, data: UpdateProposalRequest): Promise => { - await apiClient.put(`/proposals/${id}`, data); - }, - - approveProposal: async (id: string): Promise => { - await apiClient.post(`/proposals/${id}/approve`); - }, - - returnToReview: async (id: string): Promise => { - await apiClient.post(`/proposals/${id}/return-to-review`); - }, - - sendProposal: async (id: string): Promise => { - await apiClient.post(`/proposals/${id}/send`); - }, - - reviseProposal: async (id: string): Promise => { - await apiClient.post(`/proposals/${id}/revise`); - }, - - getHistory: async (id: string): Promise => { - const res = await apiClient.get(`/proposals/${id}/history`); - return res.data; - }, - - getAudit: async (id: string): Promise => { - const res = await apiClient.get(`/proposals/${id}/audit`); - return res.data; - }, - - generateSuggestions: async (id: string): Promise => { - await apiClient.post(`/proposals/${id}/generate-suggestions`); - }, - - getSimilar: async (id: string): Promise => { - const res = await apiClient.get(`/proposals/${id}/similar`); - return res.data; - }, - - getPdf: async (id: string, regenerate = false): Promise<{ downloadUrl: string } | null> => { - const params = regenerate ? '?regenerate=true' : ''; - const res = await apiClient.get(`/proposals/${id}/pdf${params}`, { - responseType: 'blob', - validateStatus: (status) => status < 500, - }); - if (res.status === 202) return null; - const blob = res.data as Blob; - const downloadUrl = URL.createObjectURL(blob); - return { downloadUrl }; - }, -}; diff --git a/web/src/lib/api/customers.ts b/web/src/lib/api/customers.ts deleted file mode 100644 index 61ee7ee..0000000 --- a/web/src/lib/api/customers.ts +++ /dev/null @@ -1,51 +0,0 @@ -import apiClient from './client'; -// Fix: WEB-M5 — types from the shared contracts package. Note: the API takes -// addresses: string[] (CustomerDtos.cs); the old local CreateCustomerRequest -// sent a singular `address` field the API silently dropped. -import type { - Customer, - CustomerListParams, - CreateCustomerRequest, - UpdateCustomerRequest, - PagedResponse, -} from '@proposal-system/api-contracts'; - -export type { - Customer, - CustomerListParams, - CreateCustomerRequest, - UpdateCustomerRequest, -} from '@proposal-system/api-contracts'; - -export type PagedCustomerResponse = PagedResponse; - -export const customersApi = { - /** GET /api/customers?query= — search autocomplete (unchanged). */ - search: async (query?: string): Promise => { - const params = query ? `?query=${encodeURIComponent(query)}` : ''; - const res = await apiClient.get(`/customers${params}`); - return res.data; - }, - - /** GET /api/customers/list?page=&pageSize= — paginated management list (admin/sysadmin). */ - list: async (params: CustomerListParams = {}): Promise => { - const qs = new URLSearchParams(); - if (params.page) qs.append('page', String(params.page)); - if (params.pageSize) qs.append('pageSize', String(params.pageSize)); - if (params.search) qs.append('search', params.search); - const res = await apiClient.get(`/customers/list?${qs.toString()}`); - return res.data; - }, - - /** POST /api/customers — create a new customer. */ - create: async (data: CreateCustomerRequest): Promise => { - const res = await apiClient.post('/customers', data); - return res.data; - }, - - /** PUT /api/customers/{id} — update an existing customer (admin/sysadmin). */ - update: async (id: string, data: UpdateCustomerRequest): Promise => { - const res = await apiClient.put(`/customers/${id}`, data); - return res.data; - }, -}; diff --git a/web/src/lib/api/lineItems.ts b/web/src/lib/api/lineItems.ts deleted file mode 100644 index 95f4eb8..0000000 --- a/web/src/lib/api/lineItems.ts +++ /dev/null @@ -1,32 +0,0 @@ -import apiClient from './client'; -// Fix: WEB-M5 — types from the shared contracts package. -import type { LineItem, CreateLineItemRequest, UpdateLineItemEntry } from '@proposal-system/api-contracts'; - -export type { - PricingMode, - LineItemSource, - LineItem, - CreateLineItemRequest, - UpdateLineItemEntry, -} from '@proposal-system/api-contracts'; - -export const lineItemsApi = { - getAll: async (proposalId: string): Promise => { - const res = await apiClient.get(`/proposals/${proposalId}/line-items`); - return res.data; - }, - - create: async (proposalId: string, data: CreateLineItemRequest): Promise => { - const res = await apiClient.post(`/proposals/${proposalId}/line-items`, data); - return res.data; - }, - - bulkUpdate: async (proposalId: string, lineItems: UpdateLineItemEntry[]): Promise => { - const res = await apiClient.put(`/proposals/${proposalId}/line-items`, { lineItems }); - return res.data; - }, - - delete: async (proposalId: string, itemId: string): Promise => { - await apiClient.delete(`/proposals/${proposalId}/line-items/${itemId}`); - }, -}; diff --git a/web/src/lib/api/pricingLibrary.ts b/web/src/lib/api/pricingLibrary.ts deleted file mode 100644 index a0b4bb0..0000000 --- a/web/src/lib/api/pricingLibrary.ts +++ /dev/null @@ -1,46 +0,0 @@ -import apiClient from './client'; -// Fix: WEB-M5 — types from the shared contracts package. -import type { - PricingLibraryItem, - PricingLibraryListParams, - CreatePricingLibraryItemRequest, - UpdatePricingLibraryItemRequest, - PagedResponse, -} from '@proposal-system/api-contracts'; - -export type { - PricingLibraryItem, - PricingLibraryListParams, - CreatePricingLibraryItemRequest, - UpdatePricingLibraryItemRequest, -} from '@proposal-system/api-contracts'; - -export type PagedPricingLibraryResponse = PagedResponse; - -export const pricingLibraryApi = { - /** GET /api/pricing-library/list?page=&pageSize= — paginated list (admin/sysadmin). */ - list: async (params: PricingLibraryListParams = {}): Promise => { - const qs = new URLSearchParams(); - if (params.page) qs.append('page', String(params.page)); - if (params.pageSize) qs.append('pageSize', String(params.pageSize)); - const res = await apiClient.get(`/pricing-library/list?${qs.toString()}`); - return res.data; - }, - - /** POST /api/pricing-library — create a new pricing library item. */ - create: async (data: CreatePricingLibraryItemRequest): Promise => { - const res = await apiClient.post('/pricing-library', data); - return res.data; - }, - - /** PUT /api/pricing-library/{id} — update an existing pricing library item. */ - update: async (id: string, data: UpdatePricingLibraryItemRequest): Promise => { - const res = await apiClient.put(`/pricing-library/${id}`, data); - return res.data; - }, - - /** DELETE /api/pricing-library/{id} — delete a pricing library item. */ - remove: async (id: string): Promise => { - await apiClient.delete(`/pricing-library/${id}`); - }, -}; diff --git a/web/src/lib/api/proposals.ts b/web/src/lib/api/proposals.ts deleted file mode 100644 index 024d1d8..0000000 --- a/web/src/lib/api/proposals.ts +++ /dev/null @@ -1,97 +0,0 @@ -import apiClient from './client'; -// Fix: WEB-M5 — types now come from the shared contracts package (single source -// of truth aligned with the .NET DTOs); re-exported so page imports stay stable. -import type { - CreateProposalRequest, - ProposalDetail, - ProposalListItem, - ProposalFilters, - ProposalStats, - PagedResponse, - PdfVersion, - PresignedUpload, -} from '@proposal-system/api-contracts'; - -export type { - ServiceCategory, - Priority, - CreateProposalRequest, - ProposalDetail, - ProposalListItem, - ProposalFilters, - ProposalStats, - PagedResponse, - PdfVersion, - PresignedUpload, -} from '@proposal-system/api-contracts'; - -export const proposalsApi = { - create: async (data: CreateProposalRequest): Promise => { - const res = await apiClient.post('/proposals', data); - return res.data; - }, - - getAll: async (filters: ProposalFilters = {}): Promise> => { - const params = new URLSearchParams(); - if (filters.search) params.append('search', filters.search); - if (filters.page) params.append('page', String(filters.page)); - if (filters.pageSize) params.append('pageSize', String(filters.pageSize)); - if (filters.status) params.append('status', filters.status); - if (filters.serviceCategory) params.append('serviceCategory', filters.serviceCategory); - if (filters.priority) params.append('priority', filters.priority); - if (filters.fromDate) params.append('fromDate', filters.fromDate); - if (filters.toDate) params.append('toDate', filters.toDate); - if (filters.mine) params.append('mine', 'true'); - - const res = await apiClient.get(`/proposals?${params.toString()}`); - return res.data; - }, - - getById: async (id: string): Promise => { - const res = await apiClient.get(`/proposals/${id}`); - return res.data; - }, - - uploadAttachment: async (proposalId: string, fileName: string, vendorName?: string): Promise => { - const params = new URLSearchParams({ fileName }); - if (vendorName) params.append('vendorName', vendorName); - const res = await apiClient.post(`/proposals/${proposalId}/attachments?${params.toString()}`); - return res.data; - }, - - confirmUpload: async (proposalId: string, vendorProposalId: string): Promise => { - await apiClient.post(`/proposals/${proposalId}/attachments/${vendorProposalId}/confirm`); - }, - - getStats: async (): Promise => { - const res = await apiClient.get('/proposals/stats'); - return res.data; - }, - - getPdf: async (id: string): Promise<{ downloadUrl: string } | null> => { - const res = await apiClient.get(`/proposals/${id}/pdf`, { - responseType: 'blob', - validateStatus: (status) => status < 500, - }); - if (res.status === 404) return null; - const blob = res.data as Blob; - const downloadUrl = URL.createObjectURL(blob); - return { downloadUrl }; - }, - - getPdfVersions: async (id: string): Promise => { - const res = await apiClient.get(`/proposals/${id}/pdf/versions`); - return res.data; - }, - - getPdfRevision: async (id: string, revision: number): Promise<{ downloadUrl: string } | null> => { - const res = await apiClient.get(`/proposals/${id}/pdf/${revision}`, { - responseType: 'blob', - validateStatus: (status) => status < 500, - }); - if (res.status === 404) return null; - const blob = res.data as Blob; - const downloadUrl = URL.createObjectURL(blob); - return { downloadUrl }; - }, -}; diff --git a/web/src/lib/api/sites.ts b/web/src/lib/api/sites.ts deleted file mode 100644 index eb0b8a8..0000000 --- a/web/src/lib/api/sites.ts +++ /dev/null @@ -1,12 +0,0 @@ -import apiClient from './client'; -// Fix: WEB-M5 — types from the shared contracts package. -import type { Site } from '@proposal-system/api-contracts'; - -export type { Site } from '@proposal-system/api-contracts'; - -export const sitesApi = { - search: async (query: string): Promise => { - const res = await apiClient.get(`/sites?query=${encodeURIComponent(query)}`); - return res.data; - }, -}; From 1f55a5944592631b22bc79c1e56f9e7d793c282d Mon Sep 17 00:00:00 2001 From: Adam Moussa Date: Mon, 13 Jul 2026 19:06:08 -0400 Subject: [PATCH 10/10] fix(web): apply Phase 4 code-review findings (10 correctness + 4 cleanup) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Correctness: - State-transition mutations now invalidate every cached view via invalidateProposalViews (detail + line items + lists + stats + admin dashboard) — approving no longer leaves a stale queue for the 5-minute staleTime - Presigned S3 PUT moved to proposals/api.ts with res.ok check — a rejected upload is no longer confirmed as uploaded - toCustomerRequest always sends contactEmail ('' clears); API create path normalizes empty->null to match the update path — customer emails can now be cleared from the UI - Shared Number-based numeric form fields (domain/shared/formFields): '12abc' no longer silently coerces to 12 in the pricing library - Customer create/update invalidate customersKeys.all so cached search autocompletes see new customers - AdminWorkspace clears dirty right after a successful implicit save, before approve — no false unsaved-changes prompt when approve fails - ProposalFormPage submit gate and missing-fields caption derive from ONE checks list (missing customer is now listed) - Empty states gated on !err in ProposalListPage/AdminDashboard — no contradictory error + 'no proposals' UI - VendorDataPanel migrated to useVendorProposals (kills the divergent ['vendorProposals', id] cache key and the inline apiClient query) - useCustomerList/usePricingLibraryList get keepPreviousData — no TablePagination out-of-range flash on page change Cleanup: - Dead speculative hooks removed (useCreate/BulkUpdate/DeleteLineItem, useUpdateProposal, useProposalHistory/Audit, lineItemRowFormSchema, toUpdateLineItemEntry); tests moved to the live save path (useSaveProposalWorkspace) - Shared useDebouncedValue hook replaces 4 drifted inline debounce copies (one leaked its timer on unmount, two hardcoded 300ms); DEBOUNCE_AUTOCOMPLETE=300 named - Fix: WEB-H5 / WEB-H6 finding-ID markers restored at the relocated onError handlers (CLAUDE.md traceability) - shared/api-contracts gains an exports map; /schemas resolver alias deduplicated from 3 copies to the tsconfig paths mapping Verify: tsc clean, vitest 51/51 (tests updated to pin the new invalidation/mapper behavior + new '12abc' rejection test), vite build OK, dotnet 166/166. --- .../Services/CustomerService.cs | 4 +- shared/api-contracts/package.json | 10 +++ web/src/components/admin/VendorDataPanel.tsx | 28 +------- web/src/constants/index.ts | 1 + .../domain/__tests__/admin.use-cases.test.tsx | 12 ++++ .../__tests__/customers.use-cases.test.tsx | 11 +-- .../__tests__/lineItems.use-cases.test.tsx | 50 +++++++++---- web/src/domain/__tests__/schemas.test.ts | 54 +++++--------- web/src/domain/admin/use-cases.ts | 66 +++++++---------- web/src/domain/customers/schemas.ts | 8 ++- web/src/domain/customers/use-cases.ts | 17 +++-- web/src/domain/lineItems/schemas.ts | 56 +-------------- web/src/domain/lineItems/use-cases.ts | 51 +------------- web/src/domain/pricingLibrary/schemas.ts | 11 ++- web/src/domain/pricingLibrary/use-cases.ts | 7 +- web/src/domain/proposals/api.ts | 17 +++++ web/src/domain/proposals/use-cases.ts | 7 +- web/src/domain/shared/formFields.ts | 23 ++++++ web/src/hooks/useDebouncedValue.ts | 32 +++++++++ .../customers/CustomerManagementPage.tsx | 18 ++--- .../pages/admin/dashboard/AdminDashboard.tsx | 15 ++-- .../pages/admin/workspace/AdminWorkspace.tsx | 5 +- .../pages/proposals/form/ProposalFormPage.tsx | 70 +++++++++---------- .../pages/proposals/list/ProposalListPage.tsx | 20 ++---- web/tsconfig.json | 2 +- web/vite.config.ts | 12 +--- web/vitest.config.ts | 13 ++-- 27 files changed, 283 insertions(+), 337 deletions(-) create mode 100644 web/src/domain/shared/formFields.ts create mode 100644 web/src/hooks/useDebouncedValue.ts diff --git a/api/src/ProposalSystem.Infrastructure/Services/CustomerService.cs b/api/src/ProposalSystem.Infrastructure/Services/CustomerService.cs index a617206..451688e 100644 --- a/api/src/ProposalSystem.Infrastructure/Services/CustomerService.cs +++ b/api/src/ProposalSystem.Infrastructure/Services/CustomerService.cs @@ -65,7 +65,9 @@ public class CustomerService : ICustomerService Id = Guid.NewGuid(), Name = request.Name, Addresses = request.Addresses != null ? JsonSerializer.Serialize(request.Addresses) : null, - ContactEmail = request.ContactEmail, + // Normalize empty to null, matching the update path — clients may + // send contactEmail: "" (the web form always includes the field). + ContactEmail = string.IsNullOrEmpty(request.ContactEmail) ? null : request.ContactEmail, CreatedAt = now, UpdatedAt = now, }; diff --git a/shared/api-contracts/package.json b/shared/api-contracts/package.json index 59f22a7..7bebfa2 100644 --- a/shared/api-contracts/package.json +++ b/shared/api-contracts/package.json @@ -4,6 +4,16 @@ "private": true, "main": "./src/index.ts", "types": "./src/index.ts", + "exports": { + ".": { + "types": "./src/index.ts", + "default": "./src/index.ts" + }, + "./schemas": { + "types": "./src/schemas.ts", + "default": "./src/schemas.ts" + } + }, "scripts": { "typecheck": "tsc --noEmit" }, diff --git a/web/src/components/admin/VendorDataPanel.tsx b/web/src/components/admin/VendorDataPanel.tsx index f6b662c..a3e956d 100644 --- a/web/src/components/admin/VendorDataPanel.tsx +++ b/web/src/components/admin/VendorDataPanel.tsx @@ -1,37 +1,13 @@ -import { useQuery } from '@tanstack/react-query'; import { Box, Typography, Skeleton, Chip } from '@mui/material'; -import apiClient from '../../lib/api/client'; +import { useVendorProposals } from '../../domain/proposals/use-cases'; import { formatCurrency } from '../../lib/format'; -interface VendorProposal { - id: string; - vendorName: string; - fileName: string; - totalVendorCost: number; - processingStatus: string; - extractedData: { - lineItems?: Array<{ - description: string; - quantity?: number; - unitPrice?: number; - total?: number; - }>; - } | null; -} - interface VendorDataPanelProps { proposalId: string; } export default function VendorDataPanel({ proposalId }: VendorDataPanelProps) { - const { data: vendors, isLoading } = useQuery({ - queryKey: ['vendorProposals', proposalId], - queryFn: async () => { - const res = await apiClient.get(`/proposals/${proposalId}/vendors`); - return res.data; - }, - enabled: !!proposalId, - }); + const { data: vendors, isLoading } = useVendorProposals(proposalId); if (isLoading) { return ; diff --git a/web/src/constants/index.ts b/web/src/constants/index.ts index 89b2b60..aed79d1 100644 --- a/web/src/constants/index.ts +++ b/web/src/constants/index.ts @@ -12,6 +12,7 @@ export const DEFAULT_PAGE_SIZE = 12; export const DEFAULT_PAGE = 1; export const DEBOUNCE_SEARCH = 600; +export const DEBOUNCE_AUTOCOMPLETE = 300; // typeahead fields respond faster than list search export const STORAGE_KEY_TOKEN = 'proposal_system_token'; export const STORAGE_KEY_SIDEBAR = 'sidebarOpen'; diff --git a/web/src/domain/__tests__/admin.use-cases.test.tsx b/web/src/domain/__tests__/admin.use-cases.test.tsx index 617ee08..fc734f6 100644 --- a/web/src/domain/__tests__/admin.use-cases.test.tsx +++ b/web/src/domain/__tests__/admin.use-cases.test.tsx @@ -66,6 +66,18 @@ describe('useApproveProposal', () => { expect(invalidateSpy).toHaveBeenCalledWith({ queryKey: lineItemsKeys.byProposal(PROPOSAL_ID), }); + // ...AND every cached view of the queue: lists/stats/KPIs sit under the + // global 5-minute staleTime, so without these the admin returns to a + // stale queue after approving (code-review finding, Phase 4). + expect(invalidateSpy).toHaveBeenCalledWith({ + queryKey: proposalsKeys.lists(), + }); + expect(invalidateSpy).toHaveBeenCalledWith({ + queryKey: proposalsKeys.stats(), + }); + expect(invalidateSpy).toHaveBeenCalledWith({ + queryKey: ['admin', 'dashboard'], + }); expect(toast.success).toHaveBeenCalledWith('Proposal approved'); }); diff --git a/web/src/domain/__tests__/customers.use-cases.test.tsx b/web/src/domain/__tests__/customers.use-cases.test.tsx index 65b0e66..059be69 100644 --- a/web/src/domain/__tests__/customers.use-cases.test.tsx +++ b/web/src/domain/__tests__/customers.use-cases.test.tsx @@ -1,5 +1,6 @@ // Customers domain use-case hooks: create/update must invalidate the list -// key family (customersKeys.lists()) and toast. The domain api module is +// whole domain key family (customersKeys.all — lists AND cached search +// autocompletes; code-review finding, Phase 4) and toast. The domain api module is // mocked — no axios traffic. import { describe, it, expect, vi, beforeEach } from 'vitest'; import { renderHook, waitFor } from '@testing-library/react'; @@ -42,7 +43,7 @@ describe('customersKeys', () => { }); describe('useCreateCustomer', () => { - it('invalidates customersKeys.lists() and toasts on success', async () => { + it('invalidates the whole customers domain (lists + search caches) and toasts on success', async () => { vi.mocked(customersApi.create).mockResolvedValue(customer); const { queryClient, wrapper } = createQueryHarness(); const invalidateSpy = vi.spyOn(queryClient, 'invalidateQueries'); @@ -51,7 +52,7 @@ describe('useCreateCustomer', () => { result.current.mutate({ name: 'Acme Corp' } as never); await waitFor(() => expect(result.current.isSuccess).toBe(true)); - expect(invalidateSpy).toHaveBeenCalledWith({ queryKey: customersKeys.lists() }); + expect(invalidateSpy).toHaveBeenCalledWith({ queryKey: customersKeys.all }); expect(toast.success).toHaveBeenCalledWith('Customer created'); }); @@ -70,7 +71,7 @@ describe('useCreateCustomer', () => { }); describe('useUpdateCustomer', () => { - it('passes id + payload through and invalidates customersKeys.lists() on success', async () => { + it('passes id + payload through and invalidates the whole customers domain on success', async () => { vi.mocked(customersApi.update).mockResolvedValue(customer); const { queryClient, wrapper } = createQueryHarness(); const invalidateSpy = vi.spyOn(queryClient, 'invalidateQueries'); @@ -81,7 +82,7 @@ describe('useUpdateCustomer', () => { await waitFor(() => expect(result.current.isSuccess).toBe(true)); expect(customersApi.update).toHaveBeenCalledWith('c-1', data); - expect(invalidateSpy).toHaveBeenCalledWith({ queryKey: customersKeys.lists() }); + expect(invalidateSpy).toHaveBeenCalledWith({ queryKey: customersKeys.all }); expect(toast.success).toHaveBeenCalledWith('Customer updated'); }); diff --git a/web/src/domain/__tests__/lineItems.use-cases.test.tsx b/web/src/domain/__tests__/lineItems.use-cases.test.tsx index 6b32dfa..9cde471 100644 --- a/web/src/domain/__tests__/lineItems.use-cases.test.tsx +++ b/web/src/domain/__tests__/lineItems.use-cases.test.tsx @@ -1,15 +1,34 @@ -// Line items domain use-case hooks: bulk update must invalidate BOTH the -// proposal's line-items key and the proposal detail key (totals change). -// The domain api module is mocked — no axios traffic. +// Line-item persistence flows through useSaveProposalWorkspace (admin +// domain) — the live workspace save path: update proposal, bulk-replace line +// items, broad-invalidate every cached view. The domain api modules are +// mocked — no axios traffic. 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 { lineItemsKeys, useBulkUpdateLineItems } from '../lineItems/use-cases'; +import { lineItemsKeys } from '../lineItems/use-cases'; +import { useSaveProposalWorkspace } from '../admin/use-cases'; import { proposalsKeys } from '../proposals/use-cases'; +import { adminApi } from '../admin/api'; import { lineItemsApi } from '../lineItems/api'; import type { UpdateLineItemEntry } from '../lineItems/types'; +vi.mock('../admin/api', () => ({ + adminApi: { + getDashboard: vi.fn(), + updateProposal: vi.fn(), + approveProposal: vi.fn(), + sendProposal: vi.fn(), + reviseProposal: vi.fn(), + returnToReview: vi.fn(), + generateSuggestions: vi.fn(), + getSimilar: vi.fn(), + getHistory: vi.fn(), + getAudit: vi.fn(), + getPdf: vi.fn(), + }, +})); + vi.mock('../lineItems/api', () => ({ lineItemsApi: { getAll: vi.fn(), @@ -39,36 +58,43 @@ describe('lineItemsKeys', () => { }); }); -describe('useBulkUpdateLineItems', () => { - it('sends the entries and invalidates the proposal line-items + detail keys on success', async () => { +describe('useSaveProposalWorkspace', () => { + it('persists scope + entries and invalidates line items, detail, and list/stats views', async () => { + vi.mocked(adminApi.updateProposal).mockResolvedValue(undefined); vi.mocked(lineItemsApi.bulkUpdate).mockResolvedValue([]); const { queryClient, wrapper } = createQueryHarness(); const invalidateSpy = vi.spyOn(queryClient, 'invalidateQueries'); - const { result } = renderHook(() => useBulkUpdateLineItems(PROPOSAL_ID), { wrapper }); - result.current.mutate(entries); + const { result } = renderHook(() => useSaveProposalWorkspace(PROPOSAL_ID), { wrapper }); + result.current.mutate({ refinedScope: 'refined', lineItems: entries }); await waitFor(() => expect(result.current.isSuccess).toBe(true)); + expect(adminApi.updateProposal).toHaveBeenCalledWith(PROPOSAL_ID, { refinedScope: 'refined' }); expect(lineItemsApi.bulkUpdate).toHaveBeenCalledWith(PROPOSAL_ID, entries); expect(invalidateSpy).toHaveBeenCalledWith({ queryKey: lineItemsKeys.byProposal(PROPOSAL_ID), }); - // Totals live on the proposal detail — it must be refetched too. + // Totals live on the proposal detail AND the cached queue/list views. expect(invalidateSpy).toHaveBeenCalledWith({ queryKey: proposalsKeys.detail(PROPOSAL_ID), }); + expect(invalidateSpy).toHaveBeenCalledWith({ + queryKey: proposalsKeys.lists(), + }); + expect(toast.success).toHaveBeenCalledWith('Changes saved'); }); it('toasts a save failure and skips invalidation', async () => { - vi.mocked(lineItemsApi.bulkUpdate).mockRejectedValue(new Error('409 conflict')); + vi.mocked(adminApi.updateProposal).mockRejectedValue(new Error('409 conflict')); const { queryClient, wrapper } = createQueryHarness(); const invalidateSpy = vi.spyOn(queryClient, 'invalidateQueries'); - const { result } = renderHook(() => useBulkUpdateLineItems(PROPOSAL_ID), { wrapper }); - result.current.mutate(entries); + const { result } = renderHook(() => useSaveProposalWorkspace(PROPOSAL_ID), { wrapper }); + result.current.mutate({ refinedScope: 'refined', lineItems: entries }); await waitFor(() => expect(result.current.isError).toBe(true)); expect(toast.error).toHaveBeenCalledWith('Save failed: 409 conflict'); + expect(lineItemsApi.bulkUpdate).not.toHaveBeenCalled(); expect(invalidateSpy).not.toHaveBeenCalled(); }); }); diff --git a/web/src/domain/__tests__/schemas.test.ts b/web/src/domain/__tests__/schemas.test.ts index ff344ba..6497fe1 100644 --- a/web/src/domain/__tests__/schemas.test.ts +++ b/web/src/domain/__tests__/schemas.test.ts @@ -13,7 +13,6 @@ import { pricingLibraryItemFormSchema, toPricingLibraryItemRequest, } from '../pricingLibrary/schemas'; -import { lineItemRowFormSchema, toUpdateLineItemEntry } from '../lineItems/schemas'; describe('createProposalFormSchema', () => { const valid = { @@ -85,7 +84,9 @@ describe('customerFormSchema', () => { { name: ' Acme ', address: ' 2 New St ', contactEmail: '' }, ['1 Old St', '3 Extra Ave'] ); - expect(request).toEqual({ name: 'Acme', addresses: ['2 New 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: '' }); }); }); @@ -118,40 +119,19 @@ describe('pricingLibraryItemFormSchema', () => { }); }); -describe('lineItemRowFormSchema', () => { - const valid = { - description: 'Install thermostat', - unit: 'each', - pricingMode: 'UnitPrice' as const, - quantity: '2', - unitPrice: '150', - totalPrice: '300', - }; - - it('accepts numeric strings and an empty unitPrice', () => { - expect(lineItemRowFormSchema.safeParse(valid).success).toBe(true); - expect(lineItemRowFormSchema.safeParse({ ...valid, unitPrice: '' }).success).toBe(true); - expect(lineItemRowFormSchema.safeParse({ ...valid, quantity: '' }).success).toBe(false); - expect(lineItemRowFormSchema.safeParse({ ...valid, totalPrice: '-1' }).success).toBe(false); - }); - - it('maps to the wire entry with editor-managed identity', () => { - const entry = toUpdateLineItemEntry(valid, { id: null, sortOrder: 3, source: 'Manual' }); - expect(entry).toEqual({ - id: null, - description: 'Install thermostat', - quantity: 2, - unit: 'each', - unitPrice: 150, - totalPrice: 300, - pricingMode: 'UnitPrice', - sortOrder: 3, - source: 'Manual', - }); - const noUnitPrice = toUpdateLineItemEntry( - { ...valid, unitPrice: '' }, - { id: 'li-1', sortOrder: 0, source: 'AI' } - ); - expect(noUnitPrice.unitPrice).toBeNull(); +describe('shared numeric form fields', () => { + it('rejects trailing garbage that parseFloat would silently coerce', () => { + // Code-review finding (Phase 4): '12abc' passed the old parseFloat-based + // pricing refine as 12; the shared Number-based field must reject it. + expect( + pricingLibraryItemFormSchema.safeParse({ + title: 'Coil cleaning', + serviceCategory: 'HVAC', + unit: '', + unitPrice: '12abc', + keywords: '', + description: '', + }).success + ).toBe(false); }); }); diff --git a/web/src/domain/admin/use-cases.ts b/web/src/domain/admin/use-cases.ts index dadadb6..9c0f8d7 100644 --- a/web/src/domain/admin/use-cases.ts +++ b/web/src/domain/admin/use-cases.ts @@ -4,23 +4,35 @@ // the same toasts the AdminWorkspace page shows today. Page-specific side // effects (dialog close, dirty reset, window.open, navigate) stay at the // call site via the mutation's callbacks. -import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; +import { useMutation, useQuery, useQueryClient, type QueryClient } from '@tanstack/react-query'; import { toast } from 'react-toastify'; import { adminApi } from './api'; import { lineItemsApi } from '../lineItems/api'; import { proposalsKeys } from '../proposals/use-cases'; import { lineItemsKeys } from '../lineItems/use-cases'; -import type { UpdateProposalRequest } from './types'; import type { UpdateLineItemEntry } from '../lineItems/types'; export const adminKeys = { all: ['admin'] as const, dashboard: () => [...adminKeys.all, 'dashboard'] as const, similar: (proposalId: string) => [...adminKeys.all, 'similar', proposalId] as const, - history: (proposalId: string) => [...adminKeys.all, 'history', proposalId] as const, - audit: (proposalId: string) => [...adminKeys.all, 'audit', proposalId] as const, }; +/** + * Every state transition must refresh every view of the proposal: the + * workspace (detail + line items) AND the cached queue/list/stats/KPI + * queries — lists sit under the global 5-minute staleTime, so without the + * broad invalidation an admin returning to the queue after approving sees + * stale statuses. + */ +function invalidateProposalViews(queryClient: QueryClient, proposalId: string) { + queryClient.invalidateQueries({ queryKey: proposalsKeys.detail(proposalId) }); + queryClient.invalidateQueries({ queryKey: lineItemsKeys.byProposal(proposalId) }); + queryClient.invalidateQueries({ queryKey: proposalsKeys.lists() }); + queryClient.invalidateQueries({ queryKey: proposalsKeys.stats() }); + queryClient.invalidateQueries({ queryKey: adminKeys.dashboard() }); +} + /** Admin dashboard KPIs (AdminDashboard). */ export function useAdminDashboard() { return useQuery({ @@ -38,22 +50,6 @@ export function useSimilarProposals(proposalId: string | undefined) { }); } -export function useProposalHistory(proposalId: string | undefined) { - return useQuery({ - queryKey: adminKeys.history(proposalId ?? ''), - queryFn: () => adminApi.getHistory(proposalId!), - enabled: !!proposalId, - }); -} - -export function useProposalAudit(proposalId: string | undefined) { - return useQuery({ - queryKey: adminKeys.audit(proposalId ?? ''), - queryFn: () => adminApi.getAudit(proposalId!), - enabled: !!proposalId, - }); -} - export interface SaveWorkspaceVariables { refinedScope: string; lineItems: UpdateLineItemEntry[]; @@ -71,8 +67,7 @@ export function useSaveProposalWorkspace(proposalId: string) { await lineItemsApi.bulkUpdate(proposalId, lineItems); }, onSuccess: () => { - queryClient.invalidateQueries({ queryKey: lineItemsKeys.byProposal(proposalId) }); - queryClient.invalidateQueries({ queryKey: proposalsKeys.detail(proposalId) }); + invalidateProposalViews(queryClient, proposalId); toast.success('Changes saved'); }, onError: (error: Error) => { @@ -81,26 +76,12 @@ export function useSaveProposalWorkspace(proposalId: string) { }); } -export function useUpdateProposal(proposalId: string) { - const queryClient = useQueryClient(); - return useMutation({ - mutationFn: (data: UpdateProposalRequest) => adminApi.updateProposal(proposalId, data), - onSuccess: () => { - queryClient.invalidateQueries({ queryKey: proposalsKeys.detail(proposalId) }); - }, - onError: (error: Error) => { - toast.error(`Save failed: ${error.message}`); - }, - }); -} - export function useApproveProposal(proposalId: string) { const queryClient = useQueryClient(); return useMutation({ mutationFn: () => adminApi.approveProposal(proposalId), onSuccess: () => { - queryClient.invalidateQueries({ queryKey: proposalsKeys.detail(proposalId) }); - queryClient.invalidateQueries({ queryKey: lineItemsKeys.byProposal(proposalId) }); + invalidateProposalViews(queryClient, proposalId); toast.success('Proposal approved'); }, onError: (error: Error) => { @@ -114,9 +95,11 @@ export function useSendProposal(proposalId: string) { return useMutation({ mutationFn: () => adminApi.sendProposal(proposalId), onSuccess: () => { - queryClient.invalidateQueries({ queryKey: proposalsKeys.detail(proposalId) }); + invalidateProposalViews(queryClient, proposalId); toast.success('Proposal marked as sent'); }, + // Fix: WEB-H5 — mutation must surface failures to the user (relocated + // from AdminWorkspace during the domain-layer refactor) onError: (error: Error) => { toast.error(`Send failed: ${error.message}`); }, @@ -128,9 +111,11 @@ export function useReviseProposal(proposalId: string) { return useMutation({ mutationFn: () => adminApi.reviseProposal(proposalId), onSuccess: () => { - queryClient.invalidateQueries({ queryKey: proposalsKeys.detail(proposalId) }); + invalidateProposalViews(queryClient, proposalId); toast.success('Revision created'); }, + // Fix: WEB-H6 — mutation must surface failures to the user (relocated + // from AdminWorkspace during the domain-layer refactor) onError: (error: Error) => { toast.error(`Revision failed: ${error.message}`); }, @@ -142,8 +127,7 @@ export function useReturnToReview(proposalId: string) { return useMutation({ mutationFn: () => adminApi.returnToReview(proposalId), onSuccess: () => { - queryClient.invalidateQueries({ queryKey: proposalsKeys.detail(proposalId) }); - queryClient.invalidateQueries({ queryKey: lineItemsKeys.byProposal(proposalId) }); + invalidateProposalViews(queryClient, proposalId); toast.success('Proposal returned to review'); }, onError: (error: Error) => { diff --git a/web/src/domain/customers/schemas.ts b/web/src/domain/customers/schemas.ts index fe5f233..e7c2ab0 100644 --- a/web/src/domain/customers/schemas.ts +++ b/web/src/domain/customers/schemas.ts @@ -33,8 +33,10 @@ export type CustomerFormValues = z.infer; /** * Map validated form values to the create/update request. The form edits the - * primary address; any additional existing addresses are preserved - * (mirrors the current CustomerManagementPage submit mapping). + * primary address; any additional existing addresses are preserved. + * contactEmail is always sent: the API clears the stored email on empty + * string (CustomerService normalizes '' → null), so omitting the key when the + * admin blanks the field would make the email impossible to clear. */ export function toCustomerRequest( values: CustomerFormValues, @@ -43,6 +45,6 @@ export function toCustomerRequest( return { name: values.name.trim(), addresses: [values.address.trim(), ...existingAddresses.slice(1)], - ...(values.contactEmail.trim() ? { contactEmail: values.contactEmail.trim() } : {}), + contactEmail: values.contactEmail.trim(), }; } diff --git a/web/src/domain/customers/use-cases.ts b/web/src/domain/customers/use-cases.ts index ab37d6b..81ac14a 100644 --- a/web/src/domain/customers/use-cases.ts +++ b/web/src/domain/customers/use-cases.ts @@ -1,5 +1,5 @@ // Customers domain — TanStack Query hooks + query keys (the only public surface). -import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; +import { keepPreviousData, useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; import { toast } from 'react-toastify'; import { customersApi } from './api'; import type { CreateCustomerRequest, CustomerListParams, UpdateCustomerRequest } from './types'; @@ -21,11 +21,15 @@ export function useCustomerSearch(query: string) { }); } -/** Paginated management list (CustomerManagementPage). */ +/** Paginated management list (CustomerManagementPage). keepPreviousData + * keeps rows/totalCount rendered while a new page/search fetches (matches + * the old usePaginatedList behavior and keeps TablePagination's count from + * collapsing to 0 mid-fetch). */ export function useCustomerList(params: CustomerListParams = {}) { return useQuery({ queryKey: customersKeys.list(params), queryFn: () => customersApi.list(params), + placeholderData: keepPreviousData, }); } @@ -34,7 +38,11 @@ export function useCreateCustomer() { return useMutation({ mutationFn: (data: CreateCustomerRequest) => customersApi.create(data), onSuccess: () => { - queryClient.invalidateQueries({ queryKey: customersKeys.lists() }); + // Invalidate the whole domain: lists AND cached search-autocomplete + // results (['customers','search',q]), which lists() would not cover — + // otherwise a just-created customer is invisible in New Proposal's + // autocomplete for the 5-minute staleTime. + queryClient.invalidateQueries({ queryKey: customersKeys.all }); toast.success('Customer created'); }, onError: (error: Error) => { @@ -49,7 +57,8 @@ export function useUpdateCustomer() { mutationFn: ({ id, data }: { id: string; data: UpdateCustomerRequest }) => customersApi.update(id, data), onSuccess: () => { - queryClient.invalidateQueries({ queryKey: customersKeys.lists() }); + // Whole-domain invalidation — see useCreateCustomer. + queryClient.invalidateQueries({ queryKey: customersKeys.all }); toast.success('Customer updated'); }, onError: (error: Error) => { diff --git a/web/src/domain/lineItems/schemas.ts b/web/src/domain/lineItems/schemas.ts index f573810..3163f75 100644 --- a/web/src/domain/lineItems/schemas.ts +++ b/web/src/domain/lineItems/schemas.ts @@ -1,8 +1,6 @@ -// Line items domain — contract schema re-exports + line-item row form schema. -import { z } from 'zod'; -import { updateLineItemEntrySchema } from '@proposal-system/api-contracts/schemas'; -import type { UpdateLineItemEntry } from './types'; - +// Line items domain — contract schema re-exports. +// A row-level form schema will arrive with the LineItemEditor migration to +// react-hook-form; until a component consumes it, none is defined here. export { pricingModeSchema, lineItemSourceSchema, @@ -11,51 +9,3 @@ export { updateLineItemEntrySchema, bulkUpdateLineItemsRequestSchema, } from '@proposal-system/api-contracts/schemas'; - -/** Required numeric text input (>= 0). */ -const numericString = z - .string() - .refine((v) => v.trim() !== '' && !Number.isNaN(Number(v)) && Number(v) >= 0, { - message: 'Must be a number >= 0', - }); - -/** Optional numeric text input — empty maps to null on the wire. */ -const optionalNumericString = z - .string() - .refine((v) => v.trim() === '' || (!Number.isNaN(Number(v)) && Number(v) >= 0), { - message: 'Must be a number >= 0', - }); - -/** - * A single editable line-item row. Derived from updateLineItemEntrySchema — - * numeric inputs are strings in form state; toUpdateLineItemEntry() converts - * to the wire type. Row identity (id/sortOrder/source) is editor-managed - * state, not user input, so it is supplied to the mapper instead. - */ -export const lineItemRowFormSchema = updateLineItemEntrySchema - .pick({ description: true, unit: true, pricingMode: true }) - .extend({ - quantity: numericString, - unitPrice: optionalNumericString, - totalPrice: numericString, - }); - -export type LineItemRowFormValues = z.infer; - -/** Map a validated row to the bulk-update wire entry. */ -export function toUpdateLineItemEntry( - values: LineItemRowFormValues, - meta: { id: string | null; sortOrder: number; source: UpdateLineItemEntry['source'] } -): UpdateLineItemEntry { - return { - id: meta.id, - description: values.description, - quantity: Number(values.quantity), - unit: values.unit, - unitPrice: values.unitPrice.trim() !== '' ? Number(values.unitPrice) : null, - totalPrice: Number(values.totalPrice), - pricingMode: values.pricingMode, - sortOrder: meta.sortOrder, - source: meta.source, - }; -} diff --git a/web/src/domain/lineItems/use-cases.ts b/web/src/domain/lineItems/use-cases.ts index 01c02a6..c50a047 100644 --- a/web/src/domain/lineItems/use-cases.ts +++ b/web/src/domain/lineItems/use-cases.ts @@ -1,9 +1,8 @@ // Line items domain — TanStack Query hooks + query keys (the only public surface). -import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; -import { toast } from 'react-toastify'; +// Mutations on line items flow through useSaveProposalWorkspace (admin domain); +// add dedicated mutation hooks here when a page actually consumes them. +import { useQuery } from '@tanstack/react-query'; import { lineItemsApi } from './api'; -import { proposalsKeys } from '../proposals/use-cases'; -import type { CreateLineItemRequest, UpdateLineItemEntry } from './types'; export const lineItemsKeys = { all: ['lineItems'] as const, @@ -18,47 +17,3 @@ export function useLineItems(proposalId: string | undefined) { enabled: !!proposalId, }); } - -export function useCreateLineItem(proposalId: string) { - const queryClient = useQueryClient(); - return useMutation({ - mutationFn: (data: CreateLineItemRequest) => lineItemsApi.create(proposalId, data), - onSuccess: () => { - queryClient.invalidateQueries({ queryKey: lineItemsKeys.byProposal(proposalId) }); - queryClient.invalidateQueries({ queryKey: proposalsKeys.detail(proposalId) }); - }, - onError: (error: Error) => { - toast.error(`Failed to add line item: ${error.message}`); - }, - }); -} - -/** Replace-all update of a proposal's line items. Totals change, so the - * proposal detail is invalidated alongside the line items. */ -export function useBulkUpdateLineItems(proposalId: string) { - const queryClient = useQueryClient(); - return useMutation({ - mutationFn: (lineItems: UpdateLineItemEntry[]) => lineItemsApi.bulkUpdate(proposalId, lineItems), - onSuccess: () => { - queryClient.invalidateQueries({ queryKey: lineItemsKeys.byProposal(proposalId) }); - queryClient.invalidateQueries({ queryKey: proposalsKeys.detail(proposalId) }); - }, - onError: (error: Error) => { - toast.error(`Save failed: ${error.message}`); - }, - }); -} - -export function useDeleteLineItem(proposalId: string) { - const queryClient = useQueryClient(); - return useMutation({ - mutationFn: (itemId: string) => lineItemsApi.delete(proposalId, itemId), - onSuccess: () => { - queryClient.invalidateQueries({ queryKey: lineItemsKeys.byProposal(proposalId) }); - queryClient.invalidateQueries({ queryKey: proposalsKeys.detail(proposalId) }); - }, - onError: (error: Error) => { - toast.error(`Failed to delete line item: ${error.message}`); - }, - }); -} diff --git a/web/src/domain/pricingLibrary/schemas.ts b/web/src/domain/pricingLibrary/schemas.ts index 434ce75..5d0c306 100644 --- a/web/src/domain/pricingLibrary/schemas.ts +++ b/web/src/domain/pricingLibrary/schemas.ts @@ -1,6 +1,7 @@ // Pricing library domain — contract schema re-exports + item form schema. import { z } from 'zod'; import { createPricingLibraryItemRequestSchema } from '@proposal-system/api-contracts/schemas'; +import { optionalNumericString, toNumberOrNull } from '../shared/formFields'; import type { CreatePricingLibraryItemRequest, UpdatePricingLibraryItemRequest } from './types'; export { @@ -20,11 +21,9 @@ export const pricingLibraryItemFormSchema = createPricingLibraryItemRequestSchem .extend({ title: z.string().trim().min(1, 'Title is required'), unit: z.string(), - unitPrice: z - .string() - .refine((v) => v.trim() === '' || (!Number.isNaN(parseFloat(v)) && parseFloat(v) >= 0), { - message: 'Must be a number >= 0', - }), + // Shared Number-based field — rejects trailing garbage ('12abc') that + // the old parseFloat refine silently coerced to 12. + unitPrice: optionalNumericString, keywords: z.string(), description: z.string(), }); @@ -43,7 +42,7 @@ export function toPricingLibraryItemRequest( title: values.title.trim(), serviceCategory: values.serviceCategory, ...(values.unit.trim() ? { unit: values.unit.trim() } : {}), - unitPrice: values.unitPrice.trim() !== '' ? parseFloat(values.unitPrice) : null, + unitPrice: toNumberOrNull(values.unitPrice), ...(values.keywords.trim() ? { keywords: values.keywords.trim() } : {}), ...(values.description.trim() ? { description: values.description.trim() } : {}), }; diff --git a/web/src/domain/pricingLibrary/use-cases.ts b/web/src/domain/pricingLibrary/use-cases.ts index e237877..6ca6a74 100644 --- a/web/src/domain/pricingLibrary/use-cases.ts +++ b/web/src/domain/pricingLibrary/use-cases.ts @@ -1,5 +1,5 @@ // Pricing library domain — TanStack Query hooks + query keys (the only public surface). -import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; +import { keepPreviousData, useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; import { toast } from 'react-toastify'; import { pricingLibraryApi } from './api'; import type { @@ -14,11 +14,14 @@ export const pricingLibraryKeys = { list: (params: PricingLibraryListParams) => [...pricingLibraryKeys.lists(), params] as const, }; -/** Paginated pricing library list (PricingLibraryPage). */ +/** Paginated pricing library list (PricingLibraryPage). keepPreviousData + * keeps rows/totalCount rendered while a new page fetches (prevents + * TablePagination's count collapsing to 0 mid-fetch). */ export function usePricingLibraryList(params: PricingLibraryListParams = {}) { return useQuery({ queryKey: pricingLibraryKeys.list(params), queryFn: () => pricingLibraryApi.list(params), + placeholderData: keepPreviousData, }); } diff --git a/web/src/domain/proposals/api.ts b/web/src/domain/proposals/api.ts index 61567cf..b077f1f 100644 --- a/web/src/domain/proposals/api.ts +++ b/web/src/domain/proposals/api.ts @@ -88,4 +88,21 @@ export const proposalsApi = { const downloadUrl = URL.createObjectURL(blob); return { downloadUrl }; }, + + /** + * PUT a file to a presigned S3 URL. Plain fetch by design — the presigned + * URL must NOT carry our auth headers (the axios client would attach them). + * fetch resolves on HTTP errors, so the ok check is what surfaces S3 + * rejections (expired signature, size/content-type mismatch) as failures. + */ + uploadToPresignedUrl: async (uploadUrl: string, file: File): Promise => { + const res = await fetch(uploadUrl, { + method: 'PUT', + body: file, + headers: { 'Content-Type': file.type || 'application/pdf' }, + }); + if (!res.ok) { + throw new Error(`Vendor PDF upload failed (${res.status})`); + } + }, }; diff --git a/web/src/domain/proposals/use-cases.ts b/web/src/domain/proposals/use-cases.ts index ba2ac5d..99c3db9 100644 --- a/web/src/domain/proposals/use-cases.ts +++ b/web/src/domain/proposals/use-cases.ts @@ -72,11 +72,8 @@ export function useCreateProposal() { proposal.id, vendorFile.name ); - await fetch(uploadUrl, { - method: 'PUT', - body: vendorFile, - headers: { 'Content-Type': vendorFile.type || 'application/pdf' }, - }); + // 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( diff --git a/web/src/domain/shared/formFields.ts b/web/src/domain/shared/formFields.ts new file mode 100644 index 0000000..db3e0c9 --- /dev/null +++ b/web/src/domain/shared/formFields.ts @@ -0,0 +1,23 @@ +// Shared form-field schemas for numeric text inputs (single source — the +// Number-based validation deliberately rejects trailing garbage like '12abc', +// which parseFloat would silently coerce to 12). +import { z } from 'zod'; + +/** Required numeric text input (>= 0). */ +export const numericString = z + .string() + .refine((v) => v.trim() !== '' && !Number.isNaN(Number(v)) && Number(v) >= 0, { + message: 'Must be a number >= 0', + }); + +/** Optional numeric text input — empty maps to null on the wire. */ +export const optionalNumericString = z + .string() + .refine((v) => v.trim() === '' || (!Number.isNaN(Number(v)) && Number(v) >= 0), { + message: 'Must be a number >= 0', + }); + +/** Convert an optionalNumericString form value to the wire number|null. */ +export function toNumberOrNull(value: string): number | null { + return value.trim() !== '' ? Number(value) : null; +} diff --git a/web/src/hooks/useDebouncedValue.ts b/web/src/hooks/useDebouncedValue.ts new file mode 100644 index 0000000..2105437 --- /dev/null +++ b/web/src/hooks/useDebouncedValue.ts @@ -0,0 +1,32 @@ +import { useEffect, useRef, useState } from 'react'; + +/** + * Debounce a changing value. Single home for the debounce logic that was + * previously centralized in usePaginatedList and then hand-copied into four + * pages during the domain-layer migration (each copy had drifted: one leaked + * its timer on unmount, two hardcoded 300ms). + * + * @param onSettle optional callback fired when the debounced value actually + * changes — used by list pages to reset pagination to page 1 on a new search. + */ +export function useDebouncedValue(value: T, delayMs: number, onSettle?: (value: T) => void): T { + const [debounced, setDebounced] = useState(value); + const debouncedRef = useRef(value); + // Latest-callback ref so consumers can pass inline callbacks without + // re-arming the timer every render. + const onSettleRef = useRef(onSettle); + onSettleRef.current = onSettle; + + useEffect(() => { + const timer = setTimeout(() => { + if (debouncedRef.current !== value) { + debouncedRef.current = value; + setDebounced(value); + onSettleRef.current?.(value); + } + }, delayMs); + return () => clearTimeout(timer); + }, [value, delayMs]); + + return debounced; +} diff --git a/web/src/pages/admin/customers/CustomerManagementPage.tsx b/web/src/pages/admin/customers/CustomerManagementPage.tsx index aa6b26a..03ce926 100644 --- a/web/src/pages/admin/customers/CustomerManagementPage.tsx +++ b/web/src/pages/admin/customers/CustomerManagementPage.tsx @@ -42,6 +42,7 @@ import { } from '../../../domain/customers/schemas'; import { DEFAULT_PAGE, DEFAULT_PAGE_SIZE, DEBOUNCE_SEARCH } from '../../../constants'; import { useDocumentTitle } from '../../../hooks/useDocumentTitle'; +import { useDebouncedValue } from '../../../hooks/useDebouncedValue'; const COL_COUNT = 4; // Name, Address, Contact Email, Actions @@ -52,22 +53,13 @@ export default function CustomerManagementPage() { // --- List state --- const [search, setSearch] = useState(''); - const [debouncedSearch, setDebouncedSearch] = useState(''); const [page, setPage] = useState(DEFAULT_PAGE); const [pageSize, setPageSize] = useState(DEFAULT_PAGE_SIZE); - // Debounce search input - const [debounceTimer, setDebounceTimer] = useState | null>(null); - - const handleSearchChange = (value: string) => { - setSearch(value); - if (debounceTimer) clearTimeout(debounceTimer); - const timer = setTimeout(() => { - setDebouncedSearch(value); - setPage(1); - }, DEBOUNCE_SEARCH); - setDebounceTimer(timer); - }; + // Debounced search resets to page 1 (shared hook — the previous inline + // timer leaked on unmount). + const debouncedSearch = useDebouncedValue(search, DEBOUNCE_SEARCH, () => setPage(1)); + const handleSearchChange = (value: string) => setSearch(value); const { data, isLoading, isError, refetch } = useCustomerList({ search: debouncedSearch || undefined, diff --git a/web/src/pages/admin/dashboard/AdminDashboard.tsx b/web/src/pages/admin/dashboard/AdminDashboard.tsx index d0d0bce..8bc8700 100644 --- a/web/src/pages/admin/dashboard/AdminDashboard.tsx +++ b/web/src/pages/admin/dashboard/AdminDashboard.tsx @@ -1,4 +1,4 @@ -import { useEffect, useMemo, useState, Fragment } from 'react'; +import { useMemo, useState, Fragment } from 'react'; import { useNavigate } from 'react-router-dom'; import { Box, @@ -42,6 +42,7 @@ 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 { useDocumentTitle } from '../../../hooks/useDocumentTitle'; +import { useDebouncedValue } from '../../../hooks/useDebouncedValue'; const COL_COUNT = 11; const TAB_STATUSES = ['', 'InReview', 'Approved', 'Sent', 'Revised'] as const; @@ -81,18 +82,11 @@ export default function AdminDashboard({ defaultStatus }: { defaultStatus?: stri const [expanded, setExpanded] = useState>(new Set()); const [search, setSearch] = useState(''); - const [debouncedSearch, setDebouncedSearch] = useState(''); const [page, setPage] = useState(DEFAULT_PAGE); const [pageSize, setPageSize] = useState(DEFAULT_PAGE_SIZE); // Debounced search resets to page 1 (mirrors the old usePaginatedList behavior). - useEffect(() => { - const handle = setTimeout(() => { - setDebouncedSearch(search); - setPage(1); - }, DEBOUNCE_SEARCH); - return () => clearTimeout(handle); - }, [search]); + const debouncedSearch = useDebouncedValue(search, DEBOUNCE_SEARCH, () => setPage(1)); const { data: listData, @@ -415,7 +409,8 @@ export default function AdminDashboard({ defaultStatus }: { defaultStatus?: stri ); })} - {!loading && rows.length === 0 && ( + {/* Empty state only when the fetch actually succeeded. */} + {!loading && !err && rows.length === 0 && ( diff --git a/web/src/pages/admin/workspace/AdminWorkspace.tsx b/web/src/pages/admin/workspace/AdminWorkspace.tsx index b73a3a5..b7bb83b 100644 --- a/web/src/pages/admin/workspace/AdminWorkspace.tsx +++ b/web/src/pages/admin/workspace/AdminWorkspace.tsx @@ -162,10 +162,13 @@ export default function AdminWorkspace() { // with the previous approve mutation, which saved first when dirty). if (dirty) { await saveMutation.mutateAsync({ refinedScope, lineItems: toUpdateEntries(lineItems) }); + // The edits are persisted regardless of whether the approve below + // succeeds — clear dirty NOW so a failed approve doesn't leave a + // false unsaved-changes prompt over already-saved work. + setDirty(false); } await approveMutation.mutateAsync(); setApproveDialogOpen(false); - setDirty(false); } catch { // error toasts are handled by the domain hooks } diff --git a/web/src/pages/proposals/form/ProposalFormPage.tsx b/web/src/pages/proposals/form/ProposalFormPage.tsx index 041cb71..89a59a2 100644 --- a/web/src/pages/proposals/form/ProposalFormPage.tsx +++ b/web/src/pages/proposals/form/ProposalFormPage.tsx @@ -1,4 +1,4 @@ -import { useState, useRef, useEffect, useMemo } from 'react'; +import { useState, useEffect, useMemo } from 'react'; import { useNavigate } from 'react-router-dom'; import { Controller, useForm } from 'react-hook-form'; import { zodResolver } from '@hookform/resolvers/zod'; @@ -25,7 +25,8 @@ 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 } 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; const ALLOWED_MIME_TYPES = ['application/pdf']; @@ -54,7 +55,7 @@ export default function ProposalFormPage() { }); const [vendorFile, setVendorFile] = useState(null); - const [siteQuery, setSiteQuery] = useState(''); + const [siteSearchInput, setSiteSearchInput] = useState(''); const [siteInputValue, setSiteInputValue] = useState(''); const [manualEntry, setManualEntry] = useState(false); const [manualSiteCode, setManualSiteCode] = useState(''); @@ -62,12 +63,15 @@ export default function ProposalFormPage() { const [manualCity, setManualCity] = useState(''); const [manualState, setManualState] = useState(''); const [manualZip, setManualZip] = useState(''); - const siteDebounceRef = useRef | null>(null); // PR4: customer search state — freeSolo Autocomplete backed by the customers domain - const [customerQuery, setCustomerQuery] = useState(''); + const [customerSearchInput, setCustomerSearchInput] = useState(''); const [customerInputValue, setCustomerInputValue] = useState(''); - const customerDebounceRef = useRef | null>(null); + + // Shared debounce hook (search input is only updated on typing, so + // selecting an option never fires a search request). + const siteQuery = useDebouncedValue(siteSearchInput, DEBOUNCE_AUTOCOMPLETE); + const customerQuery = useDebouncedValue(customerSearchInput, DEBOUNCE_AUTOCOMPLETE); // Domain search hooks (disabled below 2 characters, matching prior behavior). const siteSearch = useSiteSearch(siteQuery); @@ -78,13 +82,6 @@ export default function ProposalFormPage() { const customerOptions = (customerSearch.data ?? []).map((c) => c.name); const customerLoading = customerSearch.isFetching; - useEffect(() => { - return () => { - if (siteDebounceRef.current) clearTimeout(siteDebounceRef.current); - if (customerDebounceRef.current) clearTimeout(customerDebounceRef.current); - }; - }, []); - useEffect(() => { if (!manualEntry) return; const addressParts = [ @@ -124,25 +121,28 @@ export default function ProposalFormPage() { const scopeTouched = scopeOfWork.length > 0; const scopeTooShort = scopeTouched && scopeOfWork.trim().length < MIN_SCOPE_LENGTH; - const isValid = - workOrderNumber.trim() && - customerName.trim() && - customerAddress.trim() && - scopeOfWork.trim().length >= MIN_SCOPE_LENGTH && - serviceCategory && - (serviceCategory !== 'Other' || customCategory.trim()); - + // SINGLE source for the submit gate and the missing-fields caption — + // mirrors createProposalFormSchema (zodResolver re-enforces at submit). + // Deriving both from one list keeps the button and the caption from + // drifting (the previous split logic disabled Submit for a missing + // customer while the caption never mentioned it). const missingFields = useMemo(() => { - const missing: string[] = []; - if (!workOrderNumber.trim()) missing.push('Work Order Number'); - if (!customerAddress.trim()) missing.push('Site address'); - if (!serviceCategory) missing.push('Service Category'); - if (serviceCategory === 'Other' && !customCategory.trim()) missing.push('Specify category'); - // Fix: WEB-M3 — show minimum length requirement in missing fields summary - if (!scopeOfWork.trim()) missing.push('Scope of Work'); - else if (scopeOfWork.trim().length < MIN_SCOPE_LENGTH) missing.push(`Scope of Work (min ${MIN_SCOPE_LENGTH} chars)`); - return missing; - }, [workOrderNumber, customerAddress, serviceCategory, scopeOfWork, customCategory]); + const checks: Array<{ label: string; ok: boolean }> = [ + { label: 'Work Order Number', ok: !!workOrderNumber.trim() }, + { label: 'Customer', ok: !!customerName.trim() }, + { label: 'Site address', ok: !!customerAddress.trim() }, + { label: 'Service Category', ok: !!serviceCategory }, + { 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)`, + ok: scopeOfWork.trim().length >= MIN_SCOPE_LENGTH, + }, + ]; + return checks.filter((c) => !c.ok).map((c) => c.label); + }, [workOrderNumber, customerName, customerAddress, serviceCategory, scopeOfWork, customCategory]); + + const isValid = missingFields.length === 0; return ( @@ -183,12 +183,11 @@ export default function ProposalFormPage() { if (reason === 'input') { setCustomerInputValue(value); setValue('customerName', value); - if (customerDebounceRef.current) clearTimeout(customerDebounceRef.current); - customerDebounceRef.current = setTimeout(() => setCustomerQuery(value), 300); + setCustomerSearchInput(value); } else if (reason === 'clear') { setCustomerInputValue(''); setValue('customerName', ''); - setCustomerQuery(''); + setCustomerSearchInput(''); } }} onChange={(_, value) => { @@ -337,8 +336,7 @@ export default function ProposalFormPage() { onInputChange={(_, value, reason) => { if (reason === 'input') { setSiteInputValue(value); - if (siteDebounceRef.current) clearTimeout(siteDebounceRef.current); - siteDebounceRef.current = setTimeout(() => setSiteQuery(value), 300); + setSiteSearchInput(value); } }} onChange={(_, value) => { diff --git a/web/src/pages/proposals/list/ProposalListPage.tsx b/web/src/pages/proposals/list/ProposalListPage.tsx index 3ab2aa6..7fde64d 100644 --- a/web/src/pages/proposals/list/ProposalListPage.tsx +++ b/web/src/pages/proposals/list/ProposalListPage.tsx @@ -1,4 +1,4 @@ -import { useEffect, useRef, useState } from 'react'; +import { useState } from 'react'; import { useNavigate } from 'react-router-dom'; import { Box, @@ -21,6 +21,7 @@ import { 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 { formatBidAmount, formatDate, formatDateFull } from '../../../lib/format'; import { useDocumentTitle } from '../../../hooks/useDocumentTitle'; @@ -30,22 +31,11 @@ export default function ProposalListPage() { const navigate = useNavigate(); const [search, setSearch] = useState(''); - const [debouncedSearch, setDebouncedSearch] = useState(''); const [page, setPage] = useState(DEFAULT_PAGE); const [pageSize, setPageSize] = useState(DEFAULT_PAGE_SIZE); - const debounceRef = useRef | null>(null); // Debounced search resets to page 1 (mirrors usePaginatedList). - useEffect(() => { - if (debounceRef.current) clearTimeout(debounceRef.current); - debounceRef.current = setTimeout(() => { - setDebouncedSearch(search); - setPage(1); - }, DEBOUNCE_SEARCH); - return () => { - if (debounceRef.current) clearTimeout(debounceRef.current); - }; - }, [search]); + const debouncedSearch = useDebouncedValue(search, DEBOUNCE_SEARCH, () => setPage(1)); const { data, isLoading, isPlaceholderData, error } = useProposals({ search: debouncedSearch, @@ -153,7 +143,9 @@ export default function ProposalListPage() { ))} - {!loading && rows.length === 0 && ( + {/* Empty state only when the fetch actually succeeded — on error + the banner above is the whole story. */} + {!loading && !err && rows.length === 0 && ( diff --git a/web/tsconfig.json b/web/tsconfig.json index 0651de3..21d273e 100644 --- a/web/tsconfig.json +++ b/web/tsconfig.json @@ -18,7 +18,7 @@ "noUncheckedIndexedAccess": true, "paths": { "@proposal-system/api-contracts": ["../shared/api-contracts/src"], - "@proposal-system/api-contracts/schemas": ["../shared/api-contracts/src/schemas"] + "@proposal-system/api-contracts/schemas": ["../shared/api-contracts/src/schemas.ts"] } }, "include": ["src", "../shared/api-contracts/src"] diff --git a/web/vite.config.ts b/web/vite.config.ts index 5de237b..fde3a10 100644 --- a/web/vite.config.ts +++ b/web/vite.config.ts @@ -1,4 +1,3 @@ -import { fileURLToPath } from 'node:url'; import { defineConfig } from 'vite'; import react from '@vitejs/plugin-react'; @@ -7,16 +6,9 @@ export default defineConfig({ resolve: { // @proposal-system/api-contracts is a file: symlink; preserve it so its // 'zod' import resolves from web/node_modules in CI (shared has no - // installed node_modules there). + // installed node_modules there). Subpath entrypoints (./schemas) resolve + // via the package's `exports` map. preserveSymlinks: true, - alias: { - // The package has no `exports` map (main points at src/index.ts), so - // the /schemas subpath entrypoint needs an explicit alias (mirrors the - // tsconfig `paths` mapping; vitest.config.ts carries the same alias). - '@proposal-system/api-contracts/schemas': fileURLToPath( - new URL('../shared/api-contracts/src/schemas.ts', import.meta.url) - ), - }, }, server: { port: 5173, diff --git a/web/vitest.config.ts b/web/vitest.config.ts index e14846c..2b34195 100644 --- a/web/vitest.config.ts +++ b/web/vitest.config.ts @@ -1,18 +1,13 @@ -import { fileURLToPath } from 'node:url'; import { defineConfig } from 'vitest/config'; import react from '@vitejs/plugin-react'; export default defineConfig({ plugins: [react()], resolve: { - alias: { - // @proposal-system/api-contracts has no `exports` map (main points at - // src/index.ts), so the /schemas subpath entrypoint needs an explicit - // alias for vitest's resolver (mirrors the tsconfig `paths` mapping). - '@proposal-system/api-contracts/schemas': fileURLToPath( - new URL('../shared/api-contracts/src/schemas.ts', import.meta.url) - ), - }, + // @proposal-system/api-contracts is a file: symlink; subpath entrypoints + // (./schemas) resolve via the package's `exports` map. preserveSymlinks + // keeps its 'zod' import resolving from web/node_modules in CI. + preserveSymlinks: true, }, test: { environment: 'jsdom',