mirror of
https://github.com/Sea-Haven-Industries/proposal-system.git
synced 2026-09-30 06:33:13 +00:00
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:
parent
dfaee830f7
commit
efcb13838e
55 changed files with 2079 additions and 965 deletions
|
|
@ -65,7 +65,9 @@ public class CustomerService : ICustomerService
|
||||||
Id = Guid.NewGuid(),
|
Id = Guid.NewGuid(),
|
||||||
Name = request.Name,
|
Name = request.Name,
|
||||||
Addresses = request.Addresses != null ? JsonSerializer.Serialize(request.Addresses) : null,
|
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,
|
CreatedAt = now,
|
||||||
UpdatedAt = now,
|
UpdatedAt = now,
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,16 @@
|
||||||
"private": true,
|
"private": true,
|
||||||
"main": "./src/index.ts",
|
"main": "./src/index.ts",
|
||||||
"types": "./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": {
|
"scripts": {
|
||||||
"typecheck": "tsc --noEmit"
|
"typecheck": "tsc --noEmit"
|
||||||
},
|
},
|
||||||
|
|
|
||||||
30
web/package-lock.json
generated
30
web/package-lock.json
generated
|
|
@ -13,6 +13,7 @@
|
||||||
"@fontsource/dm-sans": "^5.2.8",
|
"@fontsource/dm-sans": "^5.2.8",
|
||||||
"@fontsource/jetbrains-mono": "^5.2.8",
|
"@fontsource/jetbrains-mono": "^5.2.8",
|
||||||
"@fontsource/montserrat": "^5.2.8",
|
"@fontsource/montserrat": "^5.2.8",
|
||||||
|
"@hookform/resolvers": "^5.4.0",
|
||||||
"@mui/icons-material": "^9.2.0",
|
"@mui/icons-material": "^9.2.0",
|
||||||
"@mui/material": "^9.1.1",
|
"@mui/material": "^9.1.1",
|
||||||
"@proposal-system/api-contracts": "file:../shared/api-contracts",
|
"@proposal-system/api-contracts": "file:../shared/api-contracts",
|
||||||
|
|
@ -22,6 +23,7 @@
|
||||||
"lucide-react": "^1.24.0",
|
"lucide-react": "^1.24.0",
|
||||||
"react": "^19.2.7",
|
"react": "^19.2.7",
|
||||||
"react-dom": "^19.2.7",
|
"react-dom": "^19.2.7",
|
||||||
|
"react-hook-form": "^7.81.0",
|
||||||
"react-redux": "^9.2.0",
|
"react-redux": "^9.2.0",
|
||||||
"react-router-dom": "^7.18.1",
|
"react-router-dom": "^7.18.1",
|
||||||
"react-toastify": "^11.0.5",
|
"react-toastify": "^11.0.5",
|
||||||
|
|
@ -625,6 +627,18 @@
|
||||||
"url": "https://github.com/sponsors/ayuhito"
|
"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": {
|
"node_modules/@jridgewell/gen-mapping": {
|
||||||
"version": "0.3.13",
|
"version": "0.3.13",
|
||||||
"resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz",
|
"resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz",
|
||||||
|
|
@ -3309,6 +3323,22 @@
|
||||||
"react": "^19.2.7"
|
"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": {
|
"node_modules/react-is": {
|
||||||
"version": "19.2.7",
|
"version": "19.2.7",
|
||||||
"resolved": "https://registry.npmjs.org/react-is/-/react-is-19.2.7.tgz",
|
"resolved": "https://registry.npmjs.org/react-is/-/react-is-19.2.7.tgz",
|
||||||
|
|
|
||||||
|
|
@ -17,6 +17,7 @@
|
||||||
"@fontsource/dm-sans": "^5.2.8",
|
"@fontsource/dm-sans": "^5.2.8",
|
||||||
"@fontsource/jetbrains-mono": "^5.2.8",
|
"@fontsource/jetbrains-mono": "^5.2.8",
|
||||||
"@fontsource/montserrat": "^5.2.8",
|
"@fontsource/montserrat": "^5.2.8",
|
||||||
|
"@hookform/resolvers": "^5.4.0",
|
||||||
"@mui/icons-material": "^9.2.0",
|
"@mui/icons-material": "^9.2.0",
|
||||||
"@mui/material": "^9.1.1",
|
"@mui/material": "^9.1.1",
|
||||||
"@proposal-system/api-contracts": "file:../shared/api-contracts",
|
"@proposal-system/api-contracts": "file:../shared/api-contracts",
|
||||||
|
|
@ -26,6 +27,7 @@
|
||||||
"lucide-react": "^1.24.0",
|
"lucide-react": "^1.24.0",
|
||||||
"react": "^19.2.7",
|
"react": "^19.2.7",
|
||||||
"react-dom": "^19.2.7",
|
"react-dom": "^19.2.7",
|
||||||
|
"react-hook-form": "^7.81.0",
|
||||||
"react-redux": "^9.2.0",
|
"react-redux": "^9.2.0",
|
||||||
"react-router-dom": "^7.18.1",
|
"react-router-dom": "^7.18.1",
|
||||||
"react-toastify": "^11.0.5",
|
"react-toastify": "^11.0.5",
|
||||||
|
|
|
||||||
|
|
@ -19,7 +19,7 @@ import ContentCopyIcon from '@mui/icons-material/ContentCopy';
|
||||||
import DeleteIcon from '@mui/icons-material/Delete';
|
import DeleteIcon from '@mui/icons-material/Delete';
|
||||||
import ArrowUpwardIcon from '@mui/icons-material/ArrowUpward';
|
import ArrowUpwardIcon from '@mui/icons-material/ArrowUpward';
|
||||||
import ArrowDownwardIcon from '@mui/icons-material/ArrowDownward';
|
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 {
|
export interface EditableLineItem {
|
||||||
id: string | null;
|
id: string | null;
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,3 @@
|
||||||
import { useQuery } from '@tanstack/react-query';
|
|
||||||
import {
|
import {
|
||||||
Box,
|
Box,
|
||||||
Typography,
|
Typography,
|
||||||
|
|
@ -11,25 +10,10 @@ import {
|
||||||
} from '@mui/material';
|
} from '@mui/material';
|
||||||
import ExpandMoreIcon from '@mui/icons-material/ExpandMore';
|
import ExpandMoreIcon from '@mui/icons-material/ExpandMore';
|
||||||
import ContentCopyIcon from '@mui/icons-material/ContentCopy';
|
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 { formatCurrency } from '../../lib/format';
|
||||||
import type { EditableLineItem } from './LineItemEditor';
|
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 {
|
interface SimilarProposalsPanelProps {
|
||||||
proposalId: string;
|
proposalId: string;
|
||||||
onPullLineItem: (item: Omit<EditableLineItem, 'id' | 'sortOrder' | 'source'>) => void;
|
onPullLineItem: (item: Omit<EditableLineItem, 'id' | 'sortOrder' | 'source'>) => void;
|
||||||
|
|
@ -37,11 +21,7 @@ interface SimilarProposalsPanelProps {
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function SimilarProposalsPanel({ proposalId, onPullLineItem, disabled }: SimilarProposalsPanelProps) {
|
export default function SimilarProposalsPanel({ proposalId, onPullLineItem, disabled }: SimilarProposalsPanelProps) {
|
||||||
const { data: similar, isLoading } = useQuery<SimilarProposal[]>({
|
const { data: similar, isLoading } = useSimilarProposals(proposalId);
|
||||||
queryKey: ['similarProposals', proposalId],
|
|
||||||
queryFn: () => adminApi.getSimilar(proposalId) as Promise<SimilarProposal[]>,
|
|
||||||
enabled: !!proposalId,
|
|
||||||
});
|
|
||||||
|
|
||||||
if (isLoading) {
|
if (isLoading) {
|
||||||
return (
|
return (
|
||||||
|
|
|
||||||
|
|
@ -1,37 +1,13 @@
|
||||||
import { useQuery } from '@tanstack/react-query';
|
|
||||||
import { Box, Typography, Skeleton, Chip } from '@mui/material';
|
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';
|
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 {
|
interface VendorDataPanelProps {
|
||||||
proposalId: string;
|
proposalId: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function VendorDataPanel({ proposalId }: VendorDataPanelProps) {
|
export default function VendorDataPanel({ proposalId }: VendorDataPanelProps) {
|
||||||
const { data: vendors, isLoading } = useQuery<VendorProposal[]>({
|
const { data: vendors, isLoading } = useVendorProposals(proposalId);
|
||||||
queryKey: ['vendorProposals', proposalId],
|
|
||||||
queryFn: async () => {
|
|
||||||
const res = await apiClient.get(`/proposals/${proposalId}/vendors`);
|
|
||||||
return res.data;
|
|
||||||
},
|
|
||||||
enabled: !!proposalId,
|
|
||||||
});
|
|
||||||
|
|
||||||
if (isLoading) {
|
if (isLoading) {
|
||||||
return <Skeleton variant="rectangular" height={60} />;
|
return <Skeleton variant="rectangular" height={60} />;
|
||||||
|
|
|
||||||
|
|
@ -12,6 +12,7 @@ export const DEFAULT_PAGE_SIZE = 12;
|
||||||
export const DEFAULT_PAGE = 1;
|
export const DEFAULT_PAGE = 1;
|
||||||
|
|
||||||
export const DEBOUNCE_SEARCH = 600;
|
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_TOKEN = 'proposal_system_token';
|
||||||
export const STORAGE_KEY_SIDEBAR = 'sidebarOpen';
|
export const STORAGE_KEY_SIDEBAR = 'sidebarOpen';
|
||||||
|
|
|
||||||
|
|
@ -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
47
web/src/domain/README.md
Normal 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`.
|
||||||
99
web/src/domain/__tests__/admin.use-cases.test.tsx
Normal file
99
web/src/domain/__tests__/admin.use-cases.test.tsx
Normal 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();
|
||||||
|
});
|
||||||
|
});
|
||||||
99
web/src/domain/__tests__/customers.use-cases.test.tsx
Normal file
99
web/src/domain/__tests__/customers.use-cases.test.tsx
Normal 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');
|
||||||
|
});
|
||||||
|
});
|
||||||
18
web/src/domain/__tests__/hookTestUtils.tsx
Normal file
18
web/src/domain/__tests__/hookTestUtils.tsx
Normal 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 };
|
||||||
|
}
|
||||||
100
web/src/domain/__tests__/lineItems.use-cases.test.tsx
Normal file
100
web/src/domain/__tests__/lineItems.use-cases.test.tsx
Normal 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();
|
||||||
|
});
|
||||||
|
});
|
||||||
126
web/src/domain/__tests__/proposals.use-cases.test.tsx
Normal file
126
web/src/domain/__tests__/proposals.use-cases.test.tsx
Normal 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');
|
||||||
|
});
|
||||||
|
});
|
||||||
137
web/src/domain/__tests__/schemas.test.ts
Normal file
137
web/src/domain/__tests__/schemas.test.ts
Normal 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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -1,8 +1,7 @@
|
||||||
import apiClient from './client';
|
// Admin domain — HTTP calls only (no hooks). Moved from lib/api/admin.ts.
|
||||||
// Fix: WEB-M5 — types from the shared contracts package.
|
import apiClient from '../../lib/api/client';
|
||||||
import type { ProposalDetail, UpdateProposalRequest, DashboardStats, AuditEntry } from '@proposal-system/api-contracts';
|
import type { ProposalDetail } from '../proposals/types';
|
||||||
|
import type { DashboardStats, UpdateProposalRequest, AuditEntry, SimilarProposal } from './types';
|
||||||
export type { DashboardStats, UpdateProposalRequest, AuditEntry } from '@proposal-system/api-contracts';
|
|
||||||
|
|
||||||
export const adminApi = {
|
export const adminApi = {
|
||||||
getDashboard: async (): Promise<DashboardStats> => {
|
getDashboard: async (): Promise<DashboardStats> => {
|
||||||
|
|
@ -44,7 +43,7 @@ export const adminApi = {
|
||||||
await apiClient.post(`/proposals/${id}/generate-suggestions`);
|
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`);
|
const res = await apiClient.get(`/proposals/${id}/similar`);
|
||||||
return res.data;
|
return res.data;
|
||||||
},
|
},
|
||||||
7
web/src/domain/admin/schemas.ts
Normal file
7
web/src/domain/admin/schemas.ts
Normal 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';
|
||||||
19
web/src/domain/admin/types.ts
Normal file
19
web/src/domain/admin/types.ts
Normal 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;
|
||||||
|
}>;
|
||||||
|
}
|
||||||
163
web/src/domain/admin/use-cases.ts
Normal file
163
web/src/domain/admin/use-cases.ts
Normal 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');
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
@ -1,23 +1,12 @@
|
||||||
import apiClient from './client';
|
// Customers domain — HTTP calls only (no hooks). Moved from lib/api/customers.ts.
|
||||||
// Fix: WEB-M5 — types from the shared contracts package. Note: the API takes
|
import apiClient from '../../lib/api/client';
|
||||||
// addresses: string[] (CustomerDtos.cs); the old local CreateCustomerRequest
|
|
||||||
// sent a singular `address` field the API silently dropped.
|
|
||||||
import type {
|
import type {
|
||||||
Customer,
|
Customer,
|
||||||
CustomerListParams,
|
CustomerListParams,
|
||||||
CreateCustomerRequest,
|
CreateCustomerRequest,
|
||||||
UpdateCustomerRequest,
|
UpdateCustomerRequest,
|
||||||
PagedResponse,
|
PagedCustomerResponse,
|
||||||
} from '@proposal-system/api-contracts';
|
} from './types';
|
||||||
|
|
||||||
export type {
|
|
||||||
Customer,
|
|
||||||
CustomerListParams,
|
|
||||||
CreateCustomerRequest,
|
|
||||||
UpdateCustomerRequest,
|
|
||||||
} from '@proposal-system/api-contracts';
|
|
||||||
|
|
||||||
export type PagedCustomerResponse = PagedResponse<Customer>;
|
|
||||||
|
|
||||||
export const customersApi = {
|
export const customersApi = {
|
||||||
/** GET /api/customers?query=<q> — search autocomplete (unchanged). */
|
/** GET /api/customers?query=<q> — search autocomplete (unchanged). */
|
||||||
50
web/src/domain/customers/schemas.ts
Normal file
50
web/src/domain/customers/schemas.ts
Normal 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(),
|
||||||
|
};
|
||||||
|
}
|
||||||
11
web/src/domain/customers/types.ts
Normal file
11
web/src/domain/customers/types.ts
Normal 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>;
|
||||||
68
web/src/domain/customers/use-cases.ts
Normal file
68
web/src/domain/customers/use-cases.ts
Normal 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}`);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
@ -1,14 +1,6 @@
|
||||||
import apiClient from './client';
|
// Line items domain — HTTP calls only (no hooks). Moved from lib/api/lineItems.ts.
|
||||||
// Fix: WEB-M5 — types from the shared contracts package.
|
import apiClient from '../../lib/api/client';
|
||||||
import type { LineItem, CreateLineItemRequest, UpdateLineItemEntry } from '@proposal-system/api-contracts';
|
import type { LineItem, CreateLineItemRequest, UpdateLineItemEntry } from './types';
|
||||||
|
|
||||||
export type {
|
|
||||||
PricingMode,
|
|
||||||
LineItemSource,
|
|
||||||
LineItem,
|
|
||||||
CreateLineItemRequest,
|
|
||||||
UpdateLineItemEntry,
|
|
||||||
} from '@proposal-system/api-contracts';
|
|
||||||
|
|
||||||
export const lineItemsApi = {
|
export const lineItemsApi = {
|
||||||
getAll: async (proposalId: string): Promise<LineItem[]> => {
|
getAll: async (proposalId: string): Promise<LineItem[]> => {
|
||||||
11
web/src/domain/lineItems/schemas.ts
Normal file
11
web/src/domain/lineItems/schemas.ts
Normal 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';
|
||||||
9
web/src/domain/lineItems/types.ts
Normal file
9
web/src/domain/lineItems/types.ts
Normal 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';
|
||||||
19
web/src/domain/lineItems/use-cases.ts
Normal file
19
web/src/domain/lineItems/use-cases.ts
Normal 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,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
@ -1,21 +1,12 @@
|
||||||
import apiClient from './client';
|
// Pricing library domain — HTTP calls only (no hooks). Moved from lib/api/pricingLibrary.ts.
|
||||||
// Fix: WEB-M5 — types from the shared contracts package.
|
import apiClient from '../../lib/api/client';
|
||||||
import type {
|
import type {
|
||||||
PricingLibraryItem,
|
PricingLibraryItem,
|
||||||
PricingLibraryListParams,
|
PricingLibraryListParams,
|
||||||
CreatePricingLibraryItemRequest,
|
CreatePricingLibraryItemRequest,
|
||||||
UpdatePricingLibraryItemRequest,
|
UpdatePricingLibraryItemRequest,
|
||||||
PagedResponse,
|
PagedPricingLibraryResponse,
|
||||||
} from '@proposal-system/api-contracts';
|
} from './types';
|
||||||
|
|
||||||
export type {
|
|
||||||
PricingLibraryItem,
|
|
||||||
PricingLibraryListParams,
|
|
||||||
CreatePricingLibraryItemRequest,
|
|
||||||
UpdatePricingLibraryItemRequest,
|
|
||||||
} from '@proposal-system/api-contracts';
|
|
||||||
|
|
||||||
export type PagedPricingLibraryResponse = PagedResponse<PricingLibraryItem>;
|
|
||||||
|
|
||||||
export const pricingLibraryApi = {
|
export const pricingLibraryApi = {
|
||||||
/** GET /api/pricing-library/list?page=&pageSize= — paginated list (admin/sysadmin). */
|
/** GET /api/pricing-library/list?page=&pageSize= — paginated list (admin/sysadmin). */
|
||||||
49
web/src/domain/pricingLibrary/schemas.ts
Normal file
49
web/src/domain/pricingLibrary/schemas.ts
Normal 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() } : {}),
|
||||||
|
};
|
||||||
|
}
|
||||||
11
web/src/domain/pricingLibrary/types.ts
Normal file
11
web/src/domain/pricingLibrary/types.ts
Normal 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>;
|
||||||
69
web/src/domain/pricingLibrary/use-cases.ts
Normal file
69
web/src/domain/pricingLibrary/use-cases.ts
Normal 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}`);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
@ -1,6 +1,5 @@
|
||||||
import apiClient from './client';
|
// Proposals domain — HTTP calls only (no hooks). Moved from lib/api/proposals.ts.
|
||||||
// Fix: WEB-M5 — types now come from the shared contracts package (single source
|
import apiClient from '../../lib/api/client';
|
||||||
// of truth aligned with the .NET DTOs); re-exported so page imports stay stable.
|
|
||||||
import type {
|
import type {
|
||||||
CreateProposalRequest,
|
CreateProposalRequest,
|
||||||
ProposalDetail,
|
ProposalDetail,
|
||||||
|
|
@ -10,20 +9,8 @@ import type {
|
||||||
PagedResponse,
|
PagedResponse,
|
||||||
PdfVersion,
|
PdfVersion,
|
||||||
PresignedUpload,
|
PresignedUpload,
|
||||||
} from '@proposal-system/api-contracts';
|
VendorProposal,
|
||||||
|
} from './types';
|
||||||
export type {
|
|
||||||
ServiceCategory,
|
|
||||||
Priority,
|
|
||||||
CreateProposalRequest,
|
|
||||||
ProposalDetail,
|
|
||||||
ProposalListItem,
|
|
||||||
ProposalFilters,
|
|
||||||
ProposalStats,
|
|
||||||
PagedResponse,
|
|
||||||
PdfVersion,
|
|
||||||
PresignedUpload,
|
|
||||||
} from '@proposal-system/api-contracts';
|
|
||||||
|
|
||||||
export const proposalsApi = {
|
export const proposalsApi = {
|
||||||
create: async (data: CreateProposalRequest): Promise<ProposalDetail> => {
|
create: async (data: CreateProposalRequest): Promise<ProposalDetail> => {
|
||||||
|
|
@ -68,6 +55,13 @@ export const proposalsApi = {
|
||||||
return res.data;
|
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> => {
|
getPdf: async (id: string): Promise<{ downloadUrl: string } | null> => {
|
||||||
const res = await apiClient.get(`/proposals/${id}/pdf`, {
|
const res = await apiClient.get(`/proposals/${id}/pdf`, {
|
||||||
responseType: 'blob',
|
responseType: 'blob',
|
||||||
|
|
@ -94,4 +88,21 @@ export const proposalsApi = {
|
||||||
const downloadUrl = URL.createObjectURL(blob);
|
const downloadUrl = URL.createObjectURL(blob);
|
||||||
return { downloadUrl };
|
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})`);
|
||||||
|
}
|
||||||
|
},
|
||||||
};
|
};
|
||||||
68
web/src/domain/proposals/schemas.ts
Normal file
68
web/src/domain/proposals/schemas.ts
Normal 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,
|
||||||
|
};
|
||||||
|
}
|
||||||
32
web/src/domain/proposals/types.ts
Normal file
32
web/src/domain/proposals/types.ts
Normal 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;
|
||||||
|
}
|
||||||
95
web/src/domain/proposals/use-cases.ts
Normal file
95
web/src/domain/proposals/use-cases.ts
Normal 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');
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
23
web/src/domain/shared/formFields.ts
Normal file
23
web/src/domain/shared/formFields.ts
Normal 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;
|
||||||
|
}
|
||||||
10
web/src/domain/sites/api.ts
Normal file
10
web/src/domain/sites/api.ts
Normal 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;
|
||||||
|
},
|
||||||
|
};
|
||||||
3
web/src/domain/sites/schemas.ts
Normal file
3
web/src/domain/sites/schemas.ts
Normal 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';
|
||||||
2
web/src/domain/sites/types.ts
Normal file
2
web/src/domain/sites/types.ts
Normal file
|
|
@ -0,0 +1,2 @@
|
||||||
|
// Sites domain — contract type re-exports.
|
||||||
|
export type { Site } from '@proposal-system/api-contracts';
|
||||||
18
web/src/domain/sites/use-cases.ts
Normal file
18
web/src/domain/sites/use-cases.ts
Normal 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,
|
||||||
|
});
|
||||||
|
}
|
||||||
32
web/src/hooks/useDebouncedValue.ts
Normal file
32
web/src/hooks/useDebouncedValue.ts
Normal 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;
|
||||||
|
}
|
||||||
|
|
@ -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]
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -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;
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
import { useQuery, useMutation } from '@tanstack/react-query';
|
import { useForm } from 'react-hook-form';
|
||||||
|
import { zodResolver } from '@hookform/resolvers/zod';
|
||||||
import {
|
import {
|
||||||
Box,
|
Box,
|
||||||
Typography,
|
Typography,
|
||||||
|
|
@ -28,64 +29,42 @@ import SearchIcon from '@mui/icons-material/Search';
|
||||||
import AddIcon from '@mui/icons-material/Add';
|
import AddIcon from '@mui/icons-material/Add';
|
||||||
import EditIcon from '@mui/icons-material/Edit';
|
import EditIcon from '@mui/icons-material/Edit';
|
||||||
import ErrorOutlineIcon from '@mui/icons-material/ErrorOutlined';
|
import ErrorOutlineIcon from '@mui/icons-material/ErrorOutlined';
|
||||||
import { toast } from 'react-toastify';
|
|
||||||
import {
|
import {
|
||||||
customersApi,
|
useCustomerList,
|
||||||
type Customer,
|
useCreateCustomer,
|
||||||
type PagedCustomerResponse,
|
useUpdateCustomer,
|
||||||
type CreateCustomerRequest,
|
} from '../../../domain/customers/use-cases';
|
||||||
type UpdateCustomerRequest,
|
import type { Customer } from '../../../domain/customers/types';
|
||||||
} from '../../../lib/api/customers';
|
import {
|
||||||
import { queryClient } from '../../../lib/queryClient';
|
customerFormSchema,
|
||||||
import { QUERY_KEYS } from '../../../constants/queryKeys';
|
toCustomerRequest,
|
||||||
|
type CustomerFormValues,
|
||||||
|
} from '../../../domain/customers/schemas';
|
||||||
import { DEFAULT_PAGE, DEFAULT_PAGE_SIZE, DEBOUNCE_SEARCH } from '../../../constants';
|
import { DEFAULT_PAGE, DEFAULT_PAGE_SIZE, DEBOUNCE_SEARCH } from '../../../constants';
|
||||||
import { useDocumentTitle } from '../../../hooks/useDocumentTitle';
|
import { useDocumentTitle } from '../../../hooks/useDocumentTitle';
|
||||||
|
import { useDebouncedValue } from '../../../hooks/useDebouncedValue';
|
||||||
|
|
||||||
const COL_COUNT = 4; // Name, Address, Contact Email, Actions
|
const COL_COUNT = 4; // Name, Address, Contact Email, Actions
|
||||||
|
|
||||||
/** Simple email format check (client-side only). */
|
const EMPTY_FORM: CustomerFormValues = { name: '', address: '', contactEmail: '' };
|
||||||
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: '' };
|
|
||||||
|
|
||||||
export default function CustomerManagementPage() {
|
export default function CustomerManagementPage() {
|
||||||
useDocumentTitle('Customer Management');
|
useDocumentTitle('Customer Management');
|
||||||
|
|
||||||
// --- List state ---
|
// --- List state ---
|
||||||
const [search, setSearch] = useState('');
|
const [search, setSearch] = useState('');
|
||||||
const [debouncedSearch, setDebouncedSearch] = useState('');
|
|
||||||
const [page, setPage] = useState(DEFAULT_PAGE);
|
const [page, setPage] = useState(DEFAULT_PAGE);
|
||||||
const [pageSize, setPageSize] = useState(DEFAULT_PAGE_SIZE);
|
const [pageSize, setPageSize] = useState(DEFAULT_PAGE_SIZE);
|
||||||
|
|
||||||
// Debounce search input
|
// Debounced search resets to page 1 (shared hook — the previous inline
|
||||||
const [debounceTimer, setDebounceTimer] = useState<ReturnType<typeof setTimeout> | null>(null);
|
// timer leaked on unmount).
|
||||||
|
const debouncedSearch = useDebouncedValue(search, DEBOUNCE_SEARCH, () => setPage(1));
|
||||||
|
const handleSearchChange = (value: string) => setSearch(value);
|
||||||
|
|
||||||
const handleSearchChange = (value: string) => {
|
const { data, isLoading, isError, refetch } = useCustomerList({
|
||||||
setSearch(value);
|
search: debouncedSearch || undefined,
|
||||||
if (debounceTimer) clearTimeout(debounceTimer);
|
page,
|
||||||
const timer = setTimeout(() => {
|
pageSize,
|
||||||
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 rows = data?.items ?? [];
|
const rows = data?.items ?? [];
|
||||||
|
|
@ -94,101 +73,59 @@ export default function CustomerManagementPage() {
|
||||||
// --- Dialog state ---
|
// --- Dialog state ---
|
||||||
const [dialogOpen, setDialogOpen] = useState(false);
|
const [dialogOpen, setDialogOpen] = useState(false);
|
||||||
const [editingCustomer, setEditingCustomer] = useState<Customer | null>(null);
|
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 isEditing = editingCustomer !== null;
|
||||||
|
|
||||||
|
const {
|
||||||
|
register,
|
||||||
|
handleSubmit,
|
||||||
|
reset,
|
||||||
|
formState: { errors },
|
||||||
|
} = useForm<CustomerFormValues>({
|
||||||
|
resolver: zodResolver(customerFormSchema),
|
||||||
|
defaultValues: EMPTY_FORM,
|
||||||
|
});
|
||||||
|
|
||||||
const openCreateDialog = () => {
|
const openCreateDialog = () => {
|
||||||
setEditingCustomer(null);
|
setEditingCustomer(null);
|
||||||
setForm(EMPTY_FORM);
|
reset(EMPTY_FORM);
|
||||||
setFormErrors({});
|
|
||||||
setDialogOpen(true);
|
setDialogOpen(true);
|
||||||
};
|
};
|
||||||
|
|
||||||
const openEditDialog = (customer: Customer) => {
|
const openEditDialog = (customer: Customer) => {
|
||||||
setEditingCustomer(customer);
|
setEditingCustomer(customer);
|
||||||
setForm({
|
reset({
|
||||||
name: customer.name,
|
name: customer.name,
|
||||||
address: customer.addresses?.[0] ?? '',
|
address: customer.addresses?.[0] ?? '',
|
||||||
contactEmail: customer.contactEmail ?? '',
|
contactEmail: customer.contactEmail ?? '',
|
||||||
});
|
});
|
||||||
setFormErrors({});
|
|
||||||
setDialogOpen(true);
|
setDialogOpen(true);
|
||||||
};
|
};
|
||||||
|
|
||||||
const closeDialog = () => {
|
const closeDialog = () => {
|
||||||
setDialogOpen(false);
|
setDialogOpen(false);
|
||||||
setEditingCustomer(null);
|
setEditingCustomer(null);
|
||||||
setForm(EMPTY_FORM);
|
reset(EMPTY_FORM);
|
||||||
setFormErrors({});
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const validateForm = (): boolean => {
|
// --- Mutations (domain hooks: invalidation + toasts live in the hooks) ---
|
||||||
const errors: Partial<Record<keyof CustomerFormState, string>> = {};
|
const createMutation = useCreateCustomer();
|
||||||
if (!form.name.trim()) errors.name = 'Name is required';
|
const updateMutation = useUpdateCustomer();
|
||||||
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;
|
|
||||||
|
|
||||||
|
const onSubmit = (values: CustomerFormValues) => {
|
||||||
// Fix: WEB-M5 — the API contract is addresses: string[] (CustomerDtos.cs);
|
// Fix: WEB-M5 — the API contract is addresses: string[] (CustomerDtos.cs);
|
||||||
// the old singular `address` field was silently dropped by model binding.
|
// the old singular `address` field was silently dropped by model binding.
|
||||||
// The form edits the primary address; any additional addresses are preserved.
|
// The form edits the primary address; any additional addresses are preserved
|
||||||
const payload = {
|
// (toCustomerRequest maps to addresses: [primary, ...rest]).
|
||||||
name: form.name.trim(),
|
const payload = toCustomerRequest(values, editingCustomer?.addresses ?? []);
|
||||||
addresses: [form.address.trim(), ...(editingCustomer?.addresses.slice(1) ?? [])],
|
|
||||||
...(form.contactEmail.trim() ? { contactEmail: form.contactEmail.trim() } : {}),
|
|
||||||
};
|
|
||||||
|
|
||||||
if (isEditing) {
|
if (isEditing) {
|
||||||
updateMutation.mutate({ id: editingCustomer.id, data: payload });
|
updateMutation.mutate(
|
||||||
|
{ id: editingCustomer.id, data: payload },
|
||||||
|
{ onSuccess: closeDialog }
|
||||||
|
);
|
||||||
} else {
|
} else {
|
||||||
createMutation.mutate(payload);
|
createMutation.mutate(payload, { onSuccess: closeDialog });
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -324,15 +261,15 @@ export default function CustomerManagementPage() {
|
||||||
|
|
||||||
{/* Create / Edit Dialog */}
|
{/* Create / Edit Dialog */}
|
||||||
<Dialog open={dialogOpen} onClose={closeDialog} maxWidth="sm" fullWidth>
|
<Dialog open={dialogOpen} onClose={closeDialog} maxWidth="sm" fullWidth>
|
||||||
|
<form onSubmit={handleSubmit(onSubmit)} noValidate>
|
||||||
<DialogTitle>{isEditing ? 'Edit Customer' : 'New Customer'}</DialogTitle>
|
<DialogTitle>{isEditing ? 'Edit Customer' : 'New Customer'}</DialogTitle>
|
||||||
<DialogContent>
|
<DialogContent>
|
||||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 2, mt: 1 }}>
|
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 2, mt: 1 }}>
|
||||||
<TextField
|
<TextField
|
||||||
label="Name"
|
label="Name"
|
||||||
value={form.name}
|
{...register('name')}
|
||||||
onChange={(e) => handleFieldChange('name', e.target.value)}
|
error={!!errors.name}
|
||||||
error={!!formErrors.name}
|
helperText={errors.name?.message}
|
||||||
helperText={formErrors.name}
|
|
||||||
size="small"
|
size="small"
|
||||||
required
|
required
|
||||||
fullWidth
|
fullWidth
|
||||||
|
|
@ -340,10 +277,9 @@ export default function CustomerManagementPage() {
|
||||||
/>
|
/>
|
||||||
<TextField
|
<TextField
|
||||||
label="Address"
|
label="Address"
|
||||||
value={form.address}
|
{...register('address')}
|
||||||
onChange={(e) => handleFieldChange('address', e.target.value)}
|
error={!!errors.address}
|
||||||
error={!!formErrors.address}
|
helperText={errors.address?.message}
|
||||||
helperText={formErrors.address}
|
|
||||||
size="small"
|
size="small"
|
||||||
required
|
required
|
||||||
fullWidth
|
fullWidth
|
||||||
|
|
@ -351,10 +287,9 @@ export default function CustomerManagementPage() {
|
||||||
<TextField
|
<TextField
|
||||||
label="Contact Email"
|
label="Contact Email"
|
||||||
type="email"
|
type="email"
|
||||||
value={form.contactEmail}
|
{...register('contactEmail')}
|
||||||
onChange={(e) => handleFieldChange('contactEmail', e.target.value)}
|
error={!!errors.contactEmail}
|
||||||
error={!!formErrors.contactEmail}
|
helperText={errors.contactEmail?.message || 'Optional. Used for proposal PDF delivery.'}
|
||||||
helperText={formErrors.contactEmail || 'Optional. Used for proposal PDF delivery.'}
|
|
||||||
size="small"
|
size="small"
|
||||||
fullWidth
|
fullWidth
|
||||||
/>
|
/>
|
||||||
|
|
@ -365,13 +300,14 @@ export default function CustomerManagementPage() {
|
||||||
Cancel
|
Cancel
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
|
type="submit"
|
||||||
variant="contained"
|
variant="contained"
|
||||||
onClick={handleSubmit}
|
|
||||||
disabled={isSaving}
|
disabled={isSaving}
|
||||||
>
|
>
|
||||||
{isSaving ? (isEditing ? 'Saving...' : 'Creating...') : isEditing ? 'Save Changes' : 'Create Customer'}
|
{isSaving ? (isEditing ? 'Saving...' : 'Creating...') : isEditing ? 'Save Changes' : 'Create Customer'}
|
||||||
</Button>
|
</Button>
|
||||||
</DialogActions>
|
</DialogActions>
|
||||||
|
</form>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
</Box>
|
</Box>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,5 @@
|
||||||
import { useMemo, useState, Fragment } from 'react';
|
import { useMemo, useState, Fragment } from 'react';
|
||||||
import { useNavigate } from 'react-router-dom';
|
import { useNavigate } from 'react-router-dom';
|
||||||
import { useQuery } from '@tanstack/react-query';
|
|
||||||
import {
|
import {
|
||||||
Box,
|
Box,
|
||||||
Typography,
|
Typography,
|
||||||
|
|
@ -37,13 +36,13 @@ import ReportIcon from '@mui/icons-material/Report';
|
||||||
import ErrorOutlineIcon from '@mui/icons-material/ErrorOutlined';
|
import ErrorOutlineIcon from '@mui/icons-material/ErrorOutlined';
|
||||||
import KeyboardArrowDownIcon from '@mui/icons-material/KeyboardArrowDown';
|
import KeyboardArrowDownIcon from '@mui/icons-material/KeyboardArrowDown';
|
||||||
import KeyboardArrowRightIcon from '@mui/icons-material/KeyboardArrowRight';
|
import KeyboardArrowRightIcon from '@mui/icons-material/KeyboardArrowRight';
|
||||||
import { usePaginatedList } from '../../../hooks/usePaginatedList';
|
import { useProposals } from '../../../domain/proposals/use-cases';
|
||||||
import { proposalsApi, type ProposalListItem } from '../../../lib/api/proposals';
|
import type { ProposalListItem } from '../../../domain/proposals/types';
|
||||||
import { adminApi, type DashboardStats } from '../../../lib/api/admin';
|
import { useAdminDashboard } from '../../../domain/admin/use-cases';
|
||||||
import { QUERY_KEYS } from '../../../constants/queryKeys';
|
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 { 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 { formatBidAmount, formatDate, formatDateFull, formatAge, getAgeColor, groupRevisions } from '../../../lib/format';
|
||||||
import { useDocumentTitle } from '../../../hooks/useDocumentTitle';
|
import { useDocumentTitle } from '../../../hooks/useDocumentTitle';
|
||||||
|
import { useDebouncedValue } from '../../../hooks/useDebouncedValue';
|
||||||
|
|
||||||
const COL_COUNT = 11;
|
const COL_COUNT = 11;
|
||||||
const TAB_STATUSES = ['', 'InReview', 'Approved', 'Sent', 'Revised'] as const;
|
const TAB_STATUSES = ['', 'InReview', 'Approved', 'Sent', 'Revised'] as const;
|
||||||
|
|
@ -75,33 +74,41 @@ export default function AdminDashboard({ defaultStatus }: { defaultStatus?: stri
|
||||||
const showTabs = !defaultStatus;
|
const showTabs = !defaultStatus;
|
||||||
|
|
||||||
// Fix: WEB-M8 — capture error state for admin stats to show error instead of misleading zeros
|
// 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>({
|
const { data: stats, isLoading: statsLoading, isError: statsError, refetch: refetchStats } = useAdminDashboard();
|
||||||
queryKey: [QUERY_KEYS.dashboard],
|
|
||||||
queryFn: adminApi.getDashboard,
|
|
||||||
});
|
|
||||||
|
|
||||||
const [statusFilter, setStatusFilter] = useState(defaultStatus ?? '');
|
const [statusFilter, setStatusFilter] = useState(defaultStatus ?? '');
|
||||||
const [categoryFilter, setCategoryFilter] = useState('');
|
const [categoryFilter, setCategoryFilter] = useState('');
|
||||||
const [priorityFilter, setPriorityFilter] = useState('');
|
const [priorityFilter, setPriorityFilter] = useState('');
|
||||||
const [expanded, setExpanded] = useState<Set<string>>(new Set());
|
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 {
|
const {
|
||||||
rows,
|
data: listData,
|
||||||
search,
|
isPending: listPending,
|
||||||
setSearch,
|
isPlaceholderData: listIsPlaceholder,
|
||||||
|
error: listError,
|
||||||
|
} = useProposals({
|
||||||
|
search: debouncedSearch,
|
||||||
page,
|
page,
|
||||||
setPage,
|
|
||||||
pageSize,
|
pageSize,
|
||||||
setPageSize,
|
|
||||||
totalCount,
|
|
||||||
loading,
|
|
||||||
err,
|
|
||||||
} = usePaginatedList<ProposalListItem>(proposalsApi.getAll, {
|
|
||||||
...(statusFilter && { status: statusFilter }),
|
...(statusFilter && { status: statusFilter }),
|
||||||
...(categoryFilter && { serviceCategory: categoryFilter }),
|
...(categoryFilter && { serviceCategory: categoryFilter }),
|
||||||
...(priorityFilter && { priority: priorityFilter }),
|
...(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 grouped = useMemo(() => groupRevisions(rows), [rows]);
|
||||||
|
|
||||||
const toggleExpand = (baseNumber: string) => {
|
const toggleExpand = (baseNumber: string) => {
|
||||||
|
|
@ -127,6 +134,7 @@ export default function AdminDashboard({ defaultStatus }: { defaultStatus?: stri
|
||||||
setCategoryFilter('');
|
setCategoryFilter('');
|
||||||
setPriorityFilter('');
|
setPriorityFilter('');
|
||||||
setSearch('');
|
setSearch('');
|
||||||
|
setPage(1);
|
||||||
};
|
};
|
||||||
|
|
||||||
const renderRow = (row: ProposalListItem, indent: boolean, showExpandIcon: boolean, isExpanded: boolean, baseNumber: string) => (
|
const renderRow = (row: ProposalListItem, indent: boolean, showExpandIcon: boolean, isExpanded: boolean, baseNumber: string) => (
|
||||||
|
|
@ -297,7 +305,10 @@ export default function AdminDashboard({ defaultStatus }: { defaultStatus?: stri
|
||||||
label="Category"
|
label="Category"
|
||||||
value={categoryFilter}
|
value={categoryFilter}
|
||||||
sx={{ width: 160 }}
|
sx={{ width: 160 }}
|
||||||
onChange={(e) => setCategoryFilter(e.target.value)}
|
onChange={(e) => {
|
||||||
|
setCategoryFilter(e.target.value);
|
||||||
|
setPage(1);
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
<MenuItem value="">All</MenuItem>
|
<MenuItem value="">All</MenuItem>
|
||||||
{SERVICE_CATEGORIES.map((c) => (
|
{SERVICE_CATEGORIES.map((c) => (
|
||||||
|
|
@ -310,7 +321,10 @@ export default function AdminDashboard({ defaultStatus }: { defaultStatus?: stri
|
||||||
label="Priority"
|
label="Priority"
|
||||||
value={priorityFilter}
|
value={priorityFilter}
|
||||||
sx={{ width: 200 }}
|
sx={{ width: 200 }}
|
||||||
onChange={(e) => setPriorityFilter(e.target.value)}
|
onChange={(e) => {
|
||||||
|
setPriorityFilter(e.target.value);
|
||||||
|
setPage(1);
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
<MenuItem value="">All</MenuItem>
|
<MenuItem value="">All</MenuItem>
|
||||||
{PRIORITIES.map((p) => (
|
{PRIORITIES.map((p) => (
|
||||||
|
|
@ -324,7 +338,10 @@ export default function AdminDashboard({ defaultStatus }: { defaultStatus?: stri
|
||||||
label="Status"
|
label="Status"
|
||||||
value={statusFilter}
|
value={statusFilter}
|
||||||
sx={{ width: 140 }}
|
sx={{ width: 140 }}
|
||||||
onChange={(e) => setStatusFilter(e.target.value)}
|
onChange={(e) => {
|
||||||
|
setStatusFilter(e.target.value);
|
||||||
|
setPage(1);
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
<MenuItem value="">All</MenuItem>
|
<MenuItem value="">All</MenuItem>
|
||||||
{PROPOSAL_STATUSES.map((s) => (
|
{PROPOSAL_STATUSES.map((s) => (
|
||||||
|
|
@ -392,7 +409,8 @@ export default function AdminDashboard({ defaultStatus }: { defaultStatus?: stri
|
||||||
</Fragment>
|
</Fragment>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
{!loading && rows.length === 0 && (
|
{/* Empty state only when the fetch actually succeeded. */}
|
||||||
|
{!loading && !err && rows.length === 0 && (
|
||||||
<TableRow>
|
<TableRow>
|
||||||
<TableCell colSpan={COL_COUNT} align="center" sx={{ py: 4 }}>
|
<TableCell colSpan={COL_COUNT} align="center" sx={{ py: 4 }}>
|
||||||
<Typography color="text.secondary" sx={{ mb: 1 }}>
|
<Typography color="text.secondary" sx={{ mb: 1 }}>
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
import { useState } from 'react';
|
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 {
|
import {
|
||||||
Box,
|
Box,
|
||||||
Typography,
|
Typography,
|
||||||
|
|
@ -29,17 +30,18 @@ import AddIcon from '@mui/icons-material/Add';
|
||||||
import EditIcon from '@mui/icons-material/Edit';
|
import EditIcon from '@mui/icons-material/Edit';
|
||||||
import DeleteIcon from '@mui/icons-material/Delete';
|
import DeleteIcon from '@mui/icons-material/Delete';
|
||||||
import ErrorOutlineIcon from '@mui/icons-material/ErrorOutlined';
|
import ErrorOutlineIcon from '@mui/icons-material/ErrorOutlined';
|
||||||
import { toast } from 'react-toastify';
|
|
||||||
import {
|
import {
|
||||||
pricingLibraryApi,
|
usePricingLibraryList,
|
||||||
type PricingLibraryItem,
|
useCreatePricingLibraryItem,
|
||||||
type PagedPricingLibraryResponse,
|
useUpdatePricingLibraryItem,
|
||||||
type CreatePricingLibraryItemRequest,
|
useDeletePricingLibraryItem,
|
||||||
type UpdatePricingLibraryItemRequest,
|
} from '../../../domain/pricingLibrary/use-cases';
|
||||||
} from '../../../lib/api/pricingLibrary';
|
import type { PricingLibraryItem } from '../../../domain/pricingLibrary/types';
|
||||||
import type { ServiceCategory } from '@proposal-system/api-contracts';
|
import {
|
||||||
import { queryClient } from '../../../lib/queryClient';
|
pricingLibraryItemFormSchema,
|
||||||
import { QUERY_KEYS } from '../../../constants/queryKeys';
|
toPricingLibraryItemRequest,
|
||||||
|
type PricingLibraryItemFormValues,
|
||||||
|
} from '../../../domain/pricingLibrary/schemas';
|
||||||
import { DEFAULT_PAGE, DEFAULT_PAGE_SIZE, SERVICE_CATEGORIES } from '../../../constants';
|
import { DEFAULT_PAGE, DEFAULT_PAGE_SIZE, SERVICE_CATEGORIES } from '../../../constants';
|
||||||
import { useDocumentTitle } from '../../../hooks/useDocumentTitle';
|
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' },
|
Other: { bgcolor: '#F1F5F9', color: '#475569', border: '1px solid #CBD5E1' },
|
||||||
};
|
};
|
||||||
|
|
||||||
interface ItemFormState {
|
/**
|
||||||
title: string;
|
* serviceCategory stays union-typed (ServiceCategory) in the form schema, so the
|
||||||
serviceCategory: ServiceCategory | '';
|
* create defaults leave it unset rather than using an out-of-union '' sentinel.
|
||||||
unit: string;
|
*/
|
||||||
unitPrice: string;
|
const EMPTY_FORM_VALUES: DefaultValues<PricingLibraryItemFormValues> = {
|
||||||
keywords: string;
|
|
||||||
description: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
const EMPTY_FORM: ItemFormState = {
|
|
||||||
title: '',
|
title: '',
|
||||||
serviceCategory: '',
|
|
||||||
unit: '',
|
unit: '',
|
||||||
unitPrice: '',
|
unitPrice: '',
|
||||||
keywords: '',
|
keywords: '',
|
||||||
|
|
@ -86,15 +82,7 @@ export default function PricingLibraryPage() {
|
||||||
const [page, setPage] = useState(DEFAULT_PAGE);
|
const [page, setPage] = useState(DEFAULT_PAGE);
|
||||||
const [pageSize, setPageSize] = useState(DEFAULT_PAGE_SIZE);
|
const [pageSize, setPageSize] = useState(DEFAULT_PAGE_SIZE);
|
||||||
|
|
||||||
const {
|
const { data, isLoading, isError, refetch } = usePricingLibraryList({ page, pageSize });
|
||||||
data,
|
|
||||||
isLoading,
|
|
||||||
isError,
|
|
||||||
refetch,
|
|
||||||
} = useQuery<PagedPricingLibraryResponse>({
|
|
||||||
queryKey: [QUERY_KEYS.pricingLibraryList, page, pageSize],
|
|
||||||
queryFn: () => pricingLibraryApi.list({ page, pageSize }),
|
|
||||||
});
|
|
||||||
|
|
||||||
const rows = data?.items ?? [];
|
const rows = data?.items ?? [];
|
||||||
const totalCount = data?.totalCount ?? 0;
|
const totalCount = data?.totalCount ?? 0;
|
||||||
|
|
@ -102,21 +90,28 @@ export default function PricingLibraryPage() {
|
||||||
// --- Create/Edit dialog state ---
|
// --- Create/Edit dialog state ---
|
||||||
const [dialogOpen, setDialogOpen] = useState(false);
|
const [dialogOpen, setDialogOpen] = useState(false);
|
||||||
const [editingItem, setEditingItem] = useState<PricingLibraryItem | null>(null);
|
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 isEditing = editingItem !== null;
|
||||||
|
|
||||||
|
const {
|
||||||
|
control,
|
||||||
|
handleSubmit,
|
||||||
|
reset,
|
||||||
|
formState: { errors },
|
||||||
|
} = useForm<PricingLibraryItemFormValues>({
|
||||||
|
resolver: zodResolver(pricingLibraryItemFormSchema),
|
||||||
|
defaultValues: EMPTY_FORM_VALUES,
|
||||||
|
});
|
||||||
|
|
||||||
const openCreateDialog = () => {
|
const openCreateDialog = () => {
|
||||||
setEditingItem(null);
|
setEditingItem(null);
|
||||||
setForm(EMPTY_FORM);
|
reset(EMPTY_FORM_VALUES);
|
||||||
setFormErrors({});
|
|
||||||
setDialogOpen(true);
|
setDialogOpen(true);
|
||||||
};
|
};
|
||||||
|
|
||||||
const openEditDialog = (item: PricingLibraryItem) => {
|
const openEditDialog = (item: PricingLibraryItem) => {
|
||||||
setEditingItem(item);
|
setEditingItem(item);
|
||||||
setForm({
|
reset({
|
||||||
title: item.title,
|
title: item.title,
|
||||||
serviceCategory: item.serviceCategory,
|
serviceCategory: item.serviceCategory,
|
||||||
unit: item.unit ?? '',
|
unit: item.unit ?? '',
|
||||||
|
|
@ -124,40 +119,13 @@ export default function PricingLibraryPage() {
|
||||||
keywords: item.keywords ?? '',
|
keywords: item.keywords ?? '',
|
||||||
description: item.description ?? '',
|
description: item.description ?? '',
|
||||||
});
|
});
|
||||||
setFormErrors({});
|
|
||||||
setDialogOpen(true);
|
setDialogOpen(true);
|
||||||
};
|
};
|
||||||
|
|
||||||
const closeDialog = () => {
|
const closeDialog = () => {
|
||||||
setDialogOpen(false);
|
setDialogOpen(false);
|
||||||
setEditingItem(null);
|
setEditingItem(null);
|
||||||
setForm(EMPTY_FORM);
|
reset(EMPTY_FORM_VALUES);
|
||||||
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;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
// --- Delete confirm dialog state ---
|
// --- Delete confirm dialog state ---
|
||||||
|
|
@ -171,67 +139,26 @@ export default function PricingLibraryPage() {
|
||||||
setDeleteTarget(null);
|
setDeleteTarget(null);
|
||||||
};
|
};
|
||||||
|
|
||||||
// --- Mutations ---
|
// --- Mutations (domain hooks own invalidation + toasts; dialog close stays here) ---
|
||||||
const createMutation = useMutation({
|
const createMutation = useCreatePricingLibraryItem();
|
||||||
mutationFn: (data: CreatePricingLibraryItemRequest) => pricingLibraryApi.create(data),
|
const updateMutation = useUpdatePricingLibraryItem();
|
||||||
onSuccess: () => {
|
const deleteMutation = useDeletePricingLibraryItem();
|
||||||
queryClient.invalidateQueries({ queryKey: [QUERY_KEYS.pricingLibraryList] });
|
|
||||||
closeDialog();
|
|
||||||
toast.success('Pricing item created');
|
|
||||||
},
|
|
||||||
onError: (error: Error) => {
|
|
||||||
toast.error(`Failed to create item: ${error.message}`);
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const updateMutation = useMutation({
|
const onSubmit = handleSubmit((values) => {
|
||||||
mutationFn: ({ id, data }: { id: string; data: UpdatePricingLibraryItemRequest }) =>
|
const payload = toPricingLibraryItemRequest(values);
|
||||||
pricingLibraryApi.update(id, data),
|
if (editingItem) {
|
||||||
onSuccess: () => {
|
updateMutation.mutate(
|
||||||
queryClient.invalidateQueries({ queryKey: [QUERY_KEYS.pricingLibraryList] });
|
{ id: editingItem.id, data: payload },
|
||||||
closeDialog();
|
{ onSuccess: () => 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 });
|
|
||||||
} else {
|
} else {
|
||||||
createMutation.mutate(payload);
|
createMutation.mutate(payload, { onSuccess: () => closeDialog() });
|
||||||
}
|
}
|
||||||
};
|
});
|
||||||
|
|
||||||
const handleDeleteConfirm = () => {
|
const handleDeleteConfirm = () => {
|
||||||
if (deleteTarget) {
|
if (deleteTarget) {
|
||||||
deleteMutation.mutate(deleteTarget.id);
|
deleteMutation.mutate(deleteTarget.id, { onSuccess: () => closeDeleteDialog() });
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -368,25 +295,35 @@ export default function PricingLibraryPage() {
|
||||||
{/* Create / Edit Dialog */}
|
{/* Create / Edit Dialog */}
|
||||||
<Dialog open={dialogOpen} onClose={closeDialog} maxWidth="sm" fullWidth>
|
<Dialog open={dialogOpen} onClose={closeDialog} maxWidth="sm" fullWidth>
|
||||||
<DialogTitle>{isEditing ? 'Edit Pricing Item' : 'New Pricing Item'}</DialogTitle>
|
<DialogTitle>{isEditing ? 'Edit Pricing Item' : 'New Pricing Item'}</DialogTitle>
|
||||||
|
<form onSubmit={onSubmit} noValidate>
|
||||||
<DialogContent>
|
<DialogContent>
|
||||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 2, mt: 1 }}>
|
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 2, mt: 1 }}>
|
||||||
|
<Controller
|
||||||
|
name="title"
|
||||||
|
control={control}
|
||||||
|
render={({ field }) => (
|
||||||
<TextField
|
<TextField
|
||||||
|
{...field}
|
||||||
label="Title"
|
label="Title"
|
||||||
value={form.title}
|
error={!!errors.title}
|
||||||
onChange={(e) => handleFieldChange('title', e.target.value)}
|
helperText={errors.title?.message}
|
||||||
error={!!formErrors.title}
|
|
||||||
helperText={formErrors.title}
|
|
||||||
size="small"
|
size="small"
|
||||||
required
|
required
|
||||||
fullWidth
|
fullWidth
|
||||||
autoFocus
|
autoFocus
|
||||||
/>
|
/>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<Controller
|
||||||
|
name="serviceCategory"
|
||||||
|
control={control}
|
||||||
|
render={({ field }) => (
|
||||||
<TextField
|
<TextField
|
||||||
|
{...field}
|
||||||
|
value={field.value ?? ''}
|
||||||
label="Service Category"
|
label="Service Category"
|
||||||
value={form.serviceCategory}
|
error={!!errors.serviceCategory}
|
||||||
onChange={(e) => handleFieldChange('serviceCategory', e.target.value)}
|
helperText={errors.serviceCategory ? 'Service category is required' : undefined}
|
||||||
error={!!formErrors.serviceCategory}
|
|
||||||
helperText={formErrors.serviceCategory}
|
|
||||||
size="small"
|
size="small"
|
||||||
required
|
required
|
||||||
fullWidth
|
fullWidth
|
||||||
|
|
@ -398,46 +335,68 @@ export default function PricingLibraryPage() {
|
||||||
</MenuItem>
|
</MenuItem>
|
||||||
))}
|
))}
|
||||||
</TextField>
|
</TextField>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<Controller
|
||||||
|
name="unit"
|
||||||
|
control={control}
|
||||||
|
render={({ field }) => (
|
||||||
<TextField
|
<TextField
|
||||||
|
{...field}
|
||||||
label="Unit"
|
label="Unit"
|
||||||
value={form.unit}
|
|
||||||
onChange={(e) => handleFieldChange('unit', e.target.value)}
|
|
||||||
helperText="e.g., each, hour, sq ft"
|
helperText="e.g., each, hour, sq ft"
|
||||||
size="small"
|
size="small"
|
||||||
fullWidth
|
fullWidth
|
||||||
/>
|
/>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<Controller
|
||||||
|
name="unitPrice"
|
||||||
|
control={control}
|
||||||
|
render={({ field }) => (
|
||||||
<TextField
|
<TextField
|
||||||
|
{...field}
|
||||||
label="Unit Price"
|
label="Unit Price"
|
||||||
type="number"
|
type="number"
|
||||||
value={form.unitPrice}
|
error={!!errors.unitPrice}
|
||||||
onChange={(e) => handleFieldChange('unitPrice', e.target.value)}
|
helperText={errors.unitPrice?.message || 'Optional. Leave blank if price varies.'}
|
||||||
error={!!formErrors.unitPrice}
|
|
||||||
helperText={formErrors.unitPrice || 'Optional. Leave blank if price varies.'}
|
|
||||||
size="small"
|
size="small"
|
||||||
fullWidth
|
fullWidth
|
||||||
slotProps={{
|
slotProps={{
|
||||||
htmlInput: { min: 0, step: '0.01' },
|
htmlInput: { min: 0, step: '0.01' },
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<Controller
|
||||||
|
name="keywords"
|
||||||
|
control={control}
|
||||||
|
render={({ field }) => (
|
||||||
<TextField
|
<TextField
|
||||||
|
{...field}
|
||||||
label="Keywords"
|
label="Keywords"
|
||||||
value={form.keywords}
|
|
||||||
onChange={(e) => handleFieldChange('keywords', e.target.value)}
|
|
||||||
helperText="Comma-separated keywords for AI matching."
|
helperText="Comma-separated keywords for AI matching."
|
||||||
size="small"
|
size="small"
|
||||||
fullWidth
|
fullWidth
|
||||||
multiline
|
multiline
|
||||||
minRows={2}
|
minRows={2}
|
||||||
/>
|
/>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<Controller
|
||||||
|
name="description"
|
||||||
|
control={control}
|
||||||
|
render={({ field }) => (
|
||||||
<TextField
|
<TextField
|
||||||
|
{...field}
|
||||||
label="Description"
|
label="Description"
|
||||||
value={form.description}
|
|
||||||
onChange={(e) => handleFieldChange('description', e.target.value)}
|
|
||||||
size="small"
|
size="small"
|
||||||
fullWidth
|
fullWidth
|
||||||
multiline
|
multiline
|
||||||
minRows={2}
|
minRows={2}
|
||||||
/>
|
/>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
</Box>
|
</Box>
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
<DialogActions>
|
<DialogActions>
|
||||||
|
|
@ -445,13 +404,14 @@ export default function PricingLibraryPage() {
|
||||||
Cancel
|
Cancel
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
|
type="submit"
|
||||||
variant="contained"
|
variant="contained"
|
||||||
onClick={handleSubmit}
|
|
||||||
disabled={isSaving}
|
disabled={isSaving}
|
||||||
>
|
>
|
||||||
{isSaving ? (isEditing ? 'Saving...' : 'Creating...') : isEditing ? 'Save Changes' : 'Create Item'}
|
{isSaving ? (isEditing ? 'Saving...' : 'Creating...') : isEditing ? 'Save Changes' : 'Create Item'}
|
||||||
</Button>
|
</Button>
|
||||||
</DialogActions>
|
</DialogActions>
|
||||||
|
</form>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
|
|
||||||
{/* Delete Confirm Dialog */}
|
{/* Delete Confirm Dialog */}
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,5 @@
|
||||||
import { useState, useEffect, useCallback, useRef } from 'react';
|
import { useState, useEffect, useCallback, useRef } from 'react';
|
||||||
import { useParams, useNavigate } from 'react-router-dom';
|
import { useParams, useNavigate } from 'react-router-dom';
|
||||||
import { useQuery, useMutation } from '@tanstack/react-query';
|
|
||||||
import {
|
import {
|
||||||
Box,
|
Box,
|
||||||
Typography,
|
Typography,
|
||||||
|
|
@ -31,16 +30,39 @@ import HistoryIcon from '@mui/icons-material/History';
|
||||||
import PictureAsPdfIcon from '@mui/icons-material/PictureAsPdf';
|
import PictureAsPdfIcon from '@mui/icons-material/PictureAsPdf';
|
||||||
import UndoIcon from '@mui/icons-material/Undo';
|
import UndoIcon from '@mui/icons-material/Undo';
|
||||||
import { toast } from 'react-toastify';
|
import { toast } from 'react-toastify';
|
||||||
import { proposalsApi, type ProposalDetail } from '../../../lib/api/proposals';
|
import { useProposal } from '../../../domain/proposals/use-cases';
|
||||||
import { lineItemsApi, type LineItem } from '../../../lib/api/lineItems';
|
import { useLineItems } from '../../../domain/lineItems/use-cases';
|
||||||
import { adminApi } from '../../../lib/api/admin';
|
import type { UpdateLineItemEntry } from '../../../domain/lineItems/types';
|
||||||
import { queryClient } from '../../../lib/queryClient';
|
import {
|
||||||
import { QUERY_KEYS } from '../../../constants/queryKeys';
|
useSimilarProposals,
|
||||||
|
useSaveProposalWorkspace,
|
||||||
|
useApproveProposal,
|
||||||
|
useSendProposal,
|
||||||
|
useReviseProposal,
|
||||||
|
useReturnToReview,
|
||||||
|
useGenerateSuggestions,
|
||||||
|
useProposalPdf,
|
||||||
|
} from '../../../domain/admin/use-cases';
|
||||||
import LineItemEditor, { type EditableLineItem } from '../../../components/admin/LineItemEditor';
|
import LineItemEditor, { type EditableLineItem } from '../../../components/admin/LineItemEditor';
|
||||||
import VendorDataPanel from '../../../components/admin/VendorDataPanel';
|
import VendorDataPanel from '../../../components/admin/VendorDataPanel';
|
||||||
import SimilarProposalsPanel from '../../../components/admin/SimilarProposalsPanel';
|
import SimilarProposalsPanel from '../../../components/admin/SimilarProposalsPanel';
|
||||||
import { STATUS_CHIP_STYLES } from '../../../constants';
|
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() {
|
export default function AdminWorkspace() {
|
||||||
const { id } = useParams<{ id: string }>();
|
const { id } = useParams<{ id: string }>();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
|
|
@ -55,24 +77,18 @@ export default function AdminWorkspace() {
|
||||||
const [dirty, setDirty] = useState(false);
|
const [dirty, setDirty] = useState(false);
|
||||||
|
|
||||||
// Fix: WEB-M7 — capture error state for failed fetch
|
// Fix: WEB-M7 — capture error state for failed fetch
|
||||||
const { data: proposal, isLoading, isError: isProposalError, error: proposalError, refetch: refetchProposal } = useQuery<ProposalDetail>({
|
const {
|
||||||
queryKey: [QUERY_KEYS.proposals, id],
|
data: proposal,
|
||||||
queryFn: () => proposalsApi.getById(id!),
|
isLoading,
|
||||||
enabled: !!id,
|
isError: isProposalError,
|
||||||
});
|
error: proposalError,
|
||||||
|
refetch: refetchProposal,
|
||||||
|
} = useProposal(id);
|
||||||
|
|
||||||
// Fix: WEB-M9 — capture loading state for line items skeleton
|
// Fix: WEB-M9 — capture loading state for line items skeleton
|
||||||
const { data: fetchedLineItems, isLoading: lineItemsLoading } = useQuery<LineItem[]>({
|
const { data: fetchedLineItems, isLoading: lineItemsLoading } = useLineItems(id);
|
||||||
queryKey: [QUERY_KEYS.proposalLineItems, id],
|
|
||||||
queryFn: () => lineItemsApi.getAll(id!),
|
|
||||||
enabled: !!id,
|
|
||||||
});
|
|
||||||
|
|
||||||
const { data: similarProposals } = useQuery({
|
const { data: similarProposals } = useSimilarProposals(id);
|
||||||
queryKey: ['similarProposals', id],
|
|
||||||
queryFn: () => adminApi.getSimilar(id!),
|
|
||||||
enabled: !!id,
|
|
||||||
});
|
|
||||||
|
|
||||||
const hasSimilar = similarProposals && similarProposals.length > 0;
|
const hasSimilar = similarProposals && similarProposals.length > 0;
|
||||||
|
|
||||||
|
|
@ -121,107 +137,60 @@ export default function AdminWorkspace() {
|
||||||
}
|
}
|
||||||
}, [dirty, navigate]);
|
}, [dirty, navigate]);
|
||||||
|
|
||||||
const saveMutation = useMutation({
|
// Domain mutations: cache invalidation + success/error toasts live in the
|
||||||
mutationFn: async () => {
|
// hooks; page-specific side effects (dialog close, dirty reset, window.open)
|
||||||
await adminApi.updateProposal(id!, { refinedScope });
|
// are applied here via the call-site callbacks (domain README rule 6).
|
||||||
await lineItemsApi.bulkUpdate(
|
const saveMutation = useSaveProposalWorkspace(id ?? '');
|
||||||
id!,
|
const approveMutation = useApproveProposal(id ?? '');
|
||||||
lineItems.map((li) => ({
|
const sendMutation = useSendProposal(id ?? '');
|
||||||
id: li.id,
|
const reviseMutation = useReviseProposal(id ?? '');
|
||||||
description: li.description,
|
const regenerateMutation = useGenerateSuggestions(id ?? '');
|
||||||
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}`);
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
// Fix: WEB-M11 — wire returnToReview API method to UI
|
// Fix: WEB-M11 — wire returnToReview API method to UI
|
||||||
const returnToReviewMutation = useMutation({
|
const returnToReviewMutation = useReturnToReview(id ?? '');
|
||||||
mutationFn: () => adminApi.returnToReview(id!),
|
const pdfMutation = useProposalPdf(id ?? '');
|
||||||
onSuccess: () => {
|
|
||||||
queryClient.invalidateQueries({ queryKey: [QUERY_KEYS.proposals, id] });
|
|
||||||
queryClient.invalidateQueries({ queryKey: [QUERY_KEYS.proposalLineItems, id] });
|
|
||||||
setReturnToReviewDialogOpen(false);
|
|
||||||
toast.success('Proposal returned to review');
|
|
||||||
},
|
|
||||||
onError: (error: Error) => {
|
|
||||||
toast.error(`Return to review failed: ${error.message}`);
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const pdfMutation = useMutation({
|
const handleSave = () => {
|
||||||
mutationFn: () => adminApi.getPdf(id!),
|
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) => {
|
onSuccess: (data) => {
|
||||||
if (data?.downloadUrl) {
|
if (data?.downloadUrl) {
|
||||||
window.open(data.downloadUrl, '_blank');
|
window.open(data.downloadUrl, '_blank');
|
||||||
|
|
@ -230,10 +199,8 @@ export default function AdminWorkspace() {
|
||||||
toast.info('PDF generation queued — try again in a few seconds');
|
toast.info('PDF generation queued — try again in a few seconds');
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
onError: () => {
|
|
||||||
toast.error('Failed to generate PDF');
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
|
};
|
||||||
|
|
||||||
const handleLineItemsChange = (updated: EditableLineItem[]) => {
|
const handleLineItemsChange = (updated: EditableLineItem[]) => {
|
||||||
setLineItems(updated);
|
setLineItems(updated);
|
||||||
|
|
@ -478,7 +445,7 @@ export default function AdminWorkspace() {
|
||||||
<Button
|
<Button
|
||||||
variant="contained"
|
variant="contained"
|
||||||
startIcon={<SaveIcon />}
|
startIcon={<SaveIcon />}
|
||||||
onClick={() => saveMutation.mutate()}
|
onClick={handleSave}
|
||||||
disabled={saveMutation.isPending || !dirty}
|
disabled={saveMutation.isPending || !dirty}
|
||||||
>
|
>
|
||||||
Save
|
Save
|
||||||
|
|
@ -515,7 +482,7 @@ export default function AdminWorkspace() {
|
||||||
<Button
|
<Button
|
||||||
variant="contained"
|
variant="contained"
|
||||||
startIcon={<PictureAsPdfIcon />}
|
startIcon={<PictureAsPdfIcon />}
|
||||||
onClick={() => pdfMutation.mutate()}
|
onClick={handleGeneratePdf}
|
||||||
disabled={pdfMutation.isPending}
|
disabled={pdfMutation.isPending}
|
||||||
>
|
>
|
||||||
{pdfMutation.isPending ? 'Generating...' : 'Generate PDF'}
|
{pdfMutation.isPending ? 'Generating...' : 'Generate PDF'}
|
||||||
|
|
@ -596,10 +563,10 @@ export default function AdminWorkspace() {
|
||||||
<Button
|
<Button
|
||||||
variant="contained"
|
variant="contained"
|
||||||
color="success"
|
color="success"
|
||||||
onClick={() => approveMutation.mutate()}
|
onClick={handleConfirmApprove}
|
||||||
disabled={approveMutation.isPending}
|
disabled={approvePending}
|
||||||
>
|
>
|
||||||
{approveMutation.isPending ? 'Approving...' : 'Confirm Approve'}
|
{approvePending ? 'Approving...' : 'Confirm Approve'}
|
||||||
</Button>
|
</Button>
|
||||||
</DialogActions>
|
</DialogActions>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
|
|
@ -622,7 +589,7 @@ export default function AdminWorkspace() {
|
||||||
<Button
|
<Button
|
||||||
variant="contained"
|
variant="contained"
|
||||||
color="success"
|
color="success"
|
||||||
onClick={() => sendMutation.mutate()}
|
onClick={handleConfirmSend}
|
||||||
disabled={sendMutation.isPending}
|
disabled={sendMutation.isPending}
|
||||||
>
|
>
|
||||||
{sendMutation.isPending ? 'Sending...' : 'Confirm Send'}
|
{sendMutation.isPending ? 'Sending...' : 'Confirm Send'}
|
||||||
|
|
@ -643,7 +610,7 @@ export default function AdminWorkspace() {
|
||||||
<Button onClick={() => setReviseDialogOpen(false)}>Cancel</Button>
|
<Button onClick={() => setReviseDialogOpen(false)}>Cancel</Button>
|
||||||
<Button
|
<Button
|
||||||
variant="contained"
|
variant="contained"
|
||||||
onClick={() => reviseMutation.mutate()}
|
onClick={handleConfirmRevise}
|
||||||
disabled={reviseMutation.isPending}
|
disabled={reviseMutation.isPending}
|
||||||
>
|
>
|
||||||
{reviseMutation.isPending ? 'Creating...' : 'Confirm Revision'}
|
{reviseMutation.isPending ? 'Creating...' : 'Confirm Revision'}
|
||||||
|
|
@ -665,7 +632,7 @@ export default function AdminWorkspace() {
|
||||||
<Button
|
<Button
|
||||||
variant="contained"
|
variant="contained"
|
||||||
color="warning"
|
color="warning"
|
||||||
onClick={() => returnToReviewMutation.mutate()}
|
onClick={handleConfirmReturnToReview}
|
||||||
disabled={returnToReviewMutation.isPending}
|
disabled={returnToReviewMutation.isPending}
|
||||||
>
|
>
|
||||||
{returnToReviewMutation.isPending ? 'Returning...' : 'Confirm Return'}
|
{returnToReviewMutation.isPending ? 'Returning...' : 'Confirm Return'}
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,4 @@
|
||||||
import { useNavigate } from 'react-router-dom';
|
import { useNavigate } from 'react-router-dom';
|
||||||
import { useQuery } from '@tanstack/react-query';
|
|
||||||
import {
|
import {
|
||||||
Box,
|
Box,
|
||||||
Typography,
|
Typography,
|
||||||
|
|
@ -24,8 +23,7 @@ import CheckCircleIcon from '@mui/icons-material/CheckCircle';
|
||||||
import SendIcon from '@mui/icons-material/Send';
|
import SendIcon from '@mui/icons-material/Send';
|
||||||
import ErrorOutlineIcon from '@mui/icons-material/ErrorOutlined';
|
import ErrorOutlineIcon from '@mui/icons-material/ErrorOutlined';
|
||||||
import Tooltip from '@mui/material/Tooltip';
|
import Tooltip from '@mui/material/Tooltip';
|
||||||
import { proposalsApi, type ProposalListItem, type ProposalStats } from '../../lib/api/proposals';
|
import { useProposals, useProposalStats } from '../../domain/proposals/use-cases';
|
||||||
import { QUERY_KEYS } from '../../constants/queryKeys';
|
|
||||||
import { STATUS_CHIP_STYLES } from '../../constants';
|
import { STATUS_CHIP_STYLES } from '../../constants';
|
||||||
import { formatCurrency, formatDate, formatDateFull } from '../../lib/format';
|
import { formatCurrency, formatDate, formatDateFull } from '../../lib/format';
|
||||||
import { useDocumentTitle } from '../../hooks/useDocumentTitle';
|
import { useDocumentTitle } from '../../hooks/useDocumentTitle';
|
||||||
|
|
@ -55,16 +53,10 @@ export default function Dashboard() {
|
||||||
useDocumentTitle('Dashboard');
|
useDocumentTitle('Dashboard');
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
|
|
||||||
const { data: recentData, isLoading: recentLoading, error: recentError } = useQuery<{ items: ProposalListItem[]; totalCount: number }>({
|
const { data: recentData, isLoading: recentLoading, isError: recentError } = useProposals({ page: 1, pageSize: 5, mine: true });
|
||||||
queryKey: [QUERY_KEYS.proposals, 'dashboard-recent'],
|
|
||||||
queryFn: () => proposalsApi.getAll({ page: 1, pageSize: 5, mine: true }),
|
|
||||||
});
|
|
||||||
|
|
||||||
// Fix: WEB-M8 — capture error state for stats to show error instead of misleading zeros
|
// 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>({
|
const { data: stats, isLoading: statsLoading, isError: statsError, refetch: refetchStats } = useProposalStats();
|
||||||
queryKey: [QUERY_KEYS.proposals, 'stats'],
|
|
||||||
queryFn: () => proposalsApi.getStats(),
|
|
||||||
});
|
|
||||||
|
|
||||||
const proposals = recentData?.items ?? [];
|
const proposals = recentData?.items ?? [];
|
||||||
const total = stats?.totalCount ?? 0;
|
const total = stats?.totalCount ?? 0;
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,4 @@
|
||||||
import { useParams, useNavigate } from 'react-router-dom';
|
import { useParams, useNavigate } from 'react-router-dom';
|
||||||
import { useQuery } from '@tanstack/react-query';
|
|
||||||
import {
|
import {
|
||||||
Box,
|
Box,
|
||||||
Typography,
|
Typography,
|
||||||
|
|
@ -15,7 +14,7 @@ import {
|
||||||
StepLabel,
|
StepLabel,
|
||||||
} from '@mui/material';
|
} from '@mui/material';
|
||||||
import ArrowBackIcon from '@mui/icons-material/ArrowBack';
|
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 { STATUS_CHIP_STYLES } from '../../../constants';
|
||||||
import { formatCurrency, formatDateTime } from '../../../lib/format';
|
import { formatCurrency, formatDateTime } from '../../../lib/format';
|
||||||
import { useDocumentTitle } from '../../../hooks/useDocumentTitle';
|
import { useDocumentTitle } from '../../../hooks/useDocumentTitle';
|
||||||
|
|
@ -37,11 +36,7 @@ export default function ProposalDetailPage() {
|
||||||
const { id } = useParams<{ id: string }>();
|
const { id } = useParams<{ id: string }>();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
|
|
||||||
const { data: proposal, isLoading, error } = useQuery<ProposalDetail>({
|
const { data: proposal, isLoading, error } = useProposal(id);
|
||||||
queryKey: ['proposals', id],
|
|
||||||
queryFn: () => proposalsApi.getById(id!),
|
|
||||||
enabled: !!id,
|
|
||||||
});
|
|
||||||
|
|
||||||
useDocumentTitle(proposal?.proposalNumber ?? 'Proposal');
|
useDocumentTitle(proposal?.proposalNumber ?? 'Proposal');
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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 { 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 {
|
import {
|
||||||
Box,
|
Box,
|
||||||
Typography,
|
Typography,
|
||||||
|
|
@ -15,44 +16,46 @@ import {
|
||||||
} from '@mui/material';
|
} from '@mui/material';
|
||||||
import SendIcon from '@mui/icons-material/Send';
|
import SendIcon from '@mui/icons-material/Send';
|
||||||
import { toast } from 'react-toastify';
|
import { toast } from 'react-toastify';
|
||||||
import { proposalsApi, type CreateProposalRequest, type ServiceCategory, type Priority } from '../../../lib/api/proposals';
|
import { useCreateProposal } from '../../../domain/proposals/use-cases';
|
||||||
import { sitesApi, type Site } from '../../../lib/api/sites';
|
import {
|
||||||
import { customersApi } from '../../../lib/api/customers';
|
createProposalFormSchema,
|
||||||
import { SERVICE_CATEGORIES, PRIORITIES, PRIORITY_LABELS } from '../../../constants';
|
toCreateProposalRequest,
|
||||||
import { queryClient } from '../../../lib/queryClient';
|
MIN_SCOPE_LENGTH,
|
||||||
|
type CreateProposalFormValues,
|
||||||
interface ProposalFormState {
|
} from '../../../domain/proposals/schemas';
|
||||||
workOrderNumber: string;
|
import { useSiteSearch } from '../../../domain/sites/use-cases';
|
||||||
poNumber?: string;
|
import { useCustomerSearch } from '../../../domain/customers/use-cases';
|
||||||
customerName: string;
|
import { SERVICE_CATEGORIES, PRIORITIES, PRIORITY_LABELS, DEBOUNCE_AUTOCOMPLETE } from '../../../constants';
|
||||||
customerAddress: string;
|
import { useDebouncedValue } from '../../../hooks/useDebouncedValue';
|
||||||
scopeOfWork: string;
|
|
||||||
serviceCategory: ServiceCategory | '';
|
|
||||||
priority: Priority;
|
|
||||||
notes: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
const MAX_FILE_SIZE_BYTES = 25 * 1024 * 1024;
|
const MAX_FILE_SIZE_BYTES = 25 * 1024 * 1024;
|
||||||
const ALLOWED_MIME_TYPES = ['application/pdf'];
|
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() {
|
export default function ProposalFormPage() {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
|
|
||||||
const [form, setForm] = useState<ProposalFormState>({
|
const {
|
||||||
|
register,
|
||||||
|
handleSubmit,
|
||||||
|
control,
|
||||||
|
setValue,
|
||||||
|
watch,
|
||||||
|
} = useForm<CreateProposalFormValues>({
|
||||||
|
resolver: zodResolver(createProposalFormSchema),
|
||||||
|
defaultValues: {
|
||||||
workOrderNumber: '',
|
workOrderNumber: '',
|
||||||
|
poNumber: '',
|
||||||
customerName: '',
|
customerName: '',
|
||||||
customerAddress: '',
|
customerAddress: '',
|
||||||
scopeOfWork: '',
|
scopeOfWork: '',
|
||||||
serviceCategory: '',
|
|
||||||
priority: 'Standard',
|
priority: 'Standard',
|
||||||
notes: '',
|
notes: '',
|
||||||
|
customCategory: '',
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const [vendorFile, setVendorFile] = useState<File | null>(null);
|
const [vendorFile, setVendorFile] = useState<File | null>(null);
|
||||||
const [siteOptions, setSiteOptions] = useState<Site[]>([]);
|
const [siteSearchInput, setSiteSearchInput] = useState('');
|
||||||
const [siteLoading, setSiteLoading] = useState(false);
|
|
||||||
const [siteInputValue, setSiteInputValue] = useState('');
|
const [siteInputValue, setSiteInputValue] = useState('');
|
||||||
const [manualEntry, setManualEntry] = useState(false);
|
const [manualEntry, setManualEntry] = useState(false);
|
||||||
const [manualSiteCode, setManualSiteCode] = useState('');
|
const [manualSiteCode, setManualSiteCode] = useState('');
|
||||||
|
|
@ -60,21 +63,24 @@ export default function ProposalFormPage() {
|
||||||
const [manualCity, setManualCity] = useState('');
|
const [manualCity, setManualCity] = useState('');
|
||||||
const [manualState, setManualState] = useState('');
|
const [manualState, setManualState] = useState('');
|
||||||
const [manualZip, setManualZip] = 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
|
// PR4: customer search state — freeSolo Autocomplete backed by the customers domain
|
||||||
const [customerOptions, setCustomerOptions] = useState<string[]>([]);
|
const [customerSearchInput, setCustomerSearchInput] = useState('');
|
||||||
const [customerLoading, setCustomerLoading] = useState(false);
|
|
||||||
const [customerInputValue, setCustomerInputValue] = useState('');
|
const [customerInputValue, setCustomerInputValue] = useState('');
|
||||||
const customerDebounceRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
|
||||||
|
|
||||||
useEffect(() => {
|
// Shared debounce hook (search input is only updated on typing, so
|
||||||
return () => {
|
// selecting an option never fires a search request).
|
||||||
if (siteDebounceRef.current) clearTimeout(siteDebounceRef.current);
|
const siteQuery = useDebouncedValue(siteSearchInput, DEBOUNCE_AUTOCOMPLETE);
|
||||||
if (customerDebounceRef.current) clearTimeout(customerDebounceRef.current);
|
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(() => {
|
useEffect(() => {
|
||||||
if (!manualEntry) return;
|
if (!manualEntry) return;
|
||||||
|
|
@ -88,116 +94,55 @@ export default function ProposalFormPage() {
|
||||||
manualSiteCode && address
|
manualSiteCode && address
|
||||||
? `${manualSiteCode} — ${address}`
|
? `${manualSiteCode} — ${address}`
|
||||||
: manualSiteCode || address;
|
: manualSiteCode || address;
|
||||||
setForm((prev) => ({ ...prev, customerAddress: display }));
|
setValue('customerAddress', display);
|
||||||
}, [manualEntry, manualSiteCode, manualStreet, manualCity, manualState, manualZip]);
|
}, [manualEntry, manualSiteCode, manualStreet, manualCity, manualState, manualZip, setValue]);
|
||||||
|
|
||||||
const handleChange = (field: keyof ProposalFormState, value: string) => {
|
const createMutation = useCreateProposal();
|
||||||
setForm((prev) => ({ ...prev, [field]: value }));
|
|
||||||
};
|
|
||||||
|
|
||||||
const searchSites = useCallback(async (query: string) => {
|
const onSubmit = (values: CreateProposalFormValues) => {
|
||||||
if (query.length < 2) {
|
createMutation.mutate(
|
||||||
setSiteOptions([]);
|
{ request: toCreateProposalRequest(values), vendorFile },
|
||||||
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;
|
|
||||||
},
|
|
||||||
onSuccess: (proposal) => {
|
onSuccess: (proposal) => {
|
||||||
queryClient.invalidateQueries({ queryKey: ['proposals'] });
|
|
||||||
toast.success(`Proposal ${proposal.proposalNumber} submitted`);
|
|
||||||
navigate(`/proposals/${proposal.id}`);
|
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
|
// Fix: WEB-M3 — enforce minimum scope length
|
||||||
const scopeTouched = form.scopeOfWork.length > 0;
|
const scopeTouched = scopeOfWork.length > 0;
|
||||||
const scopeTooShort = scopeTouched && form.scopeOfWork.trim().length < MIN_SCOPE_LENGTH;
|
const scopeTooShort = scopeTouched && scopeOfWork.trim().length < MIN_SCOPE_LENGTH;
|
||||||
|
|
||||||
const isValid =
|
|
||||||
form.workOrderNumber.trim() &&
|
|
||||||
form.customerName.trim() &&
|
|
||||||
form.customerAddress.trim() &&
|
|
||||||
form.scopeOfWork.trim().length >= MIN_SCOPE_LENGTH &&
|
|
||||||
form.serviceCategory &&
|
|
||||||
(form.serviceCategory !== 'Other' || customCategory.trim());
|
|
||||||
|
|
||||||
|
// 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 missingFields = useMemo(() => {
|
||||||
const missing: string[] = [];
|
const checks: Array<{ label: string; ok: boolean }> = [
|
||||||
if (!form.workOrderNumber.trim()) missing.push('Work Order Number');
|
{ label: 'Work Order Number', ok: !!workOrderNumber.trim() },
|
||||||
if (!form.customerAddress.trim()) missing.push('Site address');
|
{ label: 'Customer', ok: !!customerName.trim() },
|
||||||
if (!form.serviceCategory) missing.push('Service Category');
|
{ label: 'Site address', ok: !!customerAddress.trim() },
|
||||||
if (form.serviceCategory === 'Other' && !customCategory.trim()) missing.push('Specify category');
|
{ label: 'Service Category', ok: !!serviceCategory },
|
||||||
|
{ label: 'Specify category', ok: serviceCategory !== 'Other' || !!customCategory.trim() },
|
||||||
|
{
|
||||||
// Fix: WEB-M3 — show minimum length requirement in missing fields summary
|
// Fix: WEB-M3 — show minimum length requirement in missing fields summary
|
||||||
if (!form.scopeOfWork.trim()) missing.push('Scope of Work');
|
label: !scopeOfWork.trim() ? 'Scope of Work' : `Scope of Work (min ${MIN_SCOPE_LENGTH} chars)`,
|
||||||
else if (form.scopeOfWork.trim().length < MIN_SCOPE_LENGTH) missing.push(`Scope of Work (min ${MIN_SCOPE_LENGTH} chars)`);
|
ok: scopeOfWork.trim().length >= MIN_SCOPE_LENGTH,
|
||||||
return missing;
|
},
|
||||||
}, [form.workOrderNumber, form.customerAddress, form.serviceCategory, form.scopeOfWork, customCategory]);
|
];
|
||||||
|
return checks.filter((c) => !c.ok).map((c) => c.label);
|
||||||
|
}, [workOrderNumber, customerName, customerAddress, serviceCategory, scopeOfWork, customCategory]);
|
||||||
|
|
||||||
|
const isValid = missingFields.length === 0;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Box sx={{ maxWidth: 1200, mx: 'auto' }}>
|
<Box sx={{ maxWidth: 1200, mx: 'auto' }}>
|
||||||
|
|
@ -205,7 +150,7 @@ export default function ProposalFormPage() {
|
||||||
New Proposal Request
|
New Proposal Request
|
||||||
</Typography>
|
</Typography>
|
||||||
|
|
||||||
<Box component="form" onSubmit={handleSubmit}>
|
<Box component="form" onSubmit={handleSubmit(onSubmit)}>
|
||||||
<Card sx={{ mb: 2 }}>
|
<Card sx={{ mb: 2 }}>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
<Typography variant="subtitle1" sx={{ fontWeight: 600, mb: 2 }}>Job Details</Typography>
|
<Typography variant="subtitle1" sx={{ fontWeight: 600, mb: 2 }}>Job Details</Typography>
|
||||||
|
|
@ -215,16 +160,14 @@ export default function ProposalFormPage() {
|
||||||
label="Work Order Number"
|
label="Work Order Number"
|
||||||
required
|
required
|
||||||
fullWidth
|
fullWidth
|
||||||
value={form.workOrderNumber}
|
{...register('workOrderNumber')}
|
||||||
onChange={(e) => handleChange('workOrderNumber', e.target.value)}
|
|
||||||
/>
|
/>
|
||||||
</Grid>
|
</Grid>
|
||||||
<Grid size={{ xs: 12, sm: 6, md: 3 }}>
|
<Grid size={{ xs: 12, sm: 6, md: 3 }}>
|
||||||
<TextField
|
<TextField
|
||||||
label="Purchase Order Number"
|
label="Purchase Order Number"
|
||||||
fullWidth
|
fullWidth
|
||||||
value={form.poNumber ?? ''}
|
{...register('poNumber')}
|
||||||
onChange={(e) => handleChange('poNumber', e.target.value)}
|
|
||||||
/>
|
/>
|
||||||
</Grid>
|
</Grid>
|
||||||
{/* PR4: freeSolo customer search — dispatchers can pick an existing
|
{/* PR4: freeSolo customer search — dispatchers can pick an existing
|
||||||
|
|
@ -239,18 +182,17 @@ export default function ProposalFormPage() {
|
||||||
onInputChange={(_, value, reason) => {
|
onInputChange={(_, value, reason) => {
|
||||||
if (reason === 'input') {
|
if (reason === 'input') {
|
||||||
setCustomerInputValue(value);
|
setCustomerInputValue(value);
|
||||||
handleChange('customerName', value);
|
setValue('customerName', value);
|
||||||
if (customerDebounceRef.current) clearTimeout(customerDebounceRef.current);
|
setCustomerSearchInput(value);
|
||||||
customerDebounceRef.current = setTimeout(() => searchCustomers(value), 300);
|
|
||||||
} else if (reason === 'clear') {
|
} else if (reason === 'clear') {
|
||||||
setCustomerInputValue('');
|
setCustomerInputValue('');
|
||||||
handleChange('customerName', '');
|
setValue('customerName', '');
|
||||||
setCustomerOptions([]);
|
setCustomerSearchInput('');
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
onChange={(_, value) => {
|
onChange={(_, value) => {
|
||||||
const selected = typeof value === 'string' ? value : value ?? '';
|
const selected = typeof value === 'string' ? value : value ?? '';
|
||||||
handleChange('customerName', selected);
|
setValue('customerName', selected);
|
||||||
setCustomerInputValue(selected);
|
setCustomerInputValue(selected);
|
||||||
}}
|
}}
|
||||||
noOptionsText={
|
noOptionsText={
|
||||||
|
|
@ -283,13 +225,17 @@ export default function ProposalFormPage() {
|
||||||
/>
|
/>
|
||||||
</Grid>
|
</Grid>
|
||||||
<Grid size={{ xs: 12, sm: 6, md: 3 }}>
|
<Grid size={{ xs: 12, sm: 6, md: 3 }}>
|
||||||
|
<Controller
|
||||||
|
name="priority"
|
||||||
|
control={control}
|
||||||
|
render={({ field }) => (
|
||||||
<TextField
|
<TextField
|
||||||
label="Priority"
|
label="Priority"
|
||||||
required
|
required
|
||||||
fullWidth
|
fullWidth
|
||||||
select
|
select
|
||||||
value={form.priority}
|
value={field.value}
|
||||||
onChange={(e) => handleChange('priority', e.target.value)}
|
onChange={(e) => field.onChange(e.target.value)}
|
||||||
>
|
>
|
||||||
{PRIORITIES.map((p) => (
|
{PRIORITIES.map((p) => (
|
||||||
<MenuItem key={p} value={p}>
|
<MenuItem key={p} value={p}>
|
||||||
|
|
@ -297,6 +243,8 @@ export default function ProposalFormPage() {
|
||||||
</MenuItem>
|
</MenuItem>
|
||||||
))}
|
))}
|
||||||
</TextField>
|
</TextField>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
</Grid>
|
</Grid>
|
||||||
</Grid>
|
</Grid>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
|
|
@ -367,7 +315,7 @@ export default function ProposalFormPage() {
|
||||||
setManualState('');
|
setManualState('');
|
||||||
setManualZip('');
|
setManualZip('');
|
||||||
setSiteInputValue('');
|
setSiteInputValue('');
|
||||||
handleChange('customerAddress', '');
|
setValue('customerAddress', '');
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
Search sites instead
|
Search sites instead
|
||||||
|
|
@ -388,8 +336,7 @@ export default function ProposalFormPage() {
|
||||||
onInputChange={(_, value, reason) => {
|
onInputChange={(_, value, reason) => {
|
||||||
if (reason === 'input') {
|
if (reason === 'input') {
|
||||||
setSiteInputValue(value);
|
setSiteInputValue(value);
|
||||||
if (siteDebounceRef.current) clearTimeout(siteDebounceRef.current);
|
setSiteSearchInput(value);
|
||||||
siteDebounceRef.current = setTimeout(() => searchSites(value), 300);
|
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
onChange={(_, value) => {
|
onChange={(_, value) => {
|
||||||
|
|
@ -397,10 +344,10 @@ export default function ProposalFormPage() {
|
||||||
const display = value.fullAddress
|
const display = value.fullAddress
|
||||||
? `${value.siteCode} — ${value.fullAddress}`
|
? `${value.siteCode} — ${value.fullAddress}`
|
||||||
: value.siteCode;
|
: value.siteCode;
|
||||||
handleChange('customerAddress', display);
|
setValue('customerAddress', display);
|
||||||
setSiteInputValue(display);
|
setSiteInputValue(display);
|
||||||
} else {
|
} else {
|
||||||
handleChange('customerAddress', '');
|
setValue('customerAddress', '');
|
||||||
setSiteInputValue('');
|
setSiteInputValue('');
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
|
|
@ -450,13 +397,17 @@ export default function ProposalFormPage() {
|
||||||
<Typography variant="subtitle1" sx={{ fontWeight: 600, mb: 2 }}>Work Details</Typography>
|
<Typography variant="subtitle1" sx={{ fontWeight: 600, mb: 2 }}>Work Details</Typography>
|
||||||
<Grid container spacing={3}>
|
<Grid container spacing={3}>
|
||||||
<Grid size={{ xs: 12, sm: 6 }}>
|
<Grid size={{ xs: 12, sm: 6 }}>
|
||||||
|
<Controller
|
||||||
|
name="serviceCategory"
|
||||||
|
control={control}
|
||||||
|
render={({ field }) => (
|
||||||
<TextField
|
<TextField
|
||||||
label="Service Category"
|
label="Service Category"
|
||||||
required
|
required
|
||||||
fullWidth
|
fullWidth
|
||||||
select
|
select
|
||||||
value={form.serviceCategory}
|
value={field.value ?? ''}
|
||||||
onChange={(e) => handleChange('serviceCategory', e.target.value)}
|
onChange={(e) => field.onChange(e.target.value)}
|
||||||
>
|
>
|
||||||
{SERVICE_CATEGORIES.map((cat) => (
|
{SERVICE_CATEGORIES.map((cat) => (
|
||||||
<MenuItem key={cat} value={cat}>
|
<MenuItem key={cat} value={cat}>
|
||||||
|
|
@ -464,16 +415,17 @@ export default function ProposalFormPage() {
|
||||||
</MenuItem>
|
</MenuItem>
|
||||||
))}
|
))}
|
||||||
</TextField>
|
</TextField>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
</Grid>
|
</Grid>
|
||||||
|
|
||||||
{form.serviceCategory === 'Other' ? (
|
{serviceCategory === 'Other' ? (
|
||||||
<Grid size={{ xs: 12, sm: 6 }}>
|
<Grid size={{ xs: 12, sm: 6 }}>
|
||||||
<TextField
|
<TextField
|
||||||
label="Specify Category"
|
label="Specify Category"
|
||||||
required
|
required
|
||||||
fullWidth
|
fullWidth
|
||||||
value={customCategory}
|
{...register('customCategory')}
|
||||||
onChange={(e) => setCustomCategory(e.target.value)}
|
|
||||||
placeholder="e.g. Fire Protection"
|
placeholder="e.g. Fire Protection"
|
||||||
/>
|
/>
|
||||||
</Grid>
|
</Grid>
|
||||||
|
|
@ -489,13 +441,12 @@ export default function ProposalFormPage() {
|
||||||
fullWidth
|
fullWidth
|
||||||
multiline
|
multiline
|
||||||
minRows={4}
|
minRows={4}
|
||||||
value={form.scopeOfWork}
|
{...register('scopeOfWork')}
|
||||||
onChange={(e) => handleChange('scopeOfWork', e.target.value)}
|
|
||||||
placeholder="Describe the work needed..."
|
placeholder="Describe the work needed..."
|
||||||
error={scopeTooShort}
|
error={scopeTooShort}
|
||||||
helperText={
|
helperText={
|
||||||
scopeTooShort
|
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
|
: undefined
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
|
@ -507,8 +458,7 @@ export default function ProposalFormPage() {
|
||||||
fullWidth
|
fullWidth
|
||||||
multiline
|
multiline
|
||||||
minRows={2}
|
minRows={2}
|
||||||
value={form.notes}
|
{...register('notes')}
|
||||||
onChange={(e) => handleChange('notes', e.target.value)}
|
|
||||||
/>
|
/>
|
||||||
</Grid>
|
</Grid>
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,4 @@
|
||||||
|
import { useState } from 'react';
|
||||||
import { useNavigate } from 'react-router-dom';
|
import { useNavigate } from 'react-router-dom';
|
||||||
import {
|
import {
|
||||||
Box,
|
Box,
|
||||||
|
|
@ -19,28 +20,35 @@ import {
|
||||||
} from '@mui/material';
|
} from '@mui/material';
|
||||||
import SearchIcon from '@mui/icons-material/Search';
|
import SearchIcon from '@mui/icons-material/Search';
|
||||||
import AddCircleIcon from '@mui/icons-material/AddCircle';
|
import AddCircleIcon from '@mui/icons-material/AddCircle';
|
||||||
import { usePaginatedList } from '../../../hooks/usePaginatedList';
|
import { useProposals } from '../../../domain/proposals/use-cases';
|
||||||
import { proposalsApi, type ProposalListItem } from '../../../lib/api/proposals';
|
import { useDebouncedValue } from '../../../hooks/useDebouncedValue';
|
||||||
import { STATUS_CHIP_STYLES } from '../../../constants';
|
import { STATUS_CHIP_STYLES, DEBOUNCE_SEARCH, DEFAULT_PAGE, DEFAULT_PAGE_SIZE } from '../../../constants';
|
||||||
import { formatBidAmount, formatDate, formatDateFull } from '../../../lib/format';
|
import { formatBidAmount, formatDate, formatDateFull } from '../../../lib/format';
|
||||||
import { useDocumentTitle } from '../../../hooks/useDocumentTitle';
|
import { useDocumentTitle } from '../../../hooks/useDocumentTitle';
|
||||||
|
|
||||||
export default function ProposalListPage() {
|
export default function ProposalListPage() {
|
||||||
useDocumentTitle('My Proposals');
|
useDocumentTitle('My Proposals');
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const mineParams = { mine: true };
|
|
||||||
const {
|
const [search, setSearch] = useState('');
|
||||||
rows,
|
const [page, setPage] = useState(DEFAULT_PAGE);
|
||||||
search,
|
const [pageSize, setPageSize] = useState(DEFAULT_PAGE_SIZE);
|
||||||
setSearch,
|
|
||||||
|
// 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,
|
page,
|
||||||
setPage,
|
|
||||||
pageSize,
|
pageSize,
|
||||||
setPageSize,
|
mine: true,
|
||||||
totalCount,
|
});
|
||||||
loading,
|
|
||||||
err,
|
const rows = data?.items ?? [];
|
||||||
} = usePaginatedList<ProposalListItem>(proposalsApi.getAll, mineParams);
|
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 (
|
return (
|
||||||
<Box>
|
<Box>
|
||||||
|
|
@ -127,7 +135,7 @@ export default function ProposalListPage() {
|
||||||
<TableCell align="right">
|
<TableCell align="right">
|
||||||
{row.totalBidAmount > 0
|
{row.totalBidAmount > 0
|
||||||
? formatBidAmount(row.totalBidAmount)
|
? 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>
|
||||||
<TableCell>
|
<TableCell>
|
||||||
|
|
@ -135,7 +143,9 @@ export default function ProposalListPage() {
|
||||||
</TableCell>
|
</TableCell>
|
||||||
</TableRow>
|
</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>
|
<TableRow>
|
||||||
<TableCell colSpan={8} align="center" sx={{ py: 4 }}>
|
<TableCell colSpan={8} align="center" sx={{ py: 4 }}>
|
||||||
<Typography color="text.secondary">
|
<Typography color="text.secondary">
|
||||||
|
|
|
||||||
|
|
@ -18,7 +18,7 @@
|
||||||
"noUncheckedIndexedAccess": true,
|
"noUncheckedIndexedAccess": true,
|
||||||
"paths": {
|
"paths": {
|
||||||
"@proposal-system/api-contracts": ["../shared/api-contracts/src"],
|
"@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"]
|
"include": ["src", "../shared/api-contracts/src"]
|
||||||
|
|
|
||||||
|
|
@ -6,7 +6,8 @@ export default defineConfig({
|
||||||
resolve: {
|
resolve: {
|
||||||
// @proposal-system/api-contracts is a file: symlink; preserve it so its
|
// @proposal-system/api-contracts is a file: symlink; preserve it so its
|
||||||
// 'zod' import resolves from web/node_modules in CI (shared has no
|
// '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,
|
preserveSymlinks: true,
|
||||||
},
|
},
|
||||||
server: {
|
server: {
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,12 @@ import react from '@vitejs/plugin-react';
|
||||||
|
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
plugins: [react()],
|
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: {
|
test: {
|
||||||
environment: 'jsdom',
|
environment: 'jsdom',
|
||||||
globals: true,
|
globals: true,
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue