proposal-system/web/src/pages/proposals/detail/ProposalDetailPage.tsx
Adam Moussa 9d856a9619
Some checks failed
Deploy / Deploy to AWS (push) Has been cancelled
Phase 3 audit fixes: FIX-01–47, accessibility NITs, code quality NITs [skip deploy]
## Summary
Implements Phase 3 of the AUDIT-2026-05-20 findings:
- 29 FIX-severity items across API, web, infra, and lambdas
- 7 accessibility NITs (aria-labels, document titles)
- 4 code quality NITs (deduplication, constants extraction)

Key changes:
- API: N+1 fix, pagination clamping, idempotent transitions, upload confirm endpoint, revision TotalBidAmount carry-forward
- Web: confirmation dialogs, currency formatting, error states, date range filters, document titles
- Infra: S3 CORS lockdown, API Gateway throttling, AOSS network policy fix, CI concurrency
- Lambdas: skip empty suggestions, remove status side-effect
- Scripts: post-deploy health check

## Test plan
- [x] tsc --noEmit (web + infra)
- [x] dotnet build (api)
- [x] ruff check + format (lambdas)
- [x] Cross-review via orchestrator (no blockers)

[skip deploy]
2026-05-20 19:38:36 -04:00

167 lines
5.4 KiB
TypeScript

import { useParams, useNavigate } from 'react-router-dom';
import { useQuery } from '@tanstack/react-query';
import {
Box,
Typography,
Card,
CardContent,
Grid,
Chip,
Divider,
Button,
Skeleton,
Stepper,
Step,
StepLabel,
} from '@mui/material';
import ArrowBackIcon from '@mui/icons-material/ArrowBack';
import { proposalsApi, type ProposalDetail } from '../../../lib/api/proposals';
import { STATUS_COLORS } 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 } = useQuery<ProposalDetail>({
queryKey: ['proposals', id],
queryFn: () => proposalsApi.getById(id!),
enabled: !!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}
color={STATUS_COLORS[proposal.status] || 'default'}
size="medium"
/>
</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" color="warning" />
</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>
);
}