mirror of
https://github.com/Sea-Haven-Industries/proposal-system.git
synced 2026-09-30 07:43:14 +00:00
WEB-M2: 401 interceptor now dispatches Redux logout action to clear auth state, not just localStorage. WEB-M5: CreateProposalRequest uses typed ServiceCategory and Priority unions aligned with shared/api-contracts contract. WEB-M6: Vendor PDF upload validates MIME type (application/pdf), file extension (.pdf), and max size (25 MB) before accepting. WEB-M7: AdminWorkspace shows error Alert with retry button when proposal fetch fails, instead of rendering empty workspace. WEB-M10: State transition buttons (Approve, Send, Revise) are disabled with explanatory tooltips when proposal is not in the correct state for that transition. WEB-M13: ToastContainer moved inside BrowserRouter so toasts render in the correct React tree context.
338 lines
12 KiB
TypeScript
338 lines
12 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, type ServiceCategory, type Priority } from '../../../lib/api/proposals';
|
|
import { customersApi, type Customer } from '../../../lib/api/customers';
|
|
import { SERVICE_CATEGORIES, PRIORITIES } from '../../../constants';
|
|
import { queryClient } from '../../../lib/queryClient';
|
|
|
|
// Fix: WEB-M5 — form state allows empty serviceCategory during editing; validated before submit
|
|
interface ProposalFormState {
|
|
workOrderNumber: string;
|
|
customerName: string;
|
|
customerAddress: string;
|
|
scopeOfWork: string;
|
|
serviceCategory: ServiceCategory | '';
|
|
priority: Priority;
|
|
notes: string;
|
|
}
|
|
|
|
// Fix: WEB-M6 — file validation constants
|
|
const MAX_FILE_SIZE_BYTES = 25 * 1024 * 1024; // 25 MB
|
|
const ALLOWED_MIME_TYPES = ['application/pdf'];
|
|
|
|
export default function ProposalFormPage() {
|
|
const navigate = useNavigate();
|
|
|
|
const [form, setForm] = useState<ProposalFormState>({
|
|
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 ProposalFormState, 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 () => {
|
|
// Fix: WEB-M5 — cast validated form state to the strict request type
|
|
const request: CreateProposalRequest = {
|
|
workOrderNumber: form.workOrderNumber,
|
|
customerName: form.customerName,
|
|
customerAddress: form.customerAddress,
|
|
scopeOfWork: form.scopeOfWork,
|
|
serviceCategory: form.serviceCategory as ServiceCategory,
|
|
priority: form.priority,
|
|
notes: form.notes || undefined,
|
|
};
|
|
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) => {
|
|
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,application/pdf"
|
|
onChange={(e) => {
|
|
// Fix: WEB-M6 — validate file size and MIME type before accepting
|
|
const file = e.target.files?.[0] || null;
|
|
if (!file) {
|
|
setVendorFile(null);
|
|
return;
|
|
}
|
|
if (!file.name.toLowerCase().endsWith('.pdf') || !ALLOWED_MIME_TYPES.includes(file.type)) {
|
|
toast.error('Only PDF files are accepted.');
|
|
e.target.value = '';
|
|
return;
|
|
}
|
|
if (file.size > MAX_FILE_SIZE_BYTES) {
|
|
toast.error('File size exceeds the 25 MB limit. Please choose a smaller file.');
|
|
e.target.value = '';
|
|
return;
|
|
}
|
|
setVendorFile(file);
|
|
}}
|
|
/>
|
|
</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>
|
|
);
|
|
}
|