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/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 c9b4793..f7371f8 100644 --- a/web/package.json +++ b/web/package.json @@ -17,6 +17,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", @@ -26,6 +27,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/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/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/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/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`. 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..fc734f6 --- /dev/null +++ b/web/src/domain/__tests__/admin.use-cases.test.tsx @@ -0,0 +1,99 @@ +// 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), + }); + // ...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'); + }); + + 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..059be69 --- /dev/null +++ b/web/src/domain/__tests__/customers.use-cases.test.tsx @@ -0,0 +1,99 @@ +// Customers domain use-case hooks: create/update must invalidate the list +// 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'; +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 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'); + + 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.all }); + 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 the whole customers domain 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.all }); + 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..9cde471 --- /dev/null +++ b/web/src/domain/__tests__/lineItems.use-cases.test.tsx @@ -0,0 +1,100 @@ +// 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 } 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(), + 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('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(() => 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 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(adminApi.updateProposal).mockRejectedValue(new Error('409 conflict')); + const { queryClient, wrapper } = createQueryHarness(); + const invalidateSpy = vi.spyOn(queryClient, 'invalidateQueries'); + + 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__/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'); + }); +}); diff --git a/web/src/domain/__tests__/schemas.test.ts b/web/src/domain/__tests__/schemas.test.ts new file mode 100644 index 0000000..6497fe1 --- /dev/null +++ b/web/src/domain/__tests__/schemas.test.ts @@ -0,0 +1,137 @@ +// 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'; + +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'] + ); + // 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: '' }); + }); +}); + +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('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/lib/api/admin.ts b/web/src/domain/admin/api.ts similarity index 82% rename from web/src/lib/api/admin.ts rename to web/src/domain/admin/api.ts index 918524c..472f339 100644 --- a/web/src/lib/api/admin.ts +++ b/web/src/domain/admin/api.ts @@ -1,8 +1,7 @@ -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'; +// 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 => { @@ -44,7 +43,7 @@ export const adminApi = { await apiClient.post(`/proposals/${id}/generate-suggestions`); }, - getSimilar: async (id: string): Promise => { + getSimilar: async (id: string): Promise => { const res = await apiClient.get(`/proposals/${id}/similar`); return res.data; }, 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..9c0f8d7 --- /dev/null +++ b/web/src/domain/admin/use-cases.ts @@ -0,0 +1,163 @@ +// 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, 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 { 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, +}; + +/** + * 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({ + 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 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: () => { + invalidateProposalViews(queryClient, proposalId); + toast.success('Changes saved'); + }, + onError: (error: Error) => { + toast.error(`Save failed: ${error.message}`); + }, + }); +} + +export function useApproveProposal(proposalId: string) { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: () => adminApi.approveProposal(proposalId), + onSuccess: () => { + invalidateProposalViews(queryClient, 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: () => { + 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}`); + }, + }); +} + +export function useReviseProposal(proposalId: string) { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: () => adminApi.reviseProposal(proposalId), + onSuccess: () => { + 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}`); + }, + }); +} + +export function useReturnToReview(proposalId: string) { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: () => adminApi.returnToReview(proposalId), + onSuccess: () => { + invalidateProposalViews(queryClient, 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/lib/api/customers.ts b/web/src/domain/customers/api.ts similarity index 73% rename from web/src/lib/api/customers.ts rename to web/src/domain/customers/api.ts index 61ee7ee..695872b 100644 --- a/web/src/lib/api/customers.ts +++ b/web/src/domain/customers/api.ts @@ -1,23 +1,12 @@ -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. +// 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, - PagedResponse, -} from '@proposal-system/api-contracts'; - -export type { - Customer, - CustomerListParams, - CreateCustomerRequest, - UpdateCustomerRequest, -} from '@proposal-system/api-contracts'; - -export type PagedCustomerResponse = PagedResponse; + PagedCustomerResponse, +} from './types'; export const customersApi = { /** GET /api/customers?query= — search autocomplete (unchanged). */ diff --git a/web/src/domain/customers/schemas.ts b/web/src/domain/customers/schemas.ts new file mode 100644 index 0000000..e7c2ab0 --- /dev/null +++ b/web/src/domain/customers/schemas.ts @@ -0,0 +1,50 @@ +// 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. + * 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, + existingAddresses: string[] = [] +): CreateCustomerRequest & UpdateCustomerRequest { + return { + name: values.name.trim(), + addresses: [values.address.trim(), ...existingAddresses.slice(1)], + 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..81ac14a --- /dev/null +++ b/web/src/domain/customers/use-cases.ts @@ -0,0 +1,68 @@ +// Customers 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 { 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). 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, + }); +} + +export function useCreateCustomer() { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: (data: CreateCustomerRequest) => customersApi.create(data), + onSuccess: () => { + // 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) => { + 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: () => { + // Whole-domain invalidation — see useCreateCustomer. + queryClient.invalidateQueries({ queryKey: customersKeys.all }); + toast.success('Customer updated'); + }, + onError: (error: Error) => { + toast.error(`Failed to update customer: ${error.message}`); + }, + }); +} diff --git a/web/src/lib/api/lineItems.ts b/web/src/domain/lineItems/api.ts similarity index 74% rename from web/src/lib/api/lineItems.ts rename to web/src/domain/lineItems/api.ts index 95f4eb8..6d18f1d 100644 --- a/web/src/lib/api/lineItems.ts +++ b/web/src/domain/lineItems/api.ts @@ -1,14 +1,6 @@ -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'; +// 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 => { diff --git a/web/src/domain/lineItems/schemas.ts b/web/src/domain/lineItems/schemas.ts new file mode 100644 index 0000000..3163f75 --- /dev/null +++ b/web/src/domain/lineItems/schemas.ts @@ -0,0 +1,11 @@ +// 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, + lineItemSchema, + createLineItemRequestSchema, + updateLineItemEntrySchema, + bulkUpdateLineItemsRequestSchema, +} from '@proposal-system/api-contracts/schemas'; 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..c50a047 --- /dev/null +++ b/web/src/domain/lineItems/use-cases.ts @@ -0,0 +1,19 @@ +// Line items domain — TanStack Query hooks + query keys (the only public surface). +// 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'; + +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, + }); +} diff --git a/web/src/lib/api/pricingLibrary.ts b/web/src/domain/pricingLibrary/api.ts similarity index 77% rename from web/src/lib/api/pricingLibrary.ts rename to web/src/domain/pricingLibrary/api.ts index a0b4bb0..c9f7974 100644 --- a/web/src/lib/api/pricingLibrary.ts +++ b/web/src/domain/pricingLibrary/api.ts @@ -1,21 +1,12 @@ -import apiClient from './client'; -// Fix: WEB-M5 — types from the shared contracts package. +// 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, - PagedResponse, -} from '@proposal-system/api-contracts'; - -export type { - PricingLibraryItem, - PricingLibraryListParams, - CreatePricingLibraryItemRequest, - UpdatePricingLibraryItemRequest, -} from '@proposal-system/api-contracts'; - -export type PagedPricingLibraryResponse = PagedResponse; + PagedPricingLibraryResponse, +} from './types'; export const pricingLibraryApi = { /** GET /api/pricing-library/list?page=&pageSize= — paginated list (admin/sysadmin). */ diff --git a/web/src/domain/pricingLibrary/schemas.ts b/web/src/domain/pricingLibrary/schemas.ts new file mode 100644 index 0000000..5d0c306 --- /dev/null +++ b/web/src/domain/pricingLibrary/schemas.ts @@ -0,0 +1,49 @@ +// 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 { + 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(), + // 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(), + }); + +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: toNumberOrNull(values.unitPrice), + ...(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..6ca6a74 --- /dev/null +++ b/web/src/domain/pricingLibrary/use-cases.ts @@ -0,0 +1,69 @@ +// Pricing library 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 { 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). 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, + }); +} + +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/lib/api/proposals.ts b/web/src/domain/proposals/api.ts similarity index 72% rename from web/src/lib/api/proposals.ts rename to web/src/domain/proposals/api.ts index 024d1d8..b077f1f 100644 --- a/web/src/lib/api/proposals.ts +++ b/web/src/domain/proposals/api.ts @@ -1,6 +1,5 @@ -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. +// Proposals domain — HTTP calls only (no hooks). Moved from lib/api/proposals.ts. +import apiClient from '../../lib/api/client'; import type { CreateProposalRequest, ProposalDetail, @@ -10,20 +9,8 @@ import type { 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'; + VendorProposal, +} from './types'; export const proposalsApi = { create: async (data: CreateProposalRequest): Promise => { @@ -68,6 +55,13 @@ export const proposalsApi = { 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', @@ -94,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/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..99c3db9 --- /dev/null +++ b/web/src/domain/proposals/use-cases.ts @@ -0,0 +1,95 @@ +// 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 + ); + // Throws on S3 rejection (non-2xx) so a failed upload is never confirmed. + await proposalsApi.uploadToPresignedUrl(uploadUrl, vendorFile); + await proposalsApi.confirmUpload(proposal.id, vendorProposalId); + } catch { + toast.warning( + `Proposal ${proposal.proposalNumber} was created, but vendor PDF upload failed. You can re-upload from the proposal detail page.` + ); + } + } + + 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/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/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/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/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/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; - }, -}; diff --git a/web/src/pages/admin/customers/CustomerManagementPage.tsx b/web/src/pages/admin/customers/CustomerManagementPage.tsx index bd6a515..03ce926 100644 --- a/web/src/pages/admin/customers/CustomerManagementPage.tsx +++ b/web/src/pages/admin/customers/CustomerManagementPage.tsx @@ -1,5 +1,6 @@ import { useState } from 'react'; -import { useQuery, useMutation } from '@tanstack/react-query'; +import { useForm } from 'react-hook-form'; +import { zodResolver } from '@hookform/resolvers/zod'; import { Box, Typography, @@ -28,64 +29,42 @@ import SearchIcon from '@mui/icons-material/Search'; import AddIcon from '@mui/icons-material/Add'; import EditIcon from '@mui/icons-material/Edit'; import ErrorOutlineIcon from '@mui/icons-material/ErrorOutlined'; -import { toast } from 'react-toastify'; import { - customersApi, - type Customer, - type PagedCustomerResponse, - type CreateCustomerRequest, - type UpdateCustomerRequest, -} from '../../../lib/api/customers'; -import { queryClient } from '../../../lib/queryClient'; -import { QUERY_KEYS } from '../../../constants/queryKeys'; + useCustomerList, + useCreateCustomer, + useUpdateCustomer, +} from '../../../domain/customers/use-cases'; +import type { Customer } from '../../../domain/customers/types'; +import { + customerFormSchema, + toCustomerRequest, + type CustomerFormValues, +} 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 -/** Simple email format check (client-side only). */ -function isValidEmail(email: string): boolean { - return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email); -} - -interface CustomerFormState { - name: string; - address: string; - contactEmail: string; -} - -const EMPTY_FORM: CustomerFormState = { name: '', address: '', contactEmail: '' }; +const EMPTY_FORM: CustomerFormValues = { name: '', address: '', contactEmail: '' }; export default function CustomerManagementPage() { useDocumentTitle('Customer Management'); // --- 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); + // 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 handleSearchChange = (value: string) => { - setSearch(value); - if (debounceTimer) clearTimeout(debounceTimer); - const timer = setTimeout(() => { - setDebouncedSearch(value); - setPage(1); - }, DEBOUNCE_SEARCH); - setDebounceTimer(timer); - }; - - const { - data, - isLoading, - isError, - refetch, - } = useQuery({ - queryKey: [QUERY_KEYS.customerList, debouncedSearch, page, pageSize], - queryFn: () => customersApi.list({ search: debouncedSearch || undefined, page, pageSize }), + const { data, isLoading, isError, refetch } = useCustomerList({ + search: debouncedSearch || undefined, + page, + pageSize, }); const rows = data?.items ?? []; @@ -94,101 +73,59 @@ export default function CustomerManagementPage() { // --- Dialog state --- const [dialogOpen, setDialogOpen] = useState(false); const [editingCustomer, setEditingCustomer] = useState(null); - const [form, setForm] = useState(EMPTY_FORM); - const [formErrors, setFormErrors] = useState>>({}); const isEditing = editingCustomer !== null; + const { + register, + handleSubmit, + reset, + formState: { errors }, + } = useForm({ + resolver: zodResolver(customerFormSchema), + defaultValues: EMPTY_FORM, + }); + const openCreateDialog = () => { setEditingCustomer(null); - setForm(EMPTY_FORM); - setFormErrors({}); + reset(EMPTY_FORM); setDialogOpen(true); }; const openEditDialog = (customer: Customer) => { setEditingCustomer(customer); - setForm({ + reset({ name: customer.name, address: customer.addresses?.[0] ?? '', contactEmail: customer.contactEmail ?? '', }); - setFormErrors({}); setDialogOpen(true); }; const closeDialog = () => { setDialogOpen(false); setEditingCustomer(null); - setForm(EMPTY_FORM); - setFormErrors({}); + reset(EMPTY_FORM); }; - const validateForm = (): boolean => { - const errors: Partial> = {}; - if (!form.name.trim()) errors.name = 'Name is required'; - if (!form.address.trim()) errors.address = 'Address is required'; - if (form.contactEmail.trim() && !isValidEmail(form.contactEmail.trim())) { - errors.contactEmail = 'Enter a valid email address'; - } - setFormErrors(errors); - return Object.keys(errors).length === 0; - }; - - const handleFieldChange = (field: keyof CustomerFormState, value: string) => { - setForm((prev) => ({ ...prev, [field]: value })); - // Clear field error on change - if (formErrors[field]) { - setFormErrors((prev) => { - const next = { ...prev }; - delete next[field]; - return next; - }); - } - }; - - // --- Mutations --- - const createMutation = useMutation({ - mutationFn: (data: CreateCustomerRequest) => customersApi.create(data), - onSuccess: () => { - queryClient.invalidateQueries({ queryKey: [QUERY_KEYS.customerList] }); - closeDialog(); - toast.success('Customer created'); - }, - onError: (error: Error) => { - toast.error(`Failed to create customer: ${error.message}`); - }, - }); - - const updateMutation = useMutation({ - mutationFn: ({ id, data }: { id: string; data: UpdateCustomerRequest }) => - customersApi.update(id, data), - onSuccess: () => { - queryClient.invalidateQueries({ queryKey: [QUERY_KEYS.customerList] }); - closeDialog(); - toast.success('Customer updated'); - }, - onError: (error: Error) => { - toast.error(`Failed to update customer: ${error.message}`); - }, - }); - - const handleSubmit = () => { - if (!validateForm()) return; + // --- Mutations (domain hooks: invalidation + toasts live in the hooks) --- + const createMutation = useCreateCustomer(); + const updateMutation = useUpdateCustomer(); + const onSubmit = (values: CustomerFormValues) => { // Fix: WEB-M5 — the API contract is addresses: string[] (CustomerDtos.cs); // the old singular `address` field was silently dropped by model binding. - // The form edits the primary address; any additional addresses are preserved. - const payload = { - name: form.name.trim(), - addresses: [form.address.trim(), ...(editingCustomer?.addresses.slice(1) ?? [])], - ...(form.contactEmail.trim() ? { contactEmail: form.contactEmail.trim() } : {}), - }; + // The form edits the primary address; any additional addresses are preserved + // (toCustomerRequest maps to addresses: [primary, ...rest]). + const payload = toCustomerRequest(values, editingCustomer?.addresses ?? []); if (isEditing) { - updateMutation.mutate({ id: editingCustomer.id, data: payload }); + updateMutation.mutate( + { id: editingCustomer.id, data: payload }, + { onSuccess: closeDialog } + ); } else { - createMutation.mutate(payload); + createMutation.mutate(payload, { onSuccess: closeDialog }); } }; @@ -324,54 +261,53 @@ export default function CustomerManagementPage() { {/* Create / Edit Dialog */} - {isEditing ? 'Edit Customer' : 'New Customer'} - - - handleFieldChange('name', e.target.value)} - error={!!formErrors.name} - helperText={formErrors.name} - size="small" - required - fullWidth - autoFocus - /> - handleFieldChange('address', e.target.value)} - error={!!formErrors.address} - helperText={formErrors.address} - size="small" - required - fullWidth - /> - handleFieldChange('contactEmail', e.target.value)} - error={!!formErrors.contactEmail} - helperText={formErrors.contactEmail || 'Optional. Used for proposal PDF delivery.'} - size="small" - fullWidth - /> - - - - - - +
+ {isEditing ? 'Edit Customer' : 'New Customer'} + + + + + + + + + + + +
); diff --git a/web/src/pages/admin/dashboard/AdminDashboard.tsx b/web/src/pages/admin/dashboard/AdminDashboard.tsx index b664728..8bc8700 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 { useNavigate } from 'react-router-dom'; -import { useQuery } from '@tanstack/react-query'; import { Box, Typography, @@ -37,13 +36,13 @@ 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'; +import { useDebouncedValue } from '../../../hooks/useDebouncedValue'; const COL_COUNT = 11; const TAB_STATUSES = ['', 'InReview', 'Approved', 'Sent', 'Revised'] as const; @@ -75,33 +74,41 @@ 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 [page, setPage] = useState(DEFAULT_PAGE); + const [pageSize, setPageSize] = useState(DEFAULT_PAGE_SIZE); + + // Debounced search resets to page 1 (mirrors the old usePaginatedList behavior). + const debouncedSearch = useDebouncedValue(search, DEBOUNCE_SEARCH, () => setPage(1)); + 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 +134,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 +305,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 +321,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 +338,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) => ( @@ -392,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/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 */} diff --git a/web/src/pages/admin/workspace/AdminWorkspace.tsx b/web/src/pages/admin/workspace/AdminWorkspace.tsx index 87729e8..b7bb83b 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,70 @@ 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}`); - }, - }); - - const approveMutation = useMutation({ - mutationFn: async () => { - if (dirty) { - await saveMutation.mutateAsync(); - } - await adminApi.approveProposal(id!); - }, - onSuccess: () => { - queryClient.invalidateQueries({ queryKey: [QUERY_KEYS.proposals, id] }); - queryClient.invalidateQueries({ queryKey: [QUERY_KEYS.proposalLineItems, id] }); - setApproveDialogOpen(false); - setDirty(false); - toast.success('Proposal approved'); - }, - onError: (error: Error) => { - toast.error(`Approval failed: ${error.message}`); - }, - }); - - 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 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 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}`); - }, - }); - + // 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 = 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 returnToReviewMutation = useReturnToReview(id ?? ''); + const pdfMutation = useProposalPdf(id ?? ''); - 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'); + 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({ 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); } - }, - onError: () => { - toast.error('Failed to generate PDF'); - }, - }); + await approveMutation.mutateAsync(); + setApproveDialogOpen(false); + } catch { + // error toasts are handled by the domain hooks + } + }; + const approvePending = approveMutation.isPending || saveMutation.isPending; + + const handleConfirmSend = () => { + sendMutation.mutate(undefined, { onSuccess: () => setSendDialogOpen(false) }); + }; + + const handleConfirmRevise = () => { + reviseMutation.mutate(undefined, { onSuccess: () => setReviseDialogOpen(false) }); + }; + + const handleConfirmReturnToReview = () => { + returnToReviewMutation.mutate(undefined, { + onSuccess: () => setReturnToReviewDialogOpen(false), + }); + }; + + 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 +445,7 @@ export default function AdminWorkspace() { @@ -622,7 +589,7 @@ export default function AdminWorkspace() {