refactor(web): proposal pages on domain layer, proposal form on react-hook-form

This commit is contained in:
Adam Moussa 2026-07-13 18:07:34 -04:00
parent e6e2c60c2f
commit 97fa7f2b75
No known key found for this signature in database
3 changed files with 167 additions and 202 deletions

View file

@ -1,5 +1,4 @@
import { useParams, useNavigate } from 'react-router-dom'; import { useParams, useNavigate } from 'react-router-dom';
import { useQuery } from '@tanstack/react-query';
import { import {
Box, Box,
Typography, Typography,
@ -15,7 +14,7 @@ import {
StepLabel, StepLabel,
} from '@mui/material'; } from '@mui/material';
import ArrowBackIcon from '@mui/icons-material/ArrowBack'; import ArrowBackIcon from '@mui/icons-material/ArrowBack';
import { proposalsApi, type ProposalDetail } from '../../../lib/api/proposals'; import { useProposal } from '../../../domain/proposals/use-cases';
import { STATUS_CHIP_STYLES } from '../../../constants'; import { STATUS_CHIP_STYLES } from '../../../constants';
import { formatCurrency, formatDateTime } from '../../../lib/format'; import { formatCurrency, formatDateTime } from '../../../lib/format';
import { useDocumentTitle } from '../../../hooks/useDocumentTitle'; import { useDocumentTitle } from '../../../hooks/useDocumentTitle';
@ -37,11 +36,7 @@ export default function ProposalDetailPage() {
const { id } = useParams<{ id: string }>(); const { id } = useParams<{ id: string }>();
const navigate = useNavigate(); const navigate = useNavigate();
const { data: proposal, isLoading, error } = useQuery<ProposalDetail>({ const { data: proposal, isLoading, error } = useProposal(id);
queryKey: ['proposals', id],
queryFn: () => proposalsApi.getById(id!),
enabled: !!id,
});
useDocumentTitle(proposal?.proposalNumber ?? 'Proposal'); useDocumentTitle(proposal?.proposalNumber ?? 'Proposal');

View file

@ -1,6 +1,7 @@
import { useState, useCallback, useRef, useEffect, useMemo } from 'react'; import { useState, useRef, useEffect, useMemo } from 'react';
import { useNavigate } from 'react-router-dom'; import { useNavigate } from 'react-router-dom';
import { useMutation } from '@tanstack/react-query'; import { Controller, useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { import {
Box, Box,
Typography, Typography,
@ -15,44 +16,45 @@ import {
} from '@mui/material'; } from '@mui/material';
import SendIcon from '@mui/icons-material/Send'; import SendIcon from '@mui/icons-material/Send';
import { toast } from 'react-toastify'; import { toast } from 'react-toastify';
import { proposalsApi, type CreateProposalRequest, type ServiceCategory, type Priority } from '../../../lib/api/proposals'; import { useCreateProposal } from '../../../domain/proposals/use-cases';
import { sitesApi, type Site } from '../../../lib/api/sites'; import {
import { customersApi } from '../../../lib/api/customers'; createProposalFormSchema,
toCreateProposalRequest,
MIN_SCOPE_LENGTH,
type CreateProposalFormValues,
} from '../../../domain/proposals/schemas';
import { useSiteSearch } from '../../../domain/sites/use-cases';
import { useCustomerSearch } from '../../../domain/customers/use-cases';
import { SERVICE_CATEGORIES, PRIORITIES, PRIORITY_LABELS } from '../../../constants'; import { SERVICE_CATEGORIES, PRIORITIES, PRIORITY_LABELS } from '../../../constants';
import { queryClient } from '../../../lib/queryClient';
interface ProposalFormState {
workOrderNumber: string;
poNumber?: string;
customerName: string;
customerAddress: string;
scopeOfWork: string;
serviceCategory: ServiceCategory | '';
priority: Priority;
notes: string;
}
const MAX_FILE_SIZE_BYTES = 25 * 1024 * 1024; const MAX_FILE_SIZE_BYTES = 25 * 1024 * 1024;
const ALLOWED_MIME_TYPES = ['application/pdf']; const ALLOWED_MIME_TYPES = ['application/pdf'];
// Fix: WEB-M3 — minimum length for scope of work to ensure meaningful descriptions
const MIN_SCOPE_LENGTH = 10;
export default function ProposalFormPage() { export default function ProposalFormPage() {
const navigate = useNavigate(); const navigate = useNavigate();
const [form, setForm] = useState<ProposalFormState>({ const {
workOrderNumber: '', register,
customerName: '', handleSubmit,
customerAddress: '', control,
scopeOfWork: '', setValue,
serviceCategory: '', watch,
priority: 'Standard', } = useForm<CreateProposalFormValues>({
notes: '', resolver: zodResolver(createProposalFormSchema),
defaultValues: {
workOrderNumber: '',
poNumber: '',
customerName: '',
customerAddress: '',
scopeOfWork: '',
priority: 'Standard',
notes: '',
customCategory: '',
},
}); });
const [vendorFile, setVendorFile] = useState<File | null>(null); const [vendorFile, setVendorFile] = useState<File | null>(null);
const [siteOptions, setSiteOptions] = useState<Site[]>([]); const [siteQuery, setSiteQuery] = useState('');
const [siteLoading, setSiteLoading] = useState(false);
const [siteInputValue, setSiteInputValue] = useState(''); const [siteInputValue, setSiteInputValue] = useState('');
const [manualEntry, setManualEntry] = useState(false); const [manualEntry, setManualEntry] = useState(false);
const [manualSiteCode, setManualSiteCode] = useState(''); const [manualSiteCode, setManualSiteCode] = useState('');
@ -60,15 +62,22 @@ export default function ProposalFormPage() {
const [manualCity, setManualCity] = useState(''); const [manualCity, setManualCity] = useState('');
const [manualState, setManualState] = useState(''); const [manualState, setManualState] = useState('');
const [manualZip, setManualZip] = useState(''); const [manualZip, setManualZip] = useState('');
const [customCategory, setCustomCategory] = useState('');
const siteDebounceRef = useRef<ReturnType<typeof setTimeout> | null>(null); const siteDebounceRef = useRef<ReturnType<typeof setTimeout> | null>(null);
// PR4: customer search state — freeSolo Autocomplete backed by customersApi.search // PR4: customer search state — freeSolo Autocomplete backed by the customers domain
const [customerOptions, setCustomerOptions] = useState<string[]>([]); const [customerQuery, setCustomerQuery] = useState('');
const [customerLoading, setCustomerLoading] = useState(false);
const [customerInputValue, setCustomerInputValue] = useState(''); const [customerInputValue, setCustomerInputValue] = useState('');
const customerDebounceRef = useRef<ReturnType<typeof setTimeout> | null>(null); const customerDebounceRef = useRef<ReturnType<typeof setTimeout> | null>(null);
// Domain search hooks (disabled below 2 characters, matching prior behavior).
const siteSearch = useSiteSearch(siteQuery);
const siteOptions = siteSearch.data ?? [];
const siteLoading = siteSearch.isFetching;
const customerSearch = useCustomerSearch(customerQuery);
const customerOptions = (customerSearch.data ?? []).map((c) => c.name);
const customerLoading = customerSearch.isFetching;
useEffect(() => { useEffect(() => {
return () => { return () => {
if (siteDebounceRef.current) clearTimeout(siteDebounceRef.current); if (siteDebounceRef.current) clearTimeout(siteDebounceRef.current);
@ -88,116 +97,52 @@ export default function ProposalFormPage() {
manualSiteCode && address manualSiteCode && address
? `${manualSiteCode} — ${address}` ? `${manualSiteCode} — ${address}`
: manualSiteCode || address; : manualSiteCode || address;
setForm((prev) => ({ ...prev, customerAddress: display })); setValue('customerAddress', display);
}, [manualEntry, manualSiteCode, manualStreet, manualCity, manualState, manualZip]); }, [manualEntry, manualSiteCode, manualStreet, manualCity, manualState, manualZip, setValue]);
const handleChange = (field: keyof ProposalFormState, value: string) => { const createMutation = useCreateProposal();
setForm((prev) => ({ ...prev, [field]: value }));
};
const searchSites = useCallback(async (query: string) => { const onSubmit = (values: CreateProposalFormValues) => {
if (query.length < 2) { createMutation.mutate(
setSiteOptions([]); { request: toCreateProposalRequest(values), vendorFile },
return; {
} onSuccess: (proposal) => {
setSiteLoading(true); navigate(`/proposals/${proposal.id}`);
try { },
const results = await sitesApi.search(query);
setSiteOptions(results);
} catch {
setSiteOptions([]);
} finally {
setSiteLoading(false);
}
}, []);
// PR4: search customers by name, debounced from the Autocomplete input
const searchCustomers = useCallback(async (query: string) => {
if (query.length < 2) {
setCustomerOptions([]);
return;
}
setCustomerLoading(true);
try {
const results = await customersApi.search(query);
setCustomerOptions(results.map((c) => c.name));
} catch {
setCustomerOptions([]);
} finally {
setCustomerLoading(false);
}
}, []);
const createMutation = useMutation({
mutationFn: async () => {
const notes = form.serviceCategory === 'Other' && customCategory.trim()
? `[Category: ${customCategory.trim()}]\n${form.notes || ''}`.trim()
: form.notes || undefined;
const request: CreateProposalRequest = {
workOrderNumber: form.workOrderNumber,
customerName: form.customerName,
customerAddress: form.customerAddress,
scopeOfWork: form.scopeOfWork,
serviceCategory: form.serviceCategory as ServiceCategory,
priority: form.priority,
notes,
};
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 workOrderNumber = watch('workOrderNumber');
const customerName = watch('customerName');
const customerAddress = watch('customerAddress');
const scopeOfWork = watch('scopeOfWork');
const serviceCategory = watch('serviceCategory');
const customCategory = watch('customCategory');
// Fix: WEB-M3 — enforce minimum scope length // Fix: WEB-M3 — enforce minimum scope length
const scopeTouched = form.scopeOfWork.length > 0; const scopeTouched = scopeOfWork.length > 0;
const scopeTooShort = scopeTouched && form.scopeOfWork.trim().length < MIN_SCOPE_LENGTH; const scopeTooShort = scopeTouched && scopeOfWork.trim().length < MIN_SCOPE_LENGTH;
const isValid = const isValid =
form.workOrderNumber.trim() && workOrderNumber.trim() &&
form.customerName.trim() && customerName.trim() &&
form.customerAddress.trim() && customerAddress.trim() &&
form.scopeOfWork.trim().length >= MIN_SCOPE_LENGTH && scopeOfWork.trim().length >= MIN_SCOPE_LENGTH &&
form.serviceCategory && serviceCategory &&
(form.serviceCategory !== 'Other' || customCategory.trim()); (serviceCategory !== 'Other' || customCategory.trim());
const missingFields = useMemo(() => { const missingFields = useMemo(() => {
const missing: string[] = []; const missing: string[] = [];
if (!form.workOrderNumber.trim()) missing.push('Work Order Number'); if (!workOrderNumber.trim()) missing.push('Work Order Number');
if (!form.customerAddress.trim()) missing.push('Site address'); if (!customerAddress.trim()) missing.push('Site address');
if (!form.serviceCategory) missing.push('Service Category'); if (!serviceCategory) missing.push('Service Category');
if (form.serviceCategory === 'Other' && !customCategory.trim()) missing.push('Specify category'); if (serviceCategory === 'Other' && !customCategory.trim()) missing.push('Specify category');
// Fix: WEB-M3 — show minimum length requirement in missing fields summary // Fix: WEB-M3 — show minimum length requirement in missing fields summary
if (!form.scopeOfWork.trim()) missing.push('Scope of Work'); if (!scopeOfWork.trim()) missing.push('Scope of Work');
else if (form.scopeOfWork.trim().length < MIN_SCOPE_LENGTH) missing.push(`Scope of Work (min ${MIN_SCOPE_LENGTH} chars)`); else if (scopeOfWork.trim().length < MIN_SCOPE_LENGTH) missing.push(`Scope of Work (min ${MIN_SCOPE_LENGTH} chars)`);
return missing; return missing;
}, [form.workOrderNumber, form.customerAddress, form.serviceCategory, form.scopeOfWork, customCategory]); }, [workOrderNumber, customerAddress, serviceCategory, scopeOfWork, customCategory]);
return ( return (
<Box sx={{ maxWidth: 1200, mx: 'auto' }}> <Box sx={{ maxWidth: 1200, mx: 'auto' }}>
@ -205,7 +150,7 @@ export default function ProposalFormPage() {
New Proposal Request New Proposal Request
</Typography> </Typography>
<Box component="form" onSubmit={handleSubmit}> <Box component="form" onSubmit={handleSubmit(onSubmit)}>
<Card sx={{ mb: 2 }}> <Card sx={{ mb: 2 }}>
<CardContent> <CardContent>
<Typography variant="subtitle1" sx={{ fontWeight: 600, mb: 2 }}>Job Details</Typography> <Typography variant="subtitle1" sx={{ fontWeight: 600, mb: 2 }}>Job Details</Typography>
@ -215,16 +160,14 @@ export default function ProposalFormPage() {
label="Work Order Number" label="Work Order Number"
required required
fullWidth fullWidth
value={form.workOrderNumber} {...register('workOrderNumber')}
onChange={(e) => handleChange('workOrderNumber', e.target.value)}
/> />
</Grid> </Grid>
<Grid size={{ xs: 12, sm: 6, md: 3 }}> <Grid size={{ xs: 12, sm: 6, md: 3 }}>
<TextField <TextField
label="Purchase Order Number" label="Purchase Order Number"
fullWidth fullWidth
value={form.poNumber ?? ''} {...register('poNumber')}
onChange={(e) => handleChange('poNumber', e.target.value)}
/> />
</Grid> </Grid>
{/* PR4: freeSolo customer search — dispatchers can pick an existing {/* PR4: freeSolo customer search — dispatchers can pick an existing
@ -239,18 +182,18 @@ export default function ProposalFormPage() {
onInputChange={(_, value, reason) => { onInputChange={(_, value, reason) => {
if (reason === 'input') { if (reason === 'input') {
setCustomerInputValue(value); setCustomerInputValue(value);
handleChange('customerName', value); setValue('customerName', value);
if (customerDebounceRef.current) clearTimeout(customerDebounceRef.current); if (customerDebounceRef.current) clearTimeout(customerDebounceRef.current);
customerDebounceRef.current = setTimeout(() => searchCustomers(value), 300); customerDebounceRef.current = setTimeout(() => setCustomerQuery(value), 300);
} else if (reason === 'clear') { } else if (reason === 'clear') {
setCustomerInputValue(''); setCustomerInputValue('');
handleChange('customerName', ''); setValue('customerName', '');
setCustomerOptions([]); setCustomerQuery('');
} }
}} }}
onChange={(_, value) => { onChange={(_, value) => {
const selected = typeof value === 'string' ? value : value ?? ''; const selected = typeof value === 'string' ? value : value ?? '';
handleChange('customerName', selected); setValue('customerName', selected);
setCustomerInputValue(selected); setCustomerInputValue(selected);
}} }}
noOptionsText={ noOptionsText={
@ -283,20 +226,26 @@ export default function ProposalFormPage() {
/> />
</Grid> </Grid>
<Grid size={{ xs: 12, sm: 6, md: 3 }}> <Grid size={{ xs: 12, sm: 6, md: 3 }}>
<TextField <Controller
label="Priority" name="priority"
required control={control}
fullWidth render={({ field }) => (
select <TextField
value={form.priority} label="Priority"
onChange={(e) => handleChange('priority', e.target.value)} required
> fullWidth
{PRIORITIES.map((p) => ( select
<MenuItem key={p} value={p}> value={field.value}
{PRIORITY_LABELS[p] || p} onChange={(e) => field.onChange(e.target.value)}
</MenuItem> >
))} {PRIORITIES.map((p) => (
</TextField> <MenuItem key={p} value={p}>
{PRIORITY_LABELS[p] || p}
</MenuItem>
))}
</TextField>
)}
/>
</Grid> </Grid>
</Grid> </Grid>
</CardContent> </CardContent>
@ -367,7 +316,7 @@ export default function ProposalFormPage() {
setManualState(''); setManualState('');
setManualZip(''); setManualZip('');
setSiteInputValue(''); setSiteInputValue('');
handleChange('customerAddress', ''); setValue('customerAddress', '');
}} }}
> >
Search sites instead Search sites instead
@ -389,7 +338,7 @@ export default function ProposalFormPage() {
if (reason === 'input') { if (reason === 'input') {
setSiteInputValue(value); setSiteInputValue(value);
if (siteDebounceRef.current) clearTimeout(siteDebounceRef.current); if (siteDebounceRef.current) clearTimeout(siteDebounceRef.current);
siteDebounceRef.current = setTimeout(() => searchSites(value), 300); siteDebounceRef.current = setTimeout(() => setSiteQuery(value), 300);
} }
}} }}
onChange={(_, value) => { onChange={(_, value) => {
@ -397,10 +346,10 @@ export default function ProposalFormPage() {
const display = value.fullAddress const display = value.fullAddress
? `${value.siteCode} — ${value.fullAddress}` ? `${value.siteCode} — ${value.fullAddress}`
: value.siteCode; : value.siteCode;
handleChange('customerAddress', display); setValue('customerAddress', display);
setSiteInputValue(display); setSiteInputValue(display);
} else { } else {
handleChange('customerAddress', ''); setValue('customerAddress', '');
setSiteInputValue(''); setSiteInputValue('');
} }
}} }}
@ -450,30 +399,35 @@ export default function ProposalFormPage() {
<Typography variant="subtitle1" sx={{ fontWeight: 600, mb: 2 }}>Work Details</Typography> <Typography variant="subtitle1" sx={{ fontWeight: 600, mb: 2 }}>Work Details</Typography>
<Grid container spacing={3}> <Grid container spacing={3}>
<Grid size={{ xs: 12, sm: 6 }}> <Grid size={{ xs: 12, sm: 6 }}>
<TextField <Controller
label="Service Category" name="serviceCategory"
required control={control}
fullWidth render={({ field }) => (
select <TextField
value={form.serviceCategory} label="Service Category"
onChange={(e) => handleChange('serviceCategory', e.target.value)} required
> fullWidth
{SERVICE_CATEGORIES.map((cat) => ( select
<MenuItem key={cat} value={cat}> value={field.value ?? ''}
{cat} onChange={(e) => field.onChange(e.target.value)}
</MenuItem> >
))} {SERVICE_CATEGORIES.map((cat) => (
</TextField> <MenuItem key={cat} value={cat}>
{cat}
</MenuItem>
))}
</TextField>
)}
/>
</Grid> </Grid>
{form.serviceCategory === 'Other' ? ( {serviceCategory === 'Other' ? (
<Grid size={{ xs: 12, sm: 6 }}> <Grid size={{ xs: 12, sm: 6 }}>
<TextField <TextField
label="Specify Category" label="Specify Category"
required required
fullWidth fullWidth
value={customCategory} {...register('customCategory')}
onChange={(e) => setCustomCategory(e.target.value)}
placeholder="e.g. Fire Protection" placeholder="e.g. Fire Protection"
/> />
</Grid> </Grid>
@ -489,13 +443,12 @@ export default function ProposalFormPage() {
fullWidth fullWidth
multiline multiline
minRows={4} minRows={4}
value={form.scopeOfWork} {...register('scopeOfWork')}
onChange={(e) => handleChange('scopeOfWork', e.target.value)}
placeholder="Describe the work needed..." placeholder="Describe the work needed..."
error={scopeTooShort} error={scopeTooShort}
helperText={ helperText={
scopeTooShort scopeTooShort
? `Scope must be at least ${MIN_SCOPE_LENGTH} characters (currently ${form.scopeOfWork.trim().length})` ? `Scope must be at least ${MIN_SCOPE_LENGTH} characters (currently ${scopeOfWork.trim().length})`
: undefined : undefined
} }
/> />
@ -507,8 +460,7 @@ export default function ProposalFormPage() {
fullWidth fullWidth
multiline multiline
minRows={2} minRows={2}
value={form.notes} {...register('notes')}
onChange={(e) => handleChange('notes', e.target.value)}
/> />
</Grid> </Grid>

View file

@ -1,3 +1,4 @@
import { useEffect, useRef, useState } from 'react';
import { useNavigate } from 'react-router-dom'; import { useNavigate } from 'react-router-dom';
import { import {
Box, Box,
@ -19,28 +20,45 @@ import {
} from '@mui/material'; } from '@mui/material';
import SearchIcon from '@mui/icons-material/Search'; import SearchIcon from '@mui/icons-material/Search';
import AddCircleIcon from '@mui/icons-material/AddCircle'; import AddCircleIcon from '@mui/icons-material/AddCircle';
import { usePaginatedList } from '../../../hooks/usePaginatedList'; import { useProposals } from '../../../domain/proposals/use-cases';
import { proposalsApi, type ProposalListItem } from '../../../lib/api/proposals'; import { STATUS_CHIP_STYLES, DEBOUNCE_SEARCH, DEFAULT_PAGE, DEFAULT_PAGE_SIZE } from '../../../constants';
import { STATUS_CHIP_STYLES } from '../../../constants';
import { formatBidAmount, formatDate, formatDateFull } from '../../../lib/format'; import { formatBidAmount, formatDate, formatDateFull } from '../../../lib/format';
import { useDocumentTitle } from '../../../hooks/useDocumentTitle'; import { useDocumentTitle } from '../../../hooks/useDocumentTitle';
export default function ProposalListPage() { export default function ProposalListPage() {
useDocumentTitle('My Proposals'); useDocumentTitle('My Proposals');
const navigate = useNavigate(); const navigate = useNavigate();
const mineParams = { mine: true };
const { const [search, setSearch] = useState('');
rows, const [debouncedSearch, setDebouncedSearch] = useState('');
search, const [page, setPage] = useState(DEFAULT_PAGE);
setSearch, const [pageSize, setPageSize] = useState(DEFAULT_PAGE_SIZE);
const debounceRef = useRef<ReturnType<typeof setTimeout> | null>(null);
// Debounced search resets to page 1 (mirrors usePaginatedList).
useEffect(() => {
if (debounceRef.current) clearTimeout(debounceRef.current);
debounceRef.current = setTimeout(() => {
setDebouncedSearch(search);
setPage(1);
}, DEBOUNCE_SEARCH);
return () => {
if (debounceRef.current) clearTimeout(debounceRef.current);
};
}, [search]);
const { data, isLoading, isPlaceholderData, error } = useProposals({
search: debouncedSearch,
page, page,
setPage,
pageSize, pageSize,
setPageSize, mine: true,
totalCount, });
loading,
err, const rows = data?.items ?? [];
} = usePaginatedList<ProposalListItem>(proposalsApi.getAll, mineParams); const totalCount = data?.totalCount ?? 0;
// Skeletons on first load and while fetching a new page/search (placeholder rows shown).
const loading = isLoading || isPlaceholderData;
const err = error ? error.message || 'Failed to load' : '';
return ( return (
<Box> <Box>
@ -127,7 +145,7 @@ export default function ProposalListPage() {
<TableCell align="right"> <TableCell align="right">
{row.totalBidAmount > 0 {row.totalBidAmount > 0
? formatBidAmount(row.totalBidAmount) ? formatBidAmount(row.totalBidAmount)
: <Typography variant="body2" component="span" sx={{ color: '#64748B' }}>Not priced</Typography> : <Typography variant="body2" component="span" sx={{ color: 'var(--color-text-muted)' }}>Not priced</Typography>
} }
</TableCell> </TableCell>
<TableCell> <TableCell>