mirror of
https://github.com/Sea-Haven-Industries/proposal-system.git
synced 2026-10-07 13:58:56 +00:00
Some checks failed
Deploy / Deploy to AWS (push) Has been cancelled
## 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]
167 lines
5.4 KiB
TypeScript
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>
|
|
);
|
|
}
|