mirror of
https://github.com/Sea-Haven-Industries/proposal-system.git
synced 2026-10-04 20:42:02 +00:00
275 lines
8.8 KiB
TypeScript
275 lines
8.8 KiB
TypeScript
|
|
import { useState, useCallback } 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 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) {
|
||
|
|
const { uploadUrl } = await proposalsApi.uploadAttachment(proposal.id, vendorFile.name);
|
||
|
|
await fetch(uploadUrl, {
|
||
|
|
method: 'PUT',
|
||
|
|
body: vendorFile,
|
||
|
|
headers: { 'Content-Type': vendorFile.type || 'application/pdf' },
|
||
|
|
});
|
||
|
|
}
|
||
|
|
|
||
|
|
return proposal;
|
||
|
|
},
|
||
|
|
onSuccess: (proposal) => {
|
||
|
|
queryClient.invalidateQueries({ queryKey: ['proposals'] });
|
||
|
|
toast.success(`Proposal ${proposal.proposalNumber} submitted`);
|
||
|
|
navigate(`/proposals/${proposal.id}`);
|
||
|
|
},
|
||
|
|
});
|
||
|
|
|
||
|
|
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) => searchCustomers(value)}
|
||
|
|
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>
|
||
|
|
);
|
||
|
|
}
|