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

View file

@ -1,3 +1,4 @@
import { useEffect, useRef, useState } from 'react';
import { useNavigate } from 'react-router-dom';
import {
Box,
@ -19,28 +20,45 @@ import {
} from '@mui/material';
import SearchIcon from '@mui/icons-material/Search';
import AddCircleIcon from '@mui/icons-material/AddCircle';
import { usePaginatedList } from '../../../hooks/usePaginatedList';
import { proposalsApi, type ProposalListItem } from '../../../lib/api/proposals';
import { STATUS_CHIP_STYLES } from '../../../constants';
import { useProposals } from '../../../domain/proposals/use-cases';
import { STATUS_CHIP_STYLES, DEBOUNCE_SEARCH, DEFAULT_PAGE, DEFAULT_PAGE_SIZE } from '../../../constants';
import { formatBidAmount, formatDate, formatDateFull } from '../../../lib/format';
import { useDocumentTitle } from '../../../hooks/useDocumentTitle';
export default function ProposalListPage() {
useDocumentTitle('My Proposals');
const navigate = useNavigate();
const mineParams = { mine: true };
const {
rows,
search,
setSearch,
const [search, setSearch] = useState('');
const [debouncedSearch, setDebouncedSearch] = useState('');
const [page, setPage] = useState(DEFAULT_PAGE);
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,
setPage,
pageSize,
setPageSize,
totalCount,
loading,
err,
} = usePaginatedList<ProposalListItem>(proposalsApi.getAll, mineParams);
mine: true,
});
const rows = data?.items ?? [];
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 (
<Box>
@ -127,7 +145,7 @@ export default function ProposalListPage() {
<TableCell align="right">
{row.totalBidAmount > 0
? 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>