diff --git a/web/src/pages/proposals/detail/ProposalDetailPage.tsx b/web/src/pages/proposals/detail/ProposalDetailPage.tsx index 41a923a..a3315e7 100644 --- a/web/src/pages/proposals/detail/ProposalDetailPage.tsx +++ b/web/src/pages/proposals/detail/ProposalDetailPage.tsx @@ -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({ - queryKey: ['proposals', id], - queryFn: () => proposalsApi.getById(id!), - enabled: !!id, - }); + const { data: proposal, isLoading, error } = useProposal(id); useDocumentTitle(proposal?.proposalNumber ?? 'Proposal'); diff --git a/web/src/pages/proposals/form/ProposalFormPage.tsx b/web/src/pages/proposals/form/ProposalFormPage.tsx index 2d1d6b9..041cb71 100644 --- a/web/src/pages/proposals/form/ProposalFormPage.tsx +++ b/web/src/pages/proposals/form/ProposalFormPage.tsx @@ -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({ - workOrderNumber: '', - customerName: '', - customerAddress: '', - scopeOfWork: '', - serviceCategory: '', - priority: 'Standard', - notes: '', + const { + register, + handleSubmit, + control, + setValue, + watch, + } = useForm({ + resolver: zodResolver(createProposalFormSchema), + defaultValues: { + workOrderNumber: '', + poNumber: '', + customerName: '', + customerAddress: '', + scopeOfWork: '', + priority: 'Standard', + notes: '', + customCategory: '', + }, }); const [vendorFile, setVendorFile] = useState(null); - const [siteOptions, setSiteOptions] = useState([]); - 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 | null>(null); - // PR4: customer search state — freeSolo Autocomplete backed by customersApi.search - const [customerOptions, setCustomerOptions] = useState([]); - 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 | 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 ( @@ -205,7 +150,7 @@ export default function ProposalFormPage() { New Proposal Request - + Job Details @@ -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')} /> handleChange('poNumber', e.target.value)} + {...register('poNumber')} /> {/* 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() { /> - handleChange('priority', e.target.value)} - > - {PRIORITIES.map((p) => ( - - {PRIORITY_LABELS[p] || p} - - ))} - + ( + field.onChange(e.target.value)} + > + {PRIORITIES.map((p) => ( + + {PRIORITY_LABELS[p] || p} + + ))} + + )} + /> @@ -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() { Work Details - handleChange('serviceCategory', e.target.value)} - > - {SERVICE_CATEGORIES.map((cat) => ( - - {cat} - - ))} - + ( + field.onChange(e.target.value)} + > + {SERVICE_CATEGORIES.map((cat) => ( + + {cat} + + ))} + + )} + /> - {form.serviceCategory === 'Other' ? ( + {serviceCategory === 'Other' ? ( setCustomCategory(e.target.value)} + {...register('customCategory')} placeholder="e.g. Fire Protection" /> @@ -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')} /> diff --git a/web/src/pages/proposals/list/ProposalListPage.tsx b/web/src/pages/proposals/list/ProposalListPage.tsx index ac2e252..3ab2aa6 100644 --- a/web/src/pages/proposals/list/ProposalListPage.tsx +++ b/web/src/pages/proposals/list/ProposalListPage.tsx @@ -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 | 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(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 ( @@ -127,7 +145,7 @@ export default function ProposalListPage() { {row.totalBidAmount > 0 ? formatBidAmount(row.totalBidAmount) - : Not priced + : Not priced }