proposal-system/web/src/pages/proposals/detail/ProposalDetailPage.tsx
Adam Moussa efcb13838e
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.
2026-07-14 00:11:11 +00:00

162 lines
5.3 KiB
TypeScript

import { useParams, useNavigate } from 'react-router-dom';
import {
Box,
Typography,
Card,
CardContent,
Grid,
Chip,
Divider,
Button,
Skeleton,
Stepper,
Step,
StepLabel,
} from '@mui/material';
import ArrowBackIcon from '@mui/icons-material/ArrowBack';
import { useProposal } from '../../../domain/proposals/use-cases';
import { STATUS_CHIP_STYLES } from '../../../constants';
import { formatCurrency, formatDateTime } from '../../../lib/format';
import { useDocumentTitle } from '../../../hooks/useDocumentTitle';
const STATUS_ORDER = ['Draft', 'InReview', 'Approved', 'Sent'];
function InfoRow({ label, value }: { label: string; value: React.ReactNode }) {
return (
<Grid size={{ xs: 12, sm: 6, md: 4 }}>
<Typography variant="caption" color="text.secondary">
{label}
</Typography>
<Typography variant="body1">{value || '-'}</Typography>
</Grid>
);
}
export default function ProposalDetailPage() {
const { id } = useParams<{ id: string }>();
const navigate = useNavigate();
const { data: proposal, isLoading, error } = useProposal(id);
useDocumentTitle(proposal?.proposalNumber ?? 'Proposal');
if (isLoading) {
return (
<Box>
<Skeleton variant="text" width={300} height={40} />
<Skeleton variant="rectangular" height={400} sx={{ mt: 2, borderRadius: 1 }} />
</Box>
);
}
if (error || !proposal) {
return (
<Box>
<Typography color="error">
{error instanceof Error ? error.message : 'Proposal not found'}
</Typography>
<Button startIcon={<ArrowBackIcon />} onClick={() => navigate('/proposals')} sx={{ mt: 2 }}>
Back to Proposals
</Button>
</Box>
);
}
const activeStep = STATUS_ORDER.indexOf(proposal.status);
return (
<Box>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 2, mb: 3 }}>
<Button startIcon={<ArrowBackIcon />} onClick={() => navigate('/proposals')}>
Back
</Button>
<Typography variant="h5" sx={{ flexGrow: 1 }}>
{proposal.proposalNumber}
</Typography>
<Chip
label={proposal.status}
size="medium"
sx={STATUS_CHIP_STYLES[proposal.status]}
/>
</Box>
<Card sx={{ mb: 3 }}>
<CardContent>
<Typography variant="subtitle2" sx={{ mb: 2 }}>
Status Timeline
</Typography>
{proposal.status === 'Revised' ? (
<Box sx={{ textAlign: 'center', py: 1 }}>
<Chip label="Revised — a new revision has been created" sx={STATUS_CHIP_STYLES['Revised']} />
</Box>
) : (
<Stepper activeStep={activeStep >= 0 ? activeStep : 0} alternativeLabel>
{STATUS_ORDER.map((label) => (
<Step key={label} completed={STATUS_ORDER.indexOf(label) <= activeStep}>
<StepLabel>{label}</StepLabel>
</Step>
))}
</Stepper>
)}
</CardContent>
</Card>
<Card sx={{ mb: 3 }}>
<CardContent>
<Typography variant="subtitle2" sx={{ mb: 2 }}>
Proposal Details
</Typography>
<Grid container spacing={2}>
<InfoRow label="Work Order #" value={proposal.workOrderNumber} />
<InfoRow label="Customer" value={proposal.customerName} />
<InfoRow label="Address" value={proposal.customerAddress} />
<InfoRow label="Category" value={proposal.serviceCategory} />
<InfoRow label="Priority" value={proposal.priority} />
<InfoRow label="Revision" value={`#${proposal.currentRevision}`} />
<InfoRow label="Submitted By" value={proposal.submittedByName} />
<InfoRow label="Submitted" value={formatDateTime(proposal.submittedAt)} />
<InfoRow label="Bid Amount" value={formatCurrency(proposal.totalBidAmount)} />
</Grid>
<Divider sx={{ my: 2 }} />
<Typography variant="caption" color="text.secondary">
Scope of Work
</Typography>
<Typography variant="body1" sx={{ whiteSpace: 'pre-wrap', mt: 0.5 }}>
{proposal.refinedScope || proposal.scopeOfWork}
</Typography>
{proposal.notes && (
<>
<Divider sx={{ my: 2 }} />
<Typography variant="caption" color="text.secondary">
Notes
</Typography>
<Typography variant="body1" sx={{ whiteSpace: 'pre-wrap', mt: 0.5 }}>
{proposal.notes}
</Typography>
</>
)}
</CardContent>
</Card>
{(proposal.approvedAt || proposal.sentAt) && (
<Card>
<CardContent>
<Typography variant="subtitle2" sx={{ mb: 2 }}>
Approval & Delivery
</Typography>
<Grid container spacing={2}>
<InfoRow label="Approved At" value={formatDateTime(proposal.approvedAt)} />
<InfoRow label="Sent At" value={formatDateTime(proposal.sentAt)} />
{proposal.vendorTotalCost !== null && (
<InfoRow label="Vendor Cost" value={formatCurrency(proposal.vendorTotalCost)} />
)}
</Grid>
</CardContent>
</Card>
)}
</Box>
);
}