refactor(web): domain-layer restructure (SHOC layering) + react-hook-form (#223)

* feat(web): adopt SHOC design system and shell layout (ADR 0003)

Port shoc-frontend-new dev's design system with its CSS-variable
single-token-source mechanism:

- src/styles/theme.css: SHOC token file ported verbatim (Montserrat/
  DM Sans/JetBrains Mono, primary #1c75bc, navy #262262, full radius/
  shadow/sidebar/header token layers); fonts self-hosted via @fontsource
- src/lib/theme/{css-vars,mui-theme}.ts: getCssVar -> createTheme
  adapter mirroring SHOC's mui-theme.ts (palette, typography, shadows
  tuple, component overrides; MUI v9 slot renames expressed as class
  selectors); theme.ts is now a re-export
- Shell: SHOC composition (sidebar column + sticky gradient topbar +
  scrolling main); sidebar 244px/76px collapse with brand header row,
  grouped nav, SHOC active treatment (white card + 3px accent bar);
  topbar 100-degree gradient, surface hamburger, gradient avatar pill
- Brand: SeahavenMark + BrandLockup ported (Tailwind re-expressed as
  sx; wordmark subtitle localized to PROPOSAL SYSTEM)
- Login: SHOC auth-card treatment (centered 384px card on #f9fafb)
- Old "Sea Haven Ops" Inter/#2563EB theme and Nunito remnants removed;
  remaining hardcoded hexes replaced with tokens; lucide-react for
  shell/nav icons per SHOC convention

Verify: tsc clean, 26/26 vitest, vite build OK; Playwright screenshots
pixel-sampled against the extracted SHOC spec (all hard values exact,
no blocking deviations).

* feat(contracts): adopt shared api-contracts in web, add zod schemas and ProblemDetails codes

Closes WEB-M5 (web hand-duplicated wire types, standing drift risk):

- shared/api-contracts: rewritten as the authoritative superset of the
  .NET DTOs (ProposalListItem/ProposalDetail with poNumber and
  submittedByName, line item requests, customers, pricing library,
  dashboard, audit, sites, auth, presigned upload, ApiProblem); stale
  Proposal/UpdateLineItemsRequest shapes removed
- shared/api-contracts/src/schemas.ts: zod runtime schemas coupled to
  every wire type via `satisfies z.ZodType<T>` (schema/type drift is now
  a compile error); separate entrypoint so type-only consumers (mobile)
  never pull zod
- web: imports @proposal-system/api-contracts (file: dep + tsconfig
  paths + vite preserveSymlinks); all 7 lib/api modules re-export shared
  types so page imports stay stable; enum unions tightened
  (PricingLibraryPage form state now ServiceCategory-typed)
- fix(web): customer create/update sent a singular `address` field the
  API silently dropped (contract is addresses: string[], CustomerDtos.cs)
  - addresses now round-trip, extra addresses preserved on edit
- api: ProblemDetails responses carry a machine-readable top-level
  `code` (SHOC error-code vocabulary): ValidationFailed,
  InvalidStateTransition, NotFound, Unauthorized, InternalError; new
  BusinessRuleException(code, message) maps to 422 with its code;
  GlobalExceptionHandlerTests cover the full mapping (wire contract)

Cross-checked .NET DTOs vs TS types vs zod schemas with the
orchestrator scanner (Gemini): core domains consistent; internal-only
DTOs (FileDtos vendor/lambda surface, SimilarProposalDtos, UserDtos
admin surface) intentionally uncovered.

Verify: dotnet 166/166, web tsc + vitest 26/26 + build, mobile tsc,
shared tsc all green.

* feat(web): domain-layer conventions doc + react-hook-form deps (Phase 4 prep)

* feat(web): scaffold domain module layer (proposals, lineItems, customers, pricingLibrary, admin, sites)

Additive-only: pages still use lib/api/* and constants/queryKeys.ts until
the page-migration agents run. Each domain ships api.ts (HTTP moved from
lib/api), types.ts (contract re-exports + view types), schemas.ts (contract
schema re-exports + form schemas with toRequest mappers), and use-cases.ts
(TanStack Query v5 hooks + hierarchical query keys, mirroring current page
invalidations and toast-on-error behavior).

Adds an explicit vite/vitest alias for the
@proposal-system/api-contracts/schemas subpath (package has no exports map)
plus a schema/mapper smoke test suite.

* refactor(web): proposal pages on domain layer, proposal form on react-hook-form

* refactor(web): admin workspace on domain layer

* refactor(web): customer management on domain layer + react-hook-form

* refactor(web): pricing library on domain layer + react-hook-form

* refactor(web): dashboards on domain layer

* test(web): domain use-case hook coverage

* refactor(web): finish domain-layer integration — migrate straggler components, delete legacy api modules

- SimilarProposalsPanel -> useSimilarProposals (domain/admin); inline
  SimilarProposal type replaced by domain/admin/types (identical shape);
  query key joins the admin hierarchical key space
- LineItemEditor type imports -> domain/lineItems/types
- Delete now-orphaned lib/api/{proposals,lineItems,customers,
  pricingLibrary,admin,sites}.ts, constants/queryKeys.ts,
  hooks/usePaginatedList.ts (lib/api/client.ts + auth.ts stay per
  domain README rule 5)

Verify: tsc clean, vitest 52/52, build OK, Playwright smoke of the
authed shell renders on domain hooks.

* fix(web): apply Phase 4 code-review findings (10 correctness + 4 cleanup)

Correctness:
- State-transition mutations now invalidate every cached view via
  invalidateProposalViews (detail + line items + lists + stats + admin
  dashboard) — approving no longer leaves a stale queue for the
  5-minute staleTime
- Presigned S3 PUT moved to proposals/api.ts with res.ok check — a
  rejected upload is no longer confirmed as uploaded
- toCustomerRequest always sends contactEmail ('' clears); API create
  path normalizes empty->null to match the update path — customer
  emails can now be cleared from the UI
- Shared Number-based numeric form fields (domain/shared/formFields):
  '12abc' no longer silently coerces to 12 in the pricing library
- Customer create/update invalidate customersKeys.all so cached search
  autocompletes see new customers
- AdminWorkspace clears dirty right after a successful implicit save,
  before approve — no false unsaved-changes prompt when approve fails
- ProposalFormPage submit gate and missing-fields caption derive from
  ONE checks list (missing customer is now listed)
- Empty states gated on !err in ProposalListPage/AdminDashboard — no
  contradictory error + 'no proposals' UI
- VendorDataPanel migrated to useVendorProposals (kills the divergent
  ['vendorProposals', id] cache key and the inline apiClient query)
- useCustomerList/usePricingLibraryList get keepPreviousData — no
  TablePagination out-of-range flash on page change

Cleanup:
- Dead speculative hooks removed (useCreate/BulkUpdate/DeleteLineItem,
  useUpdateProposal, useProposalHistory/Audit, lineItemRowFormSchema,
  toUpdateLineItemEntry); tests moved to the live save path
  (useSaveProposalWorkspace)
- Shared useDebouncedValue hook replaces 4 drifted inline debounce
  copies (one leaked its timer on unmount, two hardcoded 300ms);
  DEBOUNCE_AUTOCOMPLETE=300 named
- Fix: WEB-H5 / WEB-H6 finding-ID markers restored at the relocated
  onError handlers (CLAUDE.md traceability)
- shared/api-contracts gains an exports map; /schemas resolver alias
  deduplicated from 3 copies to the tsconfig paths mapping

Verify: tsc clean, vitest 51/51 (tests updated to pin the new
invalidation/mapper behavior + new '12abc' rejection test),
vite build OK, dotnet 166/166.
This commit is contained in:
Adam Moussa 2026-07-13 20:11:11 -04:00 • committed by GitHub
parent dfaee830f7
commit efcb13838e
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
55 changed files with 2079 additions and 965 deletions

View file

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

View file

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

30
web/package-lock.json generated
View file

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

View file

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

View file

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

View file

@ -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<EditableLineItem, 'id' | 'sortOrder' | 'source'>) => void;
@ -37,11 +21,7 @@ interface SimilarProposalsPanelProps {
}
export default function SimilarProposalsPanel({ proposalId, onPullLineItem, disabled }: SimilarProposalsPanelProps) {
const { data: similar, isLoading } = useQuery<SimilarProposal[]>({
queryKey: ['similarProposals', proposalId],
queryFn: () => adminApi.getSimilar(proposalId) as Promise<SimilarProposal[]>,
enabled: !!proposalId,
});
const { data: similar, isLoading } = useSimilarProposals(proposalId);
if (isLoading) {
return (

View file

@ -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<VendorProposal[]>({
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 <Skeleton variant="rectangular" height={60} />;

View file

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

View file

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

47
web/src/domain/README.md Normal file
View file

@ -0,0 +1,47 @@
# Frontend domain layer — conventions
SHOC-alignment Phase 4 (mirrors shoc-frontend-new dev's `src/domain/<entity>/` layering).
Every agent/contributor working in this tree builds to THIS spec.
## Structure
```
src/domain/<entity>/
├── 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/<x>/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 <entity>Keys = { all: ['<entity>'] as const, detail: (id: string) => ['<entity>', 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/<domain>.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`.

View file

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

View file

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

View file

@ -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 }) => (
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
);
return { queryClient, wrapper };
}

View file

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

View file

@ -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<ProposalListItem> = {
items: [],
page: 2,
pageSize: 25,
totalCount: 0,
} as unknown as PagedResponse<ProposalListItem>;
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');
});
});

View file

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

View file

@ -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<DashboardStats> => {
@ -44,7 +43,7 @@ export const adminApi = {
await apiClient.post(`/proposals/${id}/generate-suggestions`);
},
getSimilar: async (id: string): Promise<unknown[]> => {
getSimilar: async (id: string): Promise<SimilarProposal[]> => {
const res = await apiClient.get(`/proposals/${id}/similar`);
return res.data;
},

View file

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

View file

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

View file

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

View file

@ -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<Customer>;
PagedCustomerResponse,
} from './types';
export const customersApi = {
/** GET /api/customers?query=<q> — search autocomplete (unchanged). */

View file

@ -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<typeof customerFormSchema>;
/**
* 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(),
};
}

View file

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

View file

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

View file

@ -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<LineItem[]> => {

View file

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

View file

@ -0,0 +1,9 @@
// Line items domain — contract type re-exports.
export type {
PricingMode,
LineItemSource,
LineItem,
CreateLineItemRequest,
UpdateLineItemEntry,
BulkUpdateLineItemsRequest,
} from '@proposal-system/api-contracts';

View file

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

View file

@ -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<PricingLibraryItem>;
PagedPricingLibraryResponse,
} from './types';
export const pricingLibraryApi = {
/** GET /api/pricing-library/list?page=&pageSize= — paginated list (admin/sysadmin). */

View file

@ -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<typeof pricingLibraryItemFormSchema>;
/**
* 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() } : {}),
};
}

View file

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

View file

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

View file

@ -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<ProposalDetail> => {
@ -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<VendorProposal[]> => {
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<void> => {
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})`);
}
},
};

View file

@ -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<typeof createProposalFormSchema>;
/** 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,
};
}

View file

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

View file

@ -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<ProposalDetail> => {
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');
},
});
}

View file

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

View file

@ -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<Site[]> => {
const res = await apiClient.get(`/sites?query=${encodeURIComponent(query)}`);
return res.data;
},
};

View file

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

View file

@ -0,0 +1,2 @@
// Sites domain — contract type re-exports.
export type { Site } from '@proposal-system/api-contracts';

View file

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

View file

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

View file

@ -1,76 +0,0 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { DEBOUNCE_SEARCH, DEFAULT_PAGE, DEFAULT_PAGE_SIZE } from '../constants';
interface PaginatedResult<T> {
items: T[];
totalCount: number;
}
type FetchFn<T> = (params: Record<string, unknown>) => Promise<PaginatedResult<T>>;
export function usePaginatedList<T>(fetchFn: FetchFn<T>, extraParams: Record<string, unknown> = {}) {
const [rows, setRows] = useState<T[]>([]);
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<ReturnType<typeof setTimeout> | 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]
);
}

View file

@ -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<Site[]> => {
const res = await apiClient.get(`/sites?query=${encodeURIComponent(query)}`);
return res.data;
},
};

View file

@ -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<ReturnType<typeof setTimeout> | 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<PagedCustomerResponse>({
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<Customer | null>(null);
const [form, setForm] = useState<CustomerFormState>(EMPTY_FORM);
const [formErrors, setFormErrors] = useState<Partial<Record<keyof CustomerFormState, string>>>({});
const isEditing = editingCustomer !== null;
const {
register,
handleSubmit,
reset,
formState: { errors },
} = useForm<CustomerFormValues>({
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<Record<keyof CustomerFormState, string>> = {};
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,15 +261,15 @@ export default function CustomerManagementPage() {
{/* Create / Edit Dialog */}
<Dialog open={dialogOpen} onClose={closeDialog} maxWidth="sm" fullWidth>
<form onSubmit={handleSubmit(onSubmit)} noValidate>
<DialogTitle>{isEditing ? 'Edit Customer' : 'New Customer'}</DialogTitle>
<DialogContent>
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 2, mt: 1 }}>
<TextField
label="Name"
value={form.name}
onChange={(e) => handleFieldChange('name', e.target.value)}
error={!!formErrors.name}
helperText={formErrors.name}
{...register('name')}
error={!!errors.name}
helperText={errors.name?.message}
size="small"
required
fullWidth
@ -340,10 +277,9 @@ export default function CustomerManagementPage() {
/>
<TextField
label="Address"
value={form.address}
onChange={(e) => handleFieldChange('address', e.target.value)}
error={!!formErrors.address}
helperText={formErrors.address}
{...register('address')}
error={!!errors.address}
helperText={errors.address?.message}
size="small"
required
fullWidth
@ -351,10 +287,9 @@ export default function CustomerManagementPage() {
<TextField
label="Contact Email"
type="email"
value={form.contactEmail}
onChange={(e) => handleFieldChange('contactEmail', e.target.value)}
error={!!formErrors.contactEmail}
helperText={formErrors.contactEmail || 'Optional. Used for proposal PDF delivery.'}
{...register('contactEmail')}
error={!!errors.contactEmail}
helperText={errors.contactEmail?.message || 'Optional. Used for proposal PDF delivery.'}
size="small"
fullWidth
/>
@ -365,13 +300,14 @@ export default function CustomerManagementPage() {
Cancel
</Button>
<Button
type="submit"
variant="contained"
onClick={handleSubmit}
disabled={isSaving}
>
{isSaving ? (isEditing ? 'Saving...' : 'Creating...') : isEditing ? 'Save Changes' : 'Create Customer'}
</Button>
</DialogActions>
</form>
</Dialog>
</Box>
);

View file

@ -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<DashboardStats>({
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<Set<string>>(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<ProposalListItem>(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);
}}
>
<MenuItem value="">All</MenuItem>
{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);
}}
>
<MenuItem value="">All</MenuItem>
{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);
}}
>
<MenuItem value="">All</MenuItem>
{PROPOSAL_STATUSES.map((s) => (
@ -392,7 +409,8 @@ export default function AdminDashboard({ defaultStatus }: { defaultStatus?: stri
</Fragment>
);
})}
{!loading && rows.length === 0 && (
{/* Empty state only when the fetch actually succeeded. */}
{!loading && !err && rows.length === 0 && (
<TableRow>
<TableCell colSpan={COL_COUNT} align="center" sx={{ py: 4 }}>
<Typography color="text.secondary" sx={{ mb: 1 }}>

View file

@ -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<string, { bgcolor: string; color: string; bor
Other: { bgcolor: '#F1F5F9', color: '#475569', border: '1px solid #CBD5E1' },
};
interface ItemFormState {
title: string;
serviceCategory: ServiceCategory | '';
unit: string;
unitPrice: string;
keywords: string;
description: string;
}
const EMPTY_FORM: ItemFormState = {
/**
* serviceCategory stays union-typed (ServiceCategory) in the form schema, so the
* create defaults leave it unset rather than using an out-of-union '' sentinel.
*/
const EMPTY_FORM_VALUES: DefaultValues<PricingLibraryItemFormValues> = {
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<PagedPricingLibraryResponse>({
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<PricingLibraryItem | null>(null);
const [form, setForm] = useState<ItemFormState>(EMPTY_FORM);
const [formErrors, setFormErrors] = useState<Partial<Record<keyof ItemFormState, string>>>({});
const isEditing = editingItem !== null;
const {
control,
handleSubmit,
reset,
formState: { errors },
} = useForm<PricingLibraryItemFormValues>({
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<Record<keyof ItemFormState, string>> = {};
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,25 +295,35 @@ export default function PricingLibraryPage() {
{/* Create / Edit Dialog */}
<Dialog open={dialogOpen} onClose={closeDialog} maxWidth="sm" fullWidth>
<DialogTitle>{isEditing ? 'Edit Pricing Item' : 'New Pricing Item'}</DialogTitle>
<form onSubmit={onSubmit} noValidate>
<DialogContent>
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 2, mt: 1 }}>
<Controller
name="title"
control={control}
render={({ field }) => (
<TextField
{...field}
label="Title"
value={form.title}
onChange={(e) => handleFieldChange('title', e.target.value)}
error={!!formErrors.title}
helperText={formErrors.title}
error={!!errors.title}
helperText={errors.title?.message}
size="small"
required
fullWidth
autoFocus
/>
)}
/>
<Controller
name="serviceCategory"
control={control}
render={({ field }) => (
<TextField
{...field}
value={field.value ?? ''}
label="Service Category"
value={form.serviceCategory}
onChange={(e) => handleFieldChange('serviceCategory', e.target.value)}
error={!!formErrors.serviceCategory}
helperText={formErrors.serviceCategory}
error={!!errors.serviceCategory}
helperText={errors.serviceCategory ? 'Service category is required' : undefined}
size="small"
required
fullWidth
@ -398,46 +335,68 @@ export default function PricingLibraryPage() {
</MenuItem>
))}
</TextField>
)}
/>
<Controller
name="unit"
control={control}
render={({ field }) => (
<TextField
{...field}
label="Unit"
value={form.unit}
onChange={(e) => handleFieldChange('unit', e.target.value)}
helperText="e.g., each, hour, sq ft"
size="small"
fullWidth
/>
)}
/>
<Controller
name="unitPrice"
control={control}
render={({ field }) => (
<TextField
{...field}
label="Unit Price"
type="number"
value={form.unitPrice}
onChange={(e) => handleFieldChange('unitPrice', e.target.value)}
error={!!formErrors.unitPrice}
helperText={formErrors.unitPrice || 'Optional. Leave blank if price varies.'}
error={!!errors.unitPrice}
helperText={errors.unitPrice?.message || 'Optional. Leave blank if price varies.'}
size="small"
fullWidth
slotProps={{
htmlInput: { min: 0, step: '0.01' },
}}
/>
)}
/>
<Controller
name="keywords"
control={control}
render={({ field }) => (
<TextField
{...field}
label="Keywords"
value={form.keywords}
onChange={(e) => handleFieldChange('keywords', e.target.value)}
helperText="Comma-separated keywords for AI matching."
size="small"
fullWidth
multiline
minRows={2}
/>
)}
/>
<Controller
name="description"
control={control}
render={({ field }) => (
<TextField
{...field}
label="Description"
value={form.description}
onChange={(e) => handleFieldChange('description', e.target.value)}
size="small"
fullWidth
multiline
minRows={2}
/>
)}
/>
</Box>
</DialogContent>
<DialogActions>
@ -445,13 +404,14 @@ export default function PricingLibraryPage() {
Cancel
</Button>
<Button
type="submit"
variant="contained"
onClick={handleSubmit}
disabled={isSaving}
>
{isSaving ? (isEditing ? 'Saving...' : 'Creating...') : isEditing ? 'Save Changes' : 'Create Item'}
</Button>
</DialogActions>
</form>
</Dialog>
{/* Delete Confirm Dialog */}

View file

@ -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<ProposalDetail>({
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<LineItem[]>({
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,107 +137,60 @@ 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!),
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);
}
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');
@ -230,10 +199,8 @@ export default function AdminWorkspace() {
toast.info('PDF generation queued — try again in a few seconds');
}
},
onError: () => {
toast.error('Failed to generate PDF');
},
});
};
const handleLineItemsChange = (updated: EditableLineItem[]) => {
setLineItems(updated);
@ -478,7 +445,7 @@ export default function AdminWorkspace() {
<Button
variant="contained"
startIcon={<SaveIcon />}
onClick={() => saveMutation.mutate()}
onClick={handleSave}
disabled={saveMutation.isPending || !dirty}
>
Save
@ -515,7 +482,7 @@ export default function AdminWorkspace() {
<Button
variant="contained"
startIcon={<PictureAsPdfIcon />}
onClick={() => pdfMutation.mutate()}
onClick={handleGeneratePdf}
disabled={pdfMutation.isPending}
>
{pdfMutation.isPending ? 'Generating...' : 'Generate PDF'}
@ -596,10 +563,10 @@ export default function AdminWorkspace() {
<Button
variant="contained"
color="success"
onClick={() => approveMutation.mutate()}
disabled={approveMutation.isPending}
onClick={handleConfirmApprove}
disabled={approvePending}
>
{approveMutation.isPending ? 'Approving...' : 'Confirm Approve'}
{approvePending ? 'Approving...' : 'Confirm Approve'}
</Button>
</DialogActions>
</Dialog>
@ -622,7 +589,7 @@ export default function AdminWorkspace() {
<Button
variant="contained"
color="success"
onClick={() => sendMutation.mutate()}
onClick={handleConfirmSend}
disabled={sendMutation.isPending}
>
{sendMutation.isPending ? 'Sending...' : 'Confirm Send'}
@ -643,7 +610,7 @@ export default function AdminWorkspace() {
<Button onClick={() => setReviseDialogOpen(false)}>Cancel</Button>
<Button
variant="contained"
onClick={() => reviseMutation.mutate()}
onClick={handleConfirmRevise}
disabled={reviseMutation.isPending}
>
{reviseMutation.isPending ? 'Creating...' : 'Confirm Revision'}
@ -665,7 +632,7 @@ export default function AdminWorkspace() {
<Button
variant="contained"
color="warning"
onClick={() => returnToReviewMutation.mutate()}
onClick={handleConfirmReturnToReview}
disabled={returnToReviewMutation.isPending}
>
{returnToReviewMutation.isPending ? 'Returning...' : 'Confirm Return'}

View file

@ -1,5 +1,4 @@
import { useNavigate } from 'react-router-dom';
import { useQuery } from '@tanstack/react-query';
import {
Box,
Typography,
@ -24,8 +23,7 @@ import CheckCircleIcon from '@mui/icons-material/CheckCircle';
import SendIcon from '@mui/icons-material/Send';
import ErrorOutlineIcon from '@mui/icons-material/ErrorOutlined';
import Tooltip from '@mui/material/Tooltip';
import { proposalsApi, type ProposalListItem, type ProposalStats } from '../../lib/api/proposals';
import { QUERY_KEYS } from '../../constants/queryKeys';
import { useProposals, useProposalStats } from '../../domain/proposals/use-cases';
import { STATUS_CHIP_STYLES } from '../../constants';
import { formatCurrency, formatDate, formatDateFull } from '../../lib/format';
import { useDocumentTitle } from '../../hooks/useDocumentTitle';
@ -55,16 +53,10 @@ export default function Dashboard() {
useDocumentTitle('Dashboard');
const navigate = useNavigate();
const { data: recentData, isLoading: recentLoading, error: recentError } = useQuery<{ items: ProposalListItem[]; totalCount: number }>({
queryKey: [QUERY_KEYS.proposals, 'dashboard-recent'],
queryFn: () => proposalsApi.getAll({ page: 1, pageSize: 5, mine: true }),
});
const { data: recentData, isLoading: recentLoading, isError: recentError } = useProposals({ page: 1, pageSize: 5, mine: true });
// Fix: WEB-M8 — capture error state for stats to show error instead of misleading zeros
const { data: stats, isLoading: statsLoading, isError: statsError, refetch: refetchStats } = useQuery<ProposalStats>({
queryKey: [QUERY_KEYS.proposals, 'stats'],
queryFn: () => proposalsApi.getStats(),
});
const { data: stats, isLoading: statsLoading, isError: statsError, refetch: refetchStats } = useProposalStats();
const proposals = recentData?.items ?? [];
const total = stats?.totalCount ?? 0;

View file

@ -1,5 +1,4 @@
import { useParams, useNavigate } from 'react-router-dom';
import { useQuery } from '@tanstack/react-query';
import {
Box,
Typography,
@ -15,7 +14,7 @@ import {
StepLabel,
} from '@mui/material';
import ArrowBackIcon from '@mui/icons-material/ArrowBack';
import { proposalsApi, type ProposalDetail } from '../../../lib/api/proposals';
import { useProposal } from '../../../domain/proposals/use-cases';
import { STATUS_CHIP_STYLES } from '../../../constants';
import { formatCurrency, formatDateTime } from '../../../lib/format';
import { useDocumentTitle } from '../../../hooks/useDocumentTitle';
@ -37,11 +36,7 @@ export default function ProposalDetailPage() {
const { id } = useParams<{ id: string }>();
const navigate = useNavigate();
const { data: proposal, isLoading, error } = useQuery<ProposalDetail>({
queryKey: ['proposals', id],
queryFn: () => proposalsApi.getById(id!),
enabled: !!id,
});
const { data: proposal, isLoading, error } = useProposal(id);
useDocumentTitle(proposal?.proposalNumber ?? 'Proposal');

View file

@ -1,6 +1,7 @@
import { useState, useCallback, useRef, useEffect, useMemo } from 'react';
import { useState, useEffect, useMemo } from 'react';
import { useNavigate } from 'react-router-dom';
import { useMutation } from '@tanstack/react-query';
import { Controller, useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import {
Box,
Typography,
@ -15,44 +16,46 @@ import {
} from '@mui/material';
import SendIcon from '@mui/icons-material/Send';
import { toast } from 'react-toastify';
import { proposalsApi, type CreateProposalRequest, type ServiceCategory, type Priority } from '../../../lib/api/proposals';
import { sitesApi, type Site } from '../../../lib/api/sites';
import { customersApi } from '../../../lib/api/customers';
import { SERVICE_CATEGORIES, PRIORITIES, PRIORITY_LABELS } from '../../../constants';
import { queryClient } from '../../../lib/queryClient';
interface ProposalFormState {
workOrderNumber: string;
poNumber?: string;
customerName: string;
customerAddress: string;
scopeOfWork: string;
serviceCategory: ServiceCategory | '';
priority: Priority;
notes: string;
}
import { useCreateProposal } from '../../../domain/proposals/use-cases';
import {
createProposalFormSchema,
toCreateProposalRequest,
MIN_SCOPE_LENGTH,
type CreateProposalFormValues,
} from '../../../domain/proposals/schemas';
import { useSiteSearch } from '../../../domain/sites/use-cases';
import { useCustomerSearch } from '../../../domain/customers/use-cases';
import { SERVICE_CATEGORIES, PRIORITIES, PRIORITY_LABELS, DEBOUNCE_AUTOCOMPLETE } from '../../../constants';
import { useDebouncedValue } from '../../../hooks/useDebouncedValue';
const MAX_FILE_SIZE_BYTES = 25 * 1024 * 1024;
const ALLOWED_MIME_TYPES = ['application/pdf'];
// Fix: WEB-M3 — minimum length for scope of work to ensure meaningful descriptions
const MIN_SCOPE_LENGTH = 10;
export default function ProposalFormPage() {
const navigate = useNavigate();
const [form, setForm] = useState<ProposalFormState>({
const {
register,
handleSubmit,
control,
setValue,
watch,
} = useForm<CreateProposalFormValues>({
resolver: zodResolver(createProposalFormSchema),
defaultValues: {
workOrderNumber: '',
poNumber: '',
customerName: '',
customerAddress: '',
scopeOfWork: '',
serviceCategory: '',
priority: 'Standard',
notes: '',
customCategory: '',
},
});
const [vendorFile, setVendorFile] = useState<File | null>(null);
const [siteOptions, setSiteOptions] = useState<Site[]>([]);
const [siteLoading, setSiteLoading] = useState(false);
const [siteSearchInput, setSiteSearchInput] = useState('');
const [siteInputValue, setSiteInputValue] = useState('');
const [manualEntry, setManualEntry] = useState(false);
const [manualSiteCode, setManualSiteCode] = useState('');
@ -60,21 +63,24 @@ export default function ProposalFormPage() {
const [manualCity, setManualCity] = useState('');
const [manualState, setManualState] = useState('');
const [manualZip, setManualZip] = useState('');
const [customCategory, setCustomCategory] = useState('');
const siteDebounceRef = useRef<ReturnType<typeof setTimeout> | null>(null);
// PR4: customer search state — freeSolo Autocomplete backed by customersApi.search
const [customerOptions, setCustomerOptions] = useState<string[]>([]);
const [customerLoading, setCustomerLoading] = useState(false);
// PR4: customer search state — freeSolo Autocomplete backed by the customers domain
const [customerSearchInput, setCustomerSearchInput] = useState('');
const [customerInputValue, setCustomerInputValue] = useState('');
const customerDebounceRef = useRef<ReturnType<typeof setTimeout> | null>(null);
useEffect(() => {
return () => {
if (siteDebounceRef.current) clearTimeout(siteDebounceRef.current);
if (customerDebounceRef.current) clearTimeout(customerDebounceRef.current);
};
}, []);
// Shared debounce hook (search input is only updated on typing, so
// selecting an option never fires a search request).
const siteQuery = useDebouncedValue(siteSearchInput, DEBOUNCE_AUTOCOMPLETE);
const customerQuery = useDebouncedValue(customerSearchInput, DEBOUNCE_AUTOCOMPLETE);
// Domain search hooks (disabled below 2 characters, matching prior behavior).
const siteSearch = useSiteSearch(siteQuery);
const siteOptions = siteSearch.data ?? [];
const siteLoading = siteSearch.isFetching;
const customerSearch = useCustomerSearch(customerQuery);
const customerOptions = (customerSearch.data ?? []).map((c) => c.name);
const customerLoading = customerSearch.isFetching;
useEffect(() => {
if (!manualEntry) return;
@ -88,116 +94,55 @@ export default function ProposalFormPage() {
manualSiteCode && address
? `${manualSiteCode} — ${address}`
: manualSiteCode || address;
setForm((prev) => ({ ...prev, customerAddress: display }));
}, [manualEntry, manualSiteCode, manualStreet, manualCity, manualState, manualZip]);
setValue('customerAddress', display);
}, [manualEntry, manualSiteCode, manualStreet, manualCity, manualState, manualZip, setValue]);
const handleChange = (field: keyof ProposalFormState, value: string) => {
setForm((prev) => ({ ...prev, [field]: value }));
};
const createMutation = useCreateProposal();
const searchSites = useCallback(async (query: string) => {
if (query.length < 2) {
setSiteOptions([]);
return;
}
setSiteLoading(true);
try {
const results = await sitesApi.search(query);
setSiteOptions(results);
} catch {
setSiteOptions([]);
} finally {
setSiteLoading(false);
}
}, []);
// PR4: search customers by name, debounced from the Autocomplete input
const searchCustomers = useCallback(async (query: string) => {
if (query.length < 2) {
setCustomerOptions([]);
return;
}
setCustomerLoading(true);
try {
const results = await customersApi.search(query);
setCustomerOptions(results.map((c) => c.name));
} catch {
setCustomerOptions([]);
} finally {
setCustomerLoading(false);
}
}, []);
const createMutation = useMutation({
mutationFn: async () => {
const notes = form.serviceCategory === 'Other' && customCategory.trim()
? `[Category: ${customCategory.trim()}]\n${form.notes || ''}`.trim()
: form.notes || undefined;
const request: CreateProposalRequest = {
workOrderNumber: form.workOrderNumber,
customerName: form.customerName,
customerAddress: form.customerAddress,
scopeOfWork: form.scopeOfWork,
serviceCategory: form.serviceCategory as ServiceCategory,
priority: form.priority,
notes,
};
const proposal = await proposalsApi.create(request);
if (vendorFile) {
try {
const { uploadUrl, vendorProposalId } = await proposalsApi.uploadAttachment(proposal.id, vendorFile.name);
await fetch(uploadUrl, {
method: 'PUT',
body: vendorFile,
headers: { 'Content-Type': vendorFile.type || 'application/pdf' },
});
await proposalsApi.confirmUpload(proposal.id, vendorProposalId);
} catch {
toast.warning(`Proposal ${proposal.proposalNumber} was created, but vendor PDF upload failed. You can re-upload from the proposal detail page.`);
}
}
return proposal;
},
const onSubmit = (values: CreateProposalFormValues) => {
createMutation.mutate(
{ request: toCreateProposalRequest(values), vendorFile },
{
onSuccess: (proposal) => {
queryClient.invalidateQueries({ queryKey: ['proposals'] });
toast.success(`Proposal ${proposal.proposalNumber} submitted`);
navigate(`/proposals/${proposal.id}`);
},
onError: (error: Error) => {
toast.error(error.message || 'Failed to submit proposal');
},
});
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
createMutation.mutate();
}
);
};
const workOrderNumber = watch('workOrderNumber');
const customerName = watch('customerName');
const customerAddress = watch('customerAddress');
const scopeOfWork = watch('scopeOfWork');
const serviceCategory = watch('serviceCategory');
const customCategory = watch('customCategory');
// Fix: WEB-M3 — enforce minimum scope length
const scopeTouched = form.scopeOfWork.length > 0;
const scopeTooShort = scopeTouched && form.scopeOfWork.trim().length < MIN_SCOPE_LENGTH;
const isValid =
form.workOrderNumber.trim() &&
form.customerName.trim() &&
form.customerAddress.trim() &&
form.scopeOfWork.trim().length >= MIN_SCOPE_LENGTH &&
form.serviceCategory &&
(form.serviceCategory !== 'Other' || customCategory.trim());
const scopeTouched = scopeOfWork.length > 0;
const scopeTooShort = scopeTouched && scopeOfWork.trim().length < MIN_SCOPE_LENGTH;
// SINGLE source for the submit gate and the missing-fields caption —
// mirrors createProposalFormSchema (zodResolver re-enforces at submit).
// Deriving both from one list keeps the button and the caption from
// drifting (the previous split logic disabled Submit for a missing
// customer while the caption never mentioned it).
const missingFields = useMemo(() => {
const missing: string[] = [];
if (!form.workOrderNumber.trim()) missing.push('Work Order Number');
if (!form.customerAddress.trim()) missing.push('Site address');
if (!form.serviceCategory) missing.push('Service Category');
if (form.serviceCategory === 'Other' && !customCategory.trim()) missing.push('Specify category');
const checks: Array<{ label: string; ok: boolean }> = [
{ label: 'Work Order Number', ok: !!workOrderNumber.trim() },
{ label: 'Customer', ok: !!customerName.trim() },
{ label: 'Site address', ok: !!customerAddress.trim() },
{ label: 'Service Category', ok: !!serviceCategory },
{ label: 'Specify category', ok: serviceCategory !== 'Other' || !!customCategory.trim() },
{
// Fix: WEB-M3 — show minimum length requirement in missing fields summary
if (!form.scopeOfWork.trim()) missing.push('Scope of Work');
else if (form.scopeOfWork.trim().length < MIN_SCOPE_LENGTH) missing.push(`Scope of Work (min ${MIN_SCOPE_LENGTH} chars)`);
return missing;
}, [form.workOrderNumber, form.customerAddress, form.serviceCategory, form.scopeOfWork, customCategory]);
label: !scopeOfWork.trim() ? 'Scope of Work' : `Scope of Work (min ${MIN_SCOPE_LENGTH} chars)`,
ok: scopeOfWork.trim().length >= MIN_SCOPE_LENGTH,
},
];
return checks.filter((c) => !c.ok).map((c) => c.label);
}, [workOrderNumber, customerName, customerAddress, serviceCategory, scopeOfWork, customCategory]);
const isValid = missingFields.length === 0;
return (
<Box sx={{ maxWidth: 1200, mx: 'auto' }}>
@ -205,7 +150,7 @@ export default function ProposalFormPage() {
New Proposal Request
</Typography>
<Box component="form" onSubmit={handleSubmit}>
<Box component="form" onSubmit={handleSubmit(onSubmit)}>
<Card sx={{ mb: 2 }}>
<CardContent>
<Typography variant="subtitle1" sx={{ fontWeight: 600, mb: 2 }}>Job Details</Typography>
@ -215,16 +160,14 @@ export default function ProposalFormPage() {
label="Work Order Number"
required
fullWidth
value={form.workOrderNumber}
onChange={(e) => handleChange('workOrderNumber', e.target.value)}
{...register('workOrderNumber')}
/>
</Grid>
<Grid size={{ xs: 12, sm: 6, md: 3 }}>
<TextField
label="Purchase Order Number"
fullWidth
value={form.poNumber ?? ''}
onChange={(e) => handleChange('poNumber', e.target.value)}
{...register('poNumber')}
/>
</Grid>
{/* PR4: freeSolo customer search — dispatchers can pick an existing
@ -239,18 +182,17 @@ export default function ProposalFormPage() {
onInputChange={(_, value, reason) => {
if (reason === 'input') {
setCustomerInputValue(value);
handleChange('customerName', value);
if (customerDebounceRef.current) clearTimeout(customerDebounceRef.current);
customerDebounceRef.current = setTimeout(() => searchCustomers(value), 300);
setValue('customerName', value);
setCustomerSearchInput(value);
} else if (reason === 'clear') {
setCustomerInputValue('');
handleChange('customerName', '');
setCustomerOptions([]);
setValue('customerName', '');
setCustomerSearchInput('');
}
}}
onChange={(_, value) => {
const selected = typeof value === 'string' ? value : value ?? '';
handleChange('customerName', selected);
setValue('customerName', selected);
setCustomerInputValue(selected);
}}
noOptionsText={
@ -283,13 +225,17 @@ export default function ProposalFormPage() {
/>
</Grid>
<Grid size={{ xs: 12, sm: 6, md: 3 }}>
<Controller
name="priority"
control={control}
render={({ field }) => (
<TextField
label="Priority"
required
fullWidth
select
value={form.priority}
onChange={(e) => handleChange('priority', e.target.value)}
value={field.value}
onChange={(e) => field.onChange(e.target.value)}
>
{PRIORITIES.map((p) => (
<MenuItem key={p} value={p}>
@ -297,6 +243,8 @@ export default function ProposalFormPage() {
</MenuItem>
))}
</TextField>
)}
/>
</Grid>
</Grid>
</CardContent>
@ -367,7 +315,7 @@ export default function ProposalFormPage() {
setManualState('');
setManualZip('');
setSiteInputValue('');
handleChange('customerAddress', '');
setValue('customerAddress', '');
}}
>
Search sites instead
@ -388,8 +336,7 @@ export default function ProposalFormPage() {
onInputChange={(_, value, reason) => {
if (reason === 'input') {
setSiteInputValue(value);
if (siteDebounceRef.current) clearTimeout(siteDebounceRef.current);
siteDebounceRef.current = setTimeout(() => searchSites(value), 300);
setSiteSearchInput(value);
}
}}
onChange={(_, value) => {
@ -397,10 +344,10 @@ export default function ProposalFormPage() {
const display = value.fullAddress
? `${value.siteCode} — ${value.fullAddress}`
: value.siteCode;
handleChange('customerAddress', display);
setValue('customerAddress', display);
setSiteInputValue(display);
} else {
handleChange('customerAddress', '');
setValue('customerAddress', '');
setSiteInputValue('');
}
}}
@ -450,13 +397,17 @@ export default function ProposalFormPage() {
<Typography variant="subtitle1" sx={{ fontWeight: 600, mb: 2 }}>Work Details</Typography>
<Grid container spacing={3}>
<Grid size={{ xs: 12, sm: 6 }}>
<Controller
name="serviceCategory"
control={control}
render={({ field }) => (
<TextField
label="Service Category"
required
fullWidth
select
value={form.serviceCategory}
onChange={(e) => handleChange('serviceCategory', e.target.value)}
value={field.value ?? ''}
onChange={(e) => field.onChange(e.target.value)}
>
{SERVICE_CATEGORIES.map((cat) => (
<MenuItem key={cat} value={cat}>
@ -464,16 +415,17 @@ export default function ProposalFormPage() {
</MenuItem>
))}
</TextField>
)}
/>
</Grid>
{form.serviceCategory === 'Other' ? (
{serviceCategory === 'Other' ? (
<Grid size={{ xs: 12, sm: 6 }}>
<TextField
label="Specify Category"
required
fullWidth
value={customCategory}
onChange={(e) => setCustomCategory(e.target.value)}
{...register('customCategory')}
placeholder="e.g. Fire Protection"
/>
</Grid>
@ -489,13 +441,12 @@ export default function ProposalFormPage() {
fullWidth
multiline
minRows={4}
value={form.scopeOfWork}
onChange={(e) => handleChange('scopeOfWork', e.target.value)}
{...register('scopeOfWork')}
placeholder="Describe the work needed..."
error={scopeTooShort}
helperText={
scopeTooShort
? `Scope must be at least ${MIN_SCOPE_LENGTH} characters (currently ${form.scopeOfWork.trim().length})`
? `Scope must be at least ${MIN_SCOPE_LENGTH} characters (currently ${scopeOfWork.trim().length})`
: undefined
}
/>
@ -507,8 +458,7 @@ export default function ProposalFormPage() {
fullWidth
multiline
minRows={2}
value={form.notes}
onChange={(e) => handleChange('notes', e.target.value)}
{...register('notes')}
/>
</Grid>

View file

@ -1,3 +1,4 @@
import { useState } from 'react';
import { useNavigate } from 'react-router-dom';
import {
Box,
@ -19,28 +20,35 @@ import {
} from '@mui/material';
import SearchIcon from '@mui/icons-material/Search';
import AddCircleIcon from '@mui/icons-material/AddCircle';
import { usePaginatedList } from '../../../hooks/usePaginatedList';
import { proposalsApi, type ProposalListItem } from '../../../lib/api/proposals';
import { STATUS_CHIP_STYLES } from '../../../constants';
import { useProposals } from '../../../domain/proposals/use-cases';
import { useDebouncedValue } from '../../../hooks/useDebouncedValue';
import { STATUS_CHIP_STYLES, DEBOUNCE_SEARCH, DEFAULT_PAGE, DEFAULT_PAGE_SIZE } from '../../../constants';
import { formatBidAmount, formatDate, formatDateFull } from '../../../lib/format';
import { useDocumentTitle } from '../../../hooks/useDocumentTitle';
export default function ProposalListPage() {
useDocumentTitle('My Proposals');
const navigate = useNavigate();
const mineParams = { mine: true };
const {
rows,
search,
setSearch,
const [search, setSearch] = useState('');
const [page, setPage] = useState(DEFAULT_PAGE);
const [pageSize, setPageSize] = useState(DEFAULT_PAGE_SIZE);
// Debounced search resets to page 1 (mirrors usePaginatedList).
const debouncedSearch = useDebouncedValue(search, DEBOUNCE_SEARCH, () => setPage(1));
const { data, isLoading, isPlaceholderData, error } = useProposals({
search: debouncedSearch,
page,
setPage,
pageSize,
setPageSize,
totalCount,
loading,
err,
} = usePaginatedList<ProposalListItem>(proposalsApi.getAll, mineParams);
mine: true,
});
const rows = data?.items ?? [];
const totalCount = data?.totalCount ?? 0;
// Skeletons on first load and while fetching a new page/search (placeholder rows shown).
const loading = isLoading || isPlaceholderData;
const err = error ? error.message || 'Failed to load' : '';
return (
<Box>
@ -127,7 +135,7 @@ export default function ProposalListPage() {
<TableCell align="right">
{row.totalBidAmount > 0
? formatBidAmount(row.totalBidAmount)
: <Typography variant="body2" component="span" sx={{ color: '#64748B' }}>Not priced</Typography>
: <Typography variant="body2" component="span" sx={{ color: 'var(--color-text-muted)' }}>Not priced</Typography>
}
</TableCell>
<TableCell>
@ -135,7 +143,9 @@ export default function ProposalListPage() {
</TableCell>
</TableRow>
))}
{!loading && rows.length === 0 && (
{/* Empty state only when the fetch actually succeeded — on error
the banner above is the whole story. */}
{!loading && !err && rows.length === 0 && (
<TableRow>
<TableCell colSpan={8} align="center" sx={{ py: 4 }}>
<Typography color="text.secondary">

View file

@ -18,7 +18,7 @@
"noUncheckedIndexedAccess": true,
"paths": {
"@proposal-system/api-contracts": ["../shared/api-contracts/src"],
"@proposal-system/api-contracts/schemas": ["../shared/api-contracts/src/schemas"]
"@proposal-system/api-contracts/schemas": ["../shared/api-contracts/src/schemas.ts"]
}
},
"include": ["src", "../shared/api-contracts/src"]

View file

@ -6,7 +6,8 @@ export default defineConfig({
resolve: {
// @proposal-system/api-contracts is a file: symlink; preserve it so its
// 'zod' import resolves from web/node_modules in CI (shared has no
// installed node_modules there).
// installed node_modules there). Subpath entrypoints (./schemas) resolve
// via the package's `exports` map.
preserveSymlinks: true,
},
server: {

View file

@ -3,6 +3,12 @@ import react from '@vitejs/plugin-react';
export default defineConfig({
plugins: [react()],
resolve: {
// @proposal-system/api-contracts is a file: symlink; subpath entrypoints
// (./schemas) resolve via the package's `exports` map. preserveSymlinks
// keeps its 'zod' import resolving from web/node_modules in CI.
preserveSymlinks: true,
},
test: {
environment: 'jsdom',
globals: true,