mirror of
https://github.com/Sea-Haven-Industries/proposal-system.git
synced 2026-10-07 12:48:57 +00:00
refactor(web): proposal pages on domain layer, proposal form on react-hook-form
This commit is contained in:
parent
e6e2c60c2f
commit
97fa7f2b75
3 changed files with 167 additions and 202 deletions
|
|
@ -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');
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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 {
|
||||||
|
register,
|
||||||
|
handleSubmit,
|
||||||
|
control,
|
||||||
|
setValue,
|
||||||
|
watch,
|
||||||
|
} = useForm<CreateProposalFormValues>({
|
||||||
|
resolver: zodResolver(createProposalFormSchema),
|
||||||
|
defaultValues: {
|
||||||
workOrderNumber: '',
|
workOrderNumber: '',
|
||||||
|
poNumber: '',
|
||||||
customerName: '',
|
customerName: '',
|
||||||
customerAddress: '',
|
customerAddress: '',
|
||||||
scopeOfWork: '',
|
scopeOfWork: '',
|
||||||
serviceCategory: '',
|
|
||||||
priority: 'Standard',
|
priority: 'Standard',
|
||||||
notes: '',
|
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;
|
{
|
||||||
}
|
|
||||||
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.`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return proposal;
|
|
||||||
},
|
|
||||||
onSuccess: (proposal) => {
|
onSuccess: (proposal) => {
|
||||||
queryClient.invalidateQueries({ queryKey: ['proposals'] });
|
|
||||||
toast.success(`Proposal ${proposal.proposalNumber} submitted`);
|
|
||||||
navigate(`/proposals/${proposal.id}`);
|
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,13 +226,17 @@ export default function ProposalFormPage() {
|
||||||
/>
|
/>
|
||||||
</Grid>
|
</Grid>
|
||||||
<Grid size={{ xs: 12, sm: 6, md: 3 }}>
|
<Grid size={{ xs: 12, sm: 6, md: 3 }}>
|
||||||
|
<Controller
|
||||||
|
name="priority"
|
||||||
|
control={control}
|
||||||
|
render={({ field }) => (
|
||||||
<TextField
|
<TextField
|
||||||
label="Priority"
|
label="Priority"
|
||||||
required
|
required
|
||||||
fullWidth
|
fullWidth
|
||||||
select
|
select
|
||||||
value={form.priority}
|
value={field.value}
|
||||||
onChange={(e) => handleChange('priority', e.target.value)}
|
onChange={(e) => field.onChange(e.target.value)}
|
||||||
>
|
>
|
||||||
{PRIORITIES.map((p) => (
|
{PRIORITIES.map((p) => (
|
||||||
<MenuItem key={p} value={p}>
|
<MenuItem key={p} value={p}>
|
||||||
|
|
@ -297,6 +244,8 @@ export default function ProposalFormPage() {
|
||||||
</MenuItem>
|
</MenuItem>
|
||||||
))}
|
))}
|
||||||
</TextField>
|
</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,13 +399,17 @@ 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 }}>
|
||||||
|
<Controller
|
||||||
|
name="serviceCategory"
|
||||||
|
control={control}
|
||||||
|
render={({ field }) => (
|
||||||
<TextField
|
<TextField
|
||||||
label="Service Category"
|
label="Service Category"
|
||||||
required
|
required
|
||||||
fullWidth
|
fullWidth
|
||||||
select
|
select
|
||||||
value={form.serviceCategory}
|
value={field.value ?? ''}
|
||||||
onChange={(e) => handleChange('serviceCategory', e.target.value)}
|
onChange={(e) => field.onChange(e.target.value)}
|
||||||
>
|
>
|
||||||
{SERVICE_CATEGORIES.map((cat) => (
|
{SERVICE_CATEGORIES.map((cat) => (
|
||||||
<MenuItem key={cat} value={cat}>
|
<MenuItem key={cat} value={cat}>
|
||||||
|
|
@ -464,16 +417,17 @@ export default function ProposalFormPage() {
|
||||||
</MenuItem>
|
</MenuItem>
|
||||||
))}
|
))}
|
||||||
</TextField>
|
</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>
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue