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]
295 lines
9.7 KiB
TypeScript
295 lines
9.7 KiB
TypeScript
import { useState, useCallback, useRef, useEffect } from 'react';
|
|
import { useNavigate } from 'react-router-dom';
|
|
import { useMutation } from '@tanstack/react-query';
|
|
import {
|
|
Box,
|
|
Typography,
|
|
Card,
|
|
CardContent,
|
|
TextField,
|
|
Button,
|
|
Grid,
|
|
MenuItem,
|
|
Autocomplete,
|
|
CircularProgress,
|
|
} from '@mui/material';
|
|
import SendIcon from '@mui/icons-material/Send';
|
|
import { toast } from 'react-toastify';
|
|
import { proposalsApi, type CreateProposalRequest } from '../../../lib/api/proposals';
|
|
import { customersApi, type Customer } from '../../../lib/api/customers';
|
|
import { SERVICE_CATEGORIES, PRIORITIES } from '../../../constants';
|
|
import { queryClient } from '../../../lib/queryClient';
|
|
|
|
export default function ProposalFormPage() {
|
|
const navigate = useNavigate();
|
|
|
|
const [form, setForm] = useState<CreateProposalRequest>({
|
|
workOrderNumber: '',
|
|
customerName: '',
|
|
customerAddress: '',
|
|
scopeOfWork: '',
|
|
serviceCategory: '',
|
|
priority: 'Standard',
|
|
notes: '',
|
|
});
|
|
|
|
const [customers, setCustomers] = useState<Customer[]>([]);
|
|
const [customerLoading, setCustomerLoading] = useState(false);
|
|
const [addresses, setAddresses] = useState<string[]>([]);
|
|
const [vendorFile, setVendorFile] = useState<File | null>(null);
|
|
const searchDebounceRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
|
|
|
useEffect(() => {
|
|
return () => {
|
|
if (searchDebounceRef.current) clearTimeout(searchDebounceRef.current);
|
|
};
|
|
}, []);
|
|
|
|
const handleChange = (field: keyof CreateProposalRequest, value: string) => {
|
|
setForm((prev) => ({ ...prev, [field]: value }));
|
|
};
|
|
|
|
const searchCustomers = useCallback(async (query: string) => {
|
|
if (query.length < 2) return;
|
|
setCustomerLoading(true);
|
|
try {
|
|
const results = await customersApi.search(query);
|
|
setCustomers(results);
|
|
} catch {
|
|
setCustomers([]);
|
|
} finally {
|
|
setCustomerLoading(false);
|
|
}
|
|
}, []);
|
|
|
|
const handleCustomerSelect = (_: unknown, value: Customer | string | null) => {
|
|
if (!value) {
|
|
setForm((prev) => ({ ...prev, customerName: '', customerAddress: '' }));
|
|
setAddresses([]);
|
|
return;
|
|
}
|
|
if (typeof value === 'string') {
|
|
setForm((prev) => ({ ...prev, customerName: value, customerAddress: '' }));
|
|
setAddresses([]);
|
|
} else {
|
|
setForm((prev) => ({ ...prev, customerName: value.name, customerAddress: value.addresses[0] || '' }));
|
|
setAddresses(value.addresses);
|
|
}
|
|
};
|
|
|
|
const createMutation = useMutation({
|
|
mutationFn: async () => {
|
|
const proposal = await proposalsApi.create(form);
|
|
|
|
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) => {
|
|
queryClient.invalidateQueries({ queryKey: ['proposals'] });
|
|
toast.success(`Proposal ${proposal.proposalNumber} submitted`);
|
|
navigate(`/proposals/${proposal.id}`);
|
|
},
|
|
onError: (error: Error) => {
|
|
toast.error(error.message || 'Failed to submit proposal');
|
|
},
|
|
});
|
|
|
|
const handleSubmit = (e: React.FormEvent) => {
|
|
e.preventDefault();
|
|
createMutation.mutate();
|
|
};
|
|
|
|
const isValid =
|
|
form.workOrderNumber.trim() &&
|
|
form.customerName.trim() &&
|
|
form.customerAddress.trim() &&
|
|
form.scopeOfWork.trim() &&
|
|
form.serviceCategory;
|
|
|
|
return (
|
|
<Box>
|
|
<Typography variant="h5" sx={{ mb: 3 }}>
|
|
New Proposal Request
|
|
</Typography>
|
|
|
|
<Card>
|
|
<CardContent>
|
|
<Box component="form" onSubmit={handleSubmit}>
|
|
<Grid container spacing={3}>
|
|
<Grid size={{ xs: 12, sm: 6 }}>
|
|
<TextField
|
|
label="Work Order #"
|
|
required
|
|
fullWidth
|
|
value={form.workOrderNumber}
|
|
onChange={(e) => handleChange('workOrderNumber', e.target.value)}
|
|
/>
|
|
</Grid>
|
|
|
|
<Grid size={{ xs: 12, sm: 6 }}>
|
|
<Autocomplete
|
|
freeSolo
|
|
options={customers}
|
|
getOptionLabel={(opt) => (typeof opt === 'string' ? opt : opt.name)}
|
|
loading={customerLoading}
|
|
onInputChange={(_, value, reason) => {
|
|
if (reason === 'input') {
|
|
handleChange('customerName', value);
|
|
}
|
|
if (searchDebounceRef.current) clearTimeout(searchDebounceRef.current);
|
|
searchDebounceRef.current = setTimeout(() => searchCustomers(value), 300);
|
|
}}
|
|
onChange={handleCustomerSelect}
|
|
renderInput={(params) => (
|
|
<TextField
|
|
{...params}
|
|
label="Customer"
|
|
required
|
|
slotProps={{
|
|
input: {
|
|
...params.InputProps,
|
|
endAdornment: (
|
|
<>
|
|
{customerLoading && <CircularProgress size={20} />}
|
|
{params.InputProps.endAdornment}
|
|
</>
|
|
),
|
|
},
|
|
}}
|
|
/>
|
|
)}
|
|
/>
|
|
</Grid>
|
|
|
|
<Grid size={{ xs: 12 }}>
|
|
{addresses.length > 1 ? (
|
|
<TextField
|
|
label="Customer Address"
|
|
required
|
|
fullWidth
|
|
select
|
|
value={form.customerAddress}
|
|
onChange={(e) => handleChange('customerAddress', e.target.value)}
|
|
>
|
|
{addresses.map((addr) => (
|
|
<MenuItem key={addr} value={addr}>
|
|
{addr}
|
|
</MenuItem>
|
|
))}
|
|
</TextField>
|
|
) : (
|
|
<TextField
|
|
label="Customer Address"
|
|
required
|
|
fullWidth
|
|
value={form.customerAddress}
|
|
onChange={(e) => handleChange('customerAddress', e.target.value)}
|
|
/>
|
|
)}
|
|
</Grid>
|
|
|
|
<Grid size={{ xs: 12, sm: 6 }}>
|
|
<TextField
|
|
label="Service Category"
|
|
required
|
|
fullWidth
|
|
select
|
|
value={form.serviceCategory}
|
|
onChange={(e) => handleChange('serviceCategory', e.target.value)}
|
|
>
|
|
{SERVICE_CATEGORIES.map((cat) => (
|
|
<MenuItem key={cat} value={cat}>
|
|
{cat}
|
|
</MenuItem>
|
|
))}
|
|
</TextField>
|
|
</Grid>
|
|
|
|
<Grid size={{ xs: 12, sm: 6 }}>
|
|
<TextField
|
|
label="Priority"
|
|
required
|
|
fullWidth
|
|
select
|
|
value={form.priority}
|
|
onChange={(e) => handleChange('priority', e.target.value)}
|
|
>
|
|
{PRIORITIES.map((p) => (
|
|
<MenuItem key={p} value={p}>
|
|
{p}
|
|
</MenuItem>
|
|
))}
|
|
</TextField>
|
|
</Grid>
|
|
|
|
<Grid size={{ xs: 12 }}>
|
|
<TextField
|
|
label="Scope of Work"
|
|
required
|
|
fullWidth
|
|
multiline
|
|
minRows={4}
|
|
value={form.scopeOfWork}
|
|
onChange={(e) => handleChange('scopeOfWork', e.target.value)}
|
|
placeholder="Describe the work needed..."
|
|
/>
|
|
</Grid>
|
|
|
|
<Grid size={{ xs: 12 }}>
|
|
<TextField
|
|
label="Notes (optional)"
|
|
fullWidth
|
|
multiline
|
|
minRows={2}
|
|
value={form.notes}
|
|
onChange={(e) => handleChange('notes', e.target.value)}
|
|
/>
|
|
</Grid>
|
|
|
|
<Grid size={{ xs: 12 }}>
|
|
<Button variant="outlined" component="label">
|
|
{vendorFile ? vendorFile.name : 'Attach Vendor Proposal (PDF)'}
|
|
<input
|
|
type="file"
|
|
hidden
|
|
accept=".pdf"
|
|
onChange={(e) => setVendorFile(e.target.files?.[0] || null)}
|
|
/>
|
|
</Button>
|
|
</Grid>
|
|
|
|
<Grid size={{ xs: 12 }}>
|
|
<Box sx={{ display: 'flex', gap: 2, justifyContent: 'flex-end' }}>
|
|
<Button variant="text" onClick={() => navigate(-1)}>
|
|
Cancel
|
|
</Button>
|
|
<Button
|
|
type="submit"
|
|
variant="contained"
|
|
startIcon={<SendIcon />}
|
|
disabled={!isValid || createMutation.isPending}
|
|
>
|
|
{createMutation.isPending ? 'Submitting...' : 'Submit Proposal'}
|
|
</Button>
|
|
</Box>
|
|
</Grid>
|
|
</Grid>
|
|
</Box>
|
|
</CardContent>
|
|
</Card>
|
|
</Box>
|
|
);
|
|
}
|