diff --git a/shared/api-contracts/src/index.ts b/shared/api-contracts/src/index.ts index 8d8a600..7575c72 100644 --- a/shared/api-contracts/src/index.ts +++ b/shared/api-contracts/src/index.ts @@ -1,5 +1,6 @@ export type ProposalStatus = 'Draft' | 'InReview' | 'Approved' | 'Sent' | 'Revised'; -export type ServiceCategory = 'HVAC' | 'Plumbing' | 'Electrical' | 'General' | 'Renovation'; +// Fix: WEB-M4 — align with API enum (ProposalSystem.Domain.Entities.ServiceCategory includes 'Other') +export type ServiceCategory = 'HVAC' | 'Plumbing' | 'Electrical' | 'General' | 'Renovation' | 'Other'; export type Priority = 'Standard' | 'Urgent' | 'Emergency'; export type LineItemSource = 'AI' | 'Vendor' | 'Manual' | 'Historical'; export type PricingMode = 'UnitPrice' | 'TotalPrice' | 'Both'; diff --git a/web/src/pages/admin/dashboard/AdminDashboard.tsx b/web/src/pages/admin/dashboard/AdminDashboard.tsx index c1157b1..45381c2 100644 --- a/web/src/pages/admin/dashboard/AdminDashboard.tsx +++ b/web/src/pages/admin/dashboard/AdminDashboard.tsx @@ -25,6 +25,7 @@ import { Collapse, IconButton, Tooltip, + Alert, } from '@mui/material'; import SearchIcon from '@mui/icons-material/Search'; import PendingActionsIcon from '@mui/icons-material/PendingActions'; @@ -33,6 +34,7 @@ import TimerIcon from '@mui/icons-material/Timer'; import DescriptionIcon from '@mui/icons-material/Description'; import WarningAmberIcon from '@mui/icons-material/WarningAmber'; import ReportIcon from '@mui/icons-material/Report'; +import ErrorOutlineIcon from '@mui/icons-material/ErrorOutline'; import KeyboardArrowDownIcon from '@mui/icons-material/KeyboardArrowDown'; import KeyboardArrowRightIcon from '@mui/icons-material/KeyboardArrowRight'; import { usePaginatedList } from '../../../hooks/usePaginatedList'; @@ -72,7 +74,8 @@ export default function AdminDashboard({ defaultStatus }: { defaultStatus?: stri const navigate = useNavigate(); const showTabs = !defaultStatus; - const { data: stats, isLoading: statsLoading } = useQuery({ + // Fix: WEB-M8 — capture error state for admin stats to show error instead of misleading zeros + const { data: stats, isLoading: statsLoading, isError: statsError, refetch: refetchStats } = useQuery({ queryKey: [QUERY_KEYS.dashboard], queryFn: adminApi.getDashboard, }); @@ -182,59 +185,78 @@ export default function AdminDashboard({ defaultStatus }: { defaultStatus?: stri {defaultStatus ? 'Admin Queue' : 'All Proposals'} + {/* Fix: WEB-M8 — show error state instead of misleading zeros when stats fetch fails */} - - {statsLoading ? ( - - ) : ( - } - label="Pending Review" - value={String(stats?.pendingCount ?? 0)} - color="#1565c0" - subtitle="Awaiting action" - /> - )} - - - {statsLoading ? ( - - ) : ( - } - label="Approved This Week" - value={String(stats?.approvedThisWeek ?? 0)} - color="#2e7d32" - subtitle={`${stats?.approvedThisWeek ?? 0} approved`} - /> - )} - - - {statsLoading ? ( - - ) : ( - } - label="Avg Turnaround" - value={stats ? `${Math.round(stats.avgTurnaroundHours)}h` : '-'} - color="#f57c00" - subtitle="Target: <24h" - /> - )} - - - {statsLoading ? ( - - ) : ( - } - label="Total Proposals" - value={String(stats?.totalProposals ?? 0)} - color="#5e35b1" - subtitle="All time" - /> - )} - + {statsError ? ( + + } + action={ + + } + > + Failed to load dashboard statistics. Please try again. + + + ) : ( + <> + + {statsLoading ? ( + + ) : ( + } + label="Pending Review" + value={String(stats?.pendingCount ?? 0)} + color="#1565c0" + subtitle="Awaiting action" + /> + )} + + + {statsLoading ? ( + + ) : ( + } + label="Approved This Week" + value={String(stats?.approvedThisWeek ?? 0)} + color="#2e7d32" + subtitle={`${stats?.approvedThisWeek ?? 0} approved`} + /> + )} + + + {statsLoading ? ( + + ) : ( + } + label="Avg Turnaround" + value={stats ? `${Math.round(stats.avgTurnaroundHours)}h` : '-'} + color="#f57c00" + subtitle="Target: <24h" + /> + )} + + + {statsLoading ? ( + + ) : ( + } + label="Total Proposals" + value={String(stats?.totalProposals ?? 0)} + color="#5e35b1" + subtitle="All time" + /> + )} + + + )} diff --git a/web/src/pages/admin/workspace/AdminWorkspace.tsx b/web/src/pages/admin/workspace/AdminWorkspace.tsx index 4d23eab..3d6d8bf 100644 --- a/web/src/pages/admin/workspace/AdminWorkspace.tsx +++ b/web/src/pages/admin/workspace/AdminWorkspace.tsx @@ -18,14 +18,18 @@ import { CircularProgress, Alert, Tooltip, + Link, + Skeleton, } from '@mui/material'; import ArrowBackIcon from '@mui/icons-material/ArrowBack'; import SaveIcon from '@mui/icons-material/Save'; import CheckCircleIcon from '@mui/icons-material/CheckCircle'; +import NavigateNextIcon from '@mui/icons-material/NavigateNext'; import SendIcon from '@mui/icons-material/Send'; import RefreshIcon from '@mui/icons-material/Refresh'; import HistoryIcon from '@mui/icons-material/History'; import PictureAsPdfIcon from '@mui/icons-material/PictureAsPdf'; +import UndoIcon from '@mui/icons-material/Undo'; import { toast } from 'react-toastify'; import { proposalsApi, type ProposalDetail } from '../../../lib/api/proposals'; import { lineItemsApi, type LineItem } from '../../../lib/api/lineItems'; @@ -35,7 +39,7 @@ import { QUERY_KEYS } from '../../../constants/queryKeys'; import LineItemEditor, { type EditableLineItem } from '../../../components/admin/LineItemEditor'; import VendorDataPanel from '../../../components/admin/VendorDataPanel'; import SimilarProposalsPanel from '../../../components/admin/SimilarProposalsPanel'; -import { STATUS_COLORS } from '../../../constants'; +import { STATUS_CHIP_STYLES } from '../../../constants'; export default function AdminWorkspace() { const { id } = useParams<{ id: string }>(); @@ -46,6 +50,8 @@ export default function AdminWorkspace() { const [approveDialogOpen, setApproveDialogOpen] = useState(false); const [sendDialogOpen, setSendDialogOpen] = useState(false); const [reviseDialogOpen, setReviseDialogOpen] = useState(false); + // Fix: WEB-M11 — add dialog state for Return to Review action + const [returnToReviewDialogOpen, setReturnToReviewDialogOpen] = useState(false); const [dirty, setDirty] = useState(false); // Fix: WEB-M7 — capture error state for failed fetch @@ -55,12 +61,21 @@ export default function AdminWorkspace() { enabled: !!id, }); - const { data: fetchedLineItems } = useQuery({ + // Fix: WEB-M9 — capture loading state for line items skeleton + const { data: fetchedLineItems, isLoading: lineItemsLoading } = useQuery({ queryKey: [QUERY_KEYS.proposalLineItems, id], queryFn: () => lineItemsApi.getAll(id!), enabled: !!id, }); + const { data: similarProposals } = useQuery({ + queryKey: ['similarProposals', id], + queryFn: () => adminApi.getSimilar(id!), + enabled: !!id, + }); + + const hasSimilar = similarProposals && similarProposals.length > 0; + useEffect(() => { if (fetchedLineItems) { setLineItems( @@ -126,6 +141,9 @@ export default function AdminWorkspace() { setDirty(false); toast.success('Changes saved'); }, + onError: (error: Error) => { + toast.error(`Save failed: ${error.message}`); + }, }); const approveMutation = useMutation({ @@ -142,6 +160,9 @@ export default function AdminWorkspace() { setDirty(false); toast.success('Proposal approved'); }, + onError: (error: Error) => { + toast.error(`Approval failed: ${error.message}`); + }, }); const sendMutation = useMutation({ @@ -151,6 +172,10 @@ export default function AdminWorkspace() { setSendDialogOpen(false); toast.success('Proposal marked as sent'); }, + // Fix: WEB-H5 — add missing onError handler + onError: (error: Error) => { + toast.error(`Send failed: ${error.message}`); + }, }); const reviseMutation = useMutation({ @@ -160,6 +185,10 @@ export default function AdminWorkspace() { setReviseDialogOpen(false); toast.success('Revision created'); }, + // Fix: WEB-H6 — add missing onError handler + onError: (error: Error) => { + toast.error(`Revision failed: ${error.message}`); + }, }); const regenerateMutation = useMutation({ @@ -167,6 +196,24 @@ export default function AdminWorkspace() { onSuccess: () => { toast.info('AI suggestion generation started'); }, + // Fix: WEB-H5 — add missing onError handler + onError: (error: Error) => { + toast.error(`Regeneration failed: ${error.message}`); + }, + }); + + // Fix: WEB-M11 — wire returnToReview API method to UI + const returnToReviewMutation = useMutation({ + mutationFn: () => adminApi.returnToReview(id!), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: [QUERY_KEYS.proposals, id] }); + queryClient.invalidateQueries({ queryKey: [QUERY_KEYS.proposalLineItems, id] }); + setReturnToReviewDialogOpen(false); + toast.success('Proposal returned to review'); + }, + onError: (error: Error) => { + toast.error(`Return to review failed: ${error.message}`); + }, }); const pdfMutation = useMutation({ @@ -253,15 +300,23 @@ export default function AdminWorkspace() { return ( {/* Header */} - - - - {proposal.proposalNumber} - + + + navigate('/admin')} + sx={{ fontSize: 14, color: '#0B5A73', cursor: 'pointer' }} + > + Admin Queue + + + + {proposal.proposalNumber} + + - + {/* Proposal Info Bar */} @@ -294,7 +349,7 @@ export default function AdminWorkspace() { {/* Left Panel: Reference */} - + Submission Details @@ -329,7 +384,7 @@ export default function AdminWorkspace() { {/* Center Panel: Editor */} - + Refined Scope @@ -345,37 +400,55 @@ export default function AdminWorkspace() { sx={{ mb: 2 }} /> - + {/* Fix: WEB-M9 — show loading skeleton while line items are being fetched */} + {lineItemsLoading ? ( + + + + + + {Array.from({ length: 4 }).map((_, i) => ( + + ))} + + + + + ) : ( + + )} - {/* Right Panel: Similar Proposals */} - - - - { - const maxSort = lineItems.length > 0 ? Math.max(...lineItems.map((i) => i.sortOrder)) : 0; - handleLineItemsChange([ - ...lineItems, - { ...item, id: null, sortOrder: maxSort + 1, source: 'Historical' }, - ]); - }} - disabled={!isEditable} - /> - - - + {/* Right Panel: Similar Proposals (hidden when empty) */} + {hasSimilar && ( + + + + { + const maxSort = lineItems.length > 0 ? Math.max(...lineItems.map((i) => i.sortOrder)) : 0; + handleLineItemsChange([ + ...lineItems, + { ...item, id: null, sortOrder: maxSort + 1, source: 'Historical' }, + ]); + }} + disabled={!isEditable} + /> + + + + )} {/* Action Bar */} - + {dirty && ( Unsaved changes @@ -393,14 +466,21 @@ export default function AdminWorkspace() { > Regenerate - + + + + + + {/* Fix: WEB-M11 — Return to Review Confirmation Dialog */} + setReturnToReviewDialogOpen(false)}> + Return to Review + + + Return {proposal.proposalNumber} to In Review status? + This will clear the approval and allow further edits to line items and scope. + + + + + + + ); } diff --git a/web/src/pages/dashboard/Dashboard.tsx b/web/src/pages/dashboard/Dashboard.tsx index 52311ae..5fd09de 100644 --- a/web/src/pages/dashboard/Dashboard.tsx +++ b/web/src/pages/dashboard/Dashboard.tsx @@ -15,12 +15,14 @@ import { Chip, Button, Skeleton, + Alert, } from '@mui/material'; import AddCircleIcon from '@mui/icons-material/AddCircle'; import DescriptionIcon from '@mui/icons-material/Description'; import HourglassTopIcon from '@mui/icons-material/HourglassTop'; import CheckCircleIcon from '@mui/icons-material/CheckCircle'; import SendIcon from '@mui/icons-material/Send'; +import ErrorOutlineIcon from '@mui/icons-material/ErrorOutline'; import Tooltip from '@mui/material/Tooltip'; import { proposalsApi, type ProposalListItem, type ProposalStats } from '../../lib/api/proposals'; import { QUERY_KEYS } from '../../constants/queryKeys'; @@ -58,12 +60,12 @@ export default function Dashboard() { queryFn: () => proposalsApi.getAll({ page: 1, pageSize: 5, mine: true }), }); - const { data: stats, isLoading: statsLoading } = useQuery({ + // Fix: WEB-M8 — capture error state for stats to show error instead of misleading zeros + const { data: stats, isLoading: statsLoading, isError: statsError, refetch: refetchStats } = useQuery({ queryKey: [QUERY_KEYS.proposals, 'stats'], queryFn: () => proposalsApi.getStats(), }); - const isLoading = recentLoading || statsLoading; const proposals = recentData?.items ?? []; const total = stats?.totalCount ?? 0; const counts = { @@ -85,35 +87,54 @@ export default function Dashboard() { + {/* Fix: WEB-M8 — show error state instead of misleading zeros when stats fetch fails */} - - {isLoading ? ( - - ) : ( - } label="Total Submitted" value={String(total)} subtitle="All time" /> - )} - - - {isLoading ? ( - - ) : ( - } label="In Review" value={String(counts.inReview)} subtitle="Awaiting pricing" /> - )} - - - {isLoading ? ( - - ) : ( - } label="Approved" value={String(counts.approved)} subtitle="Ready to send" /> - )} - - - {isLoading ? ( - - ) : ( - } label="Sent" value={String(counts.sent)} subtitle="Delivered to customer" /> - )} - + {statsError ? ( + + } + action={ + + } + > + Failed to load dashboard statistics. Please try again. + + + ) : ( + <> + + {statsLoading ? ( + + ) : ( + } label="Total Submitted" value={String(total)} subtitle="All time" /> + )} + + + {statsLoading ? ( + + ) : ( + } label="In Review" value={String(counts.inReview)} subtitle="Awaiting pricing" /> + )} + + + {statsLoading ? ( + + ) : ( + } label="Approved" value={String(counts.approved)} subtitle="Ready to send" /> + )} + + + {statsLoading ? ( + + ) : ( + } label="Sent" value={String(counts.sent)} subtitle="Delivered to customer" /> + )} + + + )} @@ -138,7 +159,7 @@ export default function Dashboard() { - {isLoading + {recentLoading ? Array.from({ length: 5 }).map((_, i) => ( {Array.from({ length: 6 }).map((_, j) => ( @@ -167,7 +188,7 @@ export default function Dashboard() { ))} - {!isLoading && recentError && ( + {!recentLoading && recentError && ( @@ -176,7 +197,7 @@ export default function Dashboard() { )} - {!isLoading && !recentError && proposals.length === 0 && ( + {!recentLoading && !recentError && proposals.length === 0 && ( diff --git a/web/src/pages/proposals/form/ProposalFormPage.tsx b/web/src/pages/proposals/form/ProposalFormPage.tsx index 602ee21..df5548e 100644 --- a/web/src/pages/proposals/form/ProposalFormPage.tsx +++ b/web/src/pages/proposals/form/ProposalFormPage.tsx @@ -1,4 +1,4 @@ -import { useState, useCallback, useRef, useEffect } from 'react'; +import { useState, useCallback, useRef, useEffect, useMemo } from 'react'; import { useNavigate } from 'react-router-dom'; import { useMutation } from '@tanstack/react-query'; import { @@ -16,13 +16,13 @@ import { 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 { customersApi, type Customer } from '../../../lib/api/customers'; -import { SERVICE_CATEGORIES, PRIORITIES } from '../../../constants'; +import { sitesApi, type Site } from '../../../lib/api/sites'; +import { SERVICE_CATEGORIES, PRIORITIES, PRIORITY_LABELS } from '../../../constants'; import { queryClient } from '../../../lib/queryClient'; -// Fix: WEB-M5 — form state allows empty serviceCategory during editing; validated before submit interface ProposalFormState { workOrderNumber: string; + poNumber?: string; customerName: string; customerAddress: string; scopeOfWork: string; @@ -31,16 +31,18 @@ interface ProposalFormState { notes: string; } -// Fix: WEB-M6 — file validation constants -const MAX_FILE_SIZE_BYTES = 25 * 1024 * 1024; // 25 MB +const MAX_FILE_SIZE_BYTES = 25 * 1024 * 1024; const ALLOWED_MIME_TYPES = ['application/pdf']; +const CUSTOMERS = ['Amazon Services, LLC'] as const; +// 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: '', + customerName: CUSTOMERS[0], customerAddress: '', scopeOfWork: '', serviceCategory: '', @@ -48,53 +50,65 @@ export default function ProposalFormPage() { notes: '', }); - const [customers, setCustomers] = useState([]); - const [customerLoading, setCustomerLoading] = useState(false); - const [addresses, setAddresses] = useState([]); const [vendorFile, setVendorFile] = useState(null); - const searchDebounceRef = useRef | null>(null); + const [siteOptions, setSiteOptions] = useState([]); + const [siteLoading, setSiteLoading] = useState(false); + const [siteInputValue, setSiteInputValue] = useState(''); + const [manualEntry, setManualEntry] = useState(false); + const [manualSiteCode, setManualSiteCode] = useState(''); + const [manualStreet, setManualStreet] = useState(''); + const [manualCity, setManualCity] = useState(''); + const [manualState, setManualState] = useState(''); + const [manualZip, setManualZip] = useState(''); + const [customCategory, setCustomCategory] = useState(''); + const siteDebounceRef = useRef | null>(null); useEffect(() => { return () => { - if (searchDebounceRef.current) clearTimeout(searchDebounceRef.current); + if (siteDebounceRef.current) clearTimeout(siteDebounceRef.current); }; }, []); + useEffect(() => { + if (!manualEntry) return; + const addressParts = [ + manualStreet, + manualCity, + [manualState, manualZip].filter(Boolean).join(' '), + ].filter(Boolean); + const address = addressParts.join(', '); + const display = + manualSiteCode && address + ? `${manualSiteCode} — ${address}` + : manualSiteCode || address; + setForm((prev) => ({ ...prev, customerAddress: display })); + }, [manualEntry, manualSiteCode, manualStreet, manualCity, manualState, manualZip]); + const handleChange = (field: keyof ProposalFormState, value: string) => { setForm((prev) => ({ ...prev, [field]: value })); }; - const searchCustomers = useCallback(async (query: string) => { - if (query.length < 2) return; - setCustomerLoading(true); + const searchSites = useCallback(async (query: string) => { + if (query.length < 2) { + setSiteOptions([]); + return; + } + setSiteLoading(true); try { - const results = await customersApi.search(query); - setCustomers(results); + const results = await sitesApi.search(query); + setSiteOptions(results); } catch { - setCustomers([]); + setSiteOptions([]); } finally { - setCustomerLoading(false); + setSiteLoading(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 () => { - // Fix: WEB-M5 — cast validated form state to the strict request type + 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, @@ -102,7 +116,7 @@ export default function ProposalFormPage() { scopeOfWork: form.scopeOfWork, serviceCategory: form.serviceCategory as ServiceCategory, priority: form.priority, - notes: form.notes || undefined, + notes, }; const proposal = await proposalsApi.create(request); @@ -137,58 +151,215 @@ export default function ProposalFormPage() { createMutation.mutate(); }; + // Fix: WEB-M3 — enforce minimum scope length + const scopeTouched = form.scopeOfWork.length > 0; + const scopeTooShort = scopeTouched && form.scopeOfWork.trim().length < MIN_SCOPE_LENGTH; + const isValid = form.workOrderNumber.trim() && form.customerName.trim() && form.customerAddress.trim() && - form.scopeOfWork.trim() && - form.serviceCategory; + form.scopeOfWork.trim().length >= MIN_SCOPE_LENGTH && + form.serviceCategory && + (form.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'); + // 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)`); + return missing; + }, [form.workOrderNumber, form.customerAddress, form.serviceCategory, form.scopeOfWork, customCategory]); return ( - + New Proposal Request - - - + + + + Job Details - + handleChange('workOrderNumber', e.target.value)} /> + + handleChange('poNumber', e.target.value)} + /> + + + handleChange('customerName', e.target.value)} + > + {CUSTOMERS.map((name) => ( + + {name} + + ))} + + + + handleChange('priority', e.target.value)} + > + {PRIORITIES.map((p) => ( + + {PRIORITY_LABELS[p] || p} + + ))} + + + + + - + + + Site & Location + {manualEntry ? ( + + + + setManualSiteCode(e.target.value)} + placeholder="e.g. LGB8" + /> + + + setManualStreet(e.target.value)} + /> + + + setManualCity(e.target.value)} + /> + + + setManualState(e.target.value)} + placeholder="e.g. CA" + /> + + + setManualZip(e.target.value)} + /> + + + + + ) : ( + (typeof opt === 'string' ? opt : opt.name)} - loading={customerLoading} + options={siteOptions} + filterOptions={(x) => x} + getOptionLabel={(opt) => + opt.fullAddress + ? `${opt.siteCode} — ${opt.fullAddress}` + : opt.siteCode + } + loading={siteLoading} + inputValue={siteInputValue} onInputChange={(_, value, reason) => { if (reason === 'input') { - handleChange('customerName', value); + setSiteInputValue(value); + if (siteDebounceRef.current) clearTimeout(siteDebounceRef.current); + siteDebounceRef.current = setTimeout(() => searchSites(value), 300); } - if (searchDebounceRef.current) clearTimeout(searchDebounceRef.current); - searchDebounceRef.current = setTimeout(() => searchCustomers(value), 300); }} - onChange={handleCustomerSelect} + onChange={(_, value) => { + if (value) { + const display = value.fullAddress + ? `${value.siteCode} — ${value.fullAddress}` + : value.siteCode; + handleChange('customerAddress', display); + setSiteInputValue(display); + } else { + handleChange('customerAddress', ''); + setSiteInputValue(''); + } + }} + noOptionsText={ + siteLoading + ? 'Searching...' + : siteInputValue.length >= 2 + ? `No verified sites found for "${siteInputValue}"` + : 'Type a site code to search (e.g. LGB8)' + } renderInput={(params) => ( - {customerLoading && } + {siteLoading && } {params.InputProps.endAdornment} ), @@ -197,35 +368,23 @@ export default function ProposalFormPage() { /> )} /> - - - - {addresses.length > 1 ? ( - handleChange('customerAddress', e.target.value)} - > - {addresses.map((addr) => ( - - {addr} - - ))} - - ) : ( - handleChange('customerAddress', e.target.value)} - /> - )} - + + + )} + + + + + Work Details + - - handleChange('priority', e.target.value)} - > - {PRIORITIES.map((p) => ( - - {p} - - ))} - - + {form.serviceCategory === 'Other' ? ( + + setCustomCategory(e.target.value)} + placeholder="e.g. Fire Protection" + /> + + ) : ( + + )} + {/* Fix: WEB-M3 — inline error when scope is too short */} handleChange('scopeOfWork', e.target.value)} placeholder="Describe the work needed..." + error={scopeTooShort} + helperText={ + scopeTooShort + ? `Scope must be at least ${MIN_SCOPE_LENGTH} characters (currently ${form.scopeOfWork.trim().length})` + : undefined + } /> @@ -313,26 +477,29 @@ export default function ProposalFormPage() { /> - - - - - - - - - - + + + + + {!isValid && missingFields.length > 0 && ( + + Complete required fields to submit: {missingFields.join(', ')} + + )} + + + + ); }