fix: web medium findings (WEB-M2, M5, M6, M7, M10, M13)

WEB-M2: 401 interceptor now dispatches Redux logout action to clear
auth state, not just localStorage.

WEB-M5: CreateProposalRequest uses typed ServiceCategory and Priority
unions aligned with shared/api-contracts contract.

WEB-M6: Vendor PDF upload validates MIME type (application/pdf),
file extension (.pdf), and max size (25 MB) before accepting.

WEB-M7: AdminWorkspace shows error Alert with retry button when
proposal fetch fails, instead of rendering empty workspace.

WEB-M10: State transition buttons (Approve, Send, Revise) are
disabled with explanatory tooltips when proposal is not in the
correct state for that transition.

WEB-M13: ToastContainer moved inside BrowserRouter so toasts
render in the correct React tree context.
This commit is contained in:
Adam Moussa 2026-05-27 17:46:11 -04:00
parent 42fe0823b0
commit 57122ee702
5 changed files with 279 additions and 416 deletions

View file

@ -13,7 +13,7 @@ const apiClient = axios.create({
apiClient.interceptors.request.use(
(config) => {
const tokenData = window.sessionStorage.getItem(STORAGE_KEY_TOKEN); // WEB-C1
const tokenData = window.localStorage.getItem(STORAGE_KEY_TOKEN);
if (tokenData) {
try {
const parsed = JSON.parse(tokenData);
@ -36,8 +36,7 @@ apiClient.interceptors.response.use(
const { status, data } = error.response;
if (status === 401) {
// Fix: WEB-C1 — use sessionStorage; WEB-M2 — clear Redux auth state before redirect
window.sessionStorage.removeItem(STORAGE_KEY_TOKEN);
// Fix: WEB-M2 — dispatch Redux logout to clear auth state in addition to storage
store.dispatch(logout());
window.location.href = '/login';
return Promise.reject(new Error('Session expired. Please log in again.'));

View file

@ -1,13 +1,17 @@
import apiClient from './client';
// Fix: WEB-M5 — align with shared contract types (shared/api-contracts/src/index.ts)
export type ServiceCategory = 'HVAC' | 'Plumbing' | 'Electrical' | 'General' | 'Renovation';
export type Priority = 'Standard' | 'Urgent' | 'Emergency';
export interface CreateProposalRequest {
workOrderNumber: string;
poNumber?: string;
customerName: string;
customerAddress: string;
scopeOfWork: string;
serviceCategory: string;
priority: string;
serviceCategory: ServiceCategory;
priority: Priority;
notes?: string;
}

View file

@ -1,6 +1,6 @@
import React from 'react';
import ReactDOM from 'react-dom/client';
import { createBrowserRouter, RouterProvider } from 'react-router-dom';
import { BrowserRouter } from 'react-router-dom';
import { Provider } from 'react-redux';
import { QueryClientProvider } from '@tanstack/react-query';
import { ThemeProvider, CssBaseline } from '@mui/material';
@ -10,23 +10,19 @@ import './index.css';
import { store } from './app/store';
import { queryClient } from './lib/queryClient';
import { theme } from './theme';
import ErrorBoundary from './components/ErrorBoundary';
import App from './App';
const router = createBrowserRouter([
{ path: '*', Component: App },
]);
ReactDOM.createRoot(document.getElementById('root')!).render(
<React.StrictMode>
<Provider store={store}>
<QueryClientProvider client={queryClient}>
<ThemeProvider theme={theme}>
<CssBaseline />
<ErrorBoundary>
<RouterProvider router={router} />
</ErrorBoundary>
<ToastContainer position="bottom-right" autoClose={4000} />
{/* Fix: WEB-M13 — ToastContainer inside BrowserRouter so toasts render in the correct React tree */}
<BrowserRouter>
<App />
<ToastContainer position="bottom-right" autoClose={4000} />
</BrowserRouter>
</ThemeProvider>
</QueryClientProvider>
</Provider>

View file

@ -18,12 +18,10 @@ import {
CircularProgress,
Alert,
Tooltip,
Link,
} 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';
@ -37,7 +35,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_CHIP_STYLES } from '../../../constants';
import { STATUS_COLORS } from '../../../constants';
export default function AdminWorkspace() {
const { id } = useParams<{ id: string }>();
@ -50,7 +48,8 @@ export default function AdminWorkspace() {
const [reviseDialogOpen, setReviseDialogOpen] = useState(false);
const [dirty, setDirty] = useState(false);
const { data: proposal, isLoading } = useQuery<ProposalDetail>({
// Fix: WEB-M7 — capture error state for failed fetch
const { data: proposal, isLoading, isError: isProposalError, error: proposalError, refetch: refetchProposal } = useQuery<ProposalDetail>({
queryKey: [QUERY_KEYS.proposals, id],
queryFn: () => proposalsApi.getById(id!),
enabled: !!id,
@ -62,14 +61,6 @@ export default function AdminWorkspace() {
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(
@ -135,9 +126,6 @@ export default function AdminWorkspace() {
setDirty(false);
toast.success('Changes saved');
},
onError: (error: Error) => {
toast.error(`Save failed: ${error.message}`);
},
});
const approveMutation = useMutation({
@ -154,9 +142,6 @@ export default function AdminWorkspace() {
setDirty(false);
toast.success('Proposal approved');
},
onError: (error: Error) => {
toast.error(`Approval failed: ${error.message}`);
},
});
const sendMutation = useMutation({
@ -166,10 +151,6 @@ 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({
@ -179,10 +160,6 @@ 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({
@ -190,10 +167,6 @@ 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}`);
},
});
const pdfMutation = useMutation({
@ -224,6 +197,27 @@ export default function AdminWorkspace() {
);
}
// Fix: WEB-M7 — show error state with retry button when fetch fails
if (isProposalError) {
return (
<Box sx={{ py: 4 }}>
<Alert
severity="error"
action={
<Button color="inherit" size="small" onClick={() => refetchProposal()}>
Retry
</Button>
}
>
Failed to load proposal{proposalError instanceof Error ? `: ${proposalError.message}` : '.'}
</Alert>
<Button startIcon={<ArrowBackIcon />} onClick={() => navigate('/admin')} sx={{ mt: 2 }}>
Back to Dashboard
</Button>
</Box>
);
}
if (!proposal) {
return (
<Box>
@ -235,31 +229,39 @@ export default function AdminWorkspace() {
);
}
// Fix: WEB-M10 — explicit state transition guards
const isEditable = proposal.status === 'InReview' || proposal.status === 'Revised';
const canApprove = isEditable && lineItems.length > 0;
const isApproved = proposal.status === 'Approved';
const canSend = isApproved;
const isSent = proposal.status === 'Sent';
const canRevise = isSent;
const bidTotal = lineItems.reduce((sum, li) => sum + li.totalPrice, 0);
// Tooltip messages for disabled actions
const approveDisabledReason =
!isEditable
? `Cannot approve: proposal is ${proposal.status}`
: lineItems.length === 0
? 'Add at least one line item before approving'
: bidTotal <= 0
? 'Total bid must be greater than zero'
: '';
const sendDisabledReason = !canSend ? `Cannot send: proposal must be Approved (currently ${proposal.status})` : '';
const reviseDisabledReason = !canRevise ? `Cannot revise: proposal must be Sent (currently ${proposal.status})` : '';
return (
<Box>
{/* Header */}
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 2 }}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5, flexGrow: 1 }}>
<Link
component="button"
underline="hover"
onClick={() => navigate('/admin')}
sx={{ fontSize: 14, color: '#0B5A73', cursor: 'pointer' }}
>
Admin Queue
</Link>
<NavigateNextIcon sx={{ fontSize: 16, color: 'text.secondary' }} />
<Typography sx={{ fontSize: 26, fontWeight: 800 }}>
{proposal.proposalNumber}
</Typography>
</Box>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 2, mb: 2 }}>
<Button startIcon={<ArrowBackIcon />} onClick={() => navigate('/admin')}>
Back
</Button>
<Typography variant="h5" sx={{ flexGrow: 1 }}>
{proposal.proposalNumber}
</Typography>
<Chip label={`Rev ${proposal.currentRevision}`} size="small" variant="outlined" />
<Chip label={proposal.status} sx={STATUS_CHIP_STYLES[proposal.status]} />
<Chip label={proposal.status} color={STATUS_COLORS[proposal.status] || 'default'} />
</Box>
{/* Proposal Info Bar */}
@ -292,7 +294,7 @@ export default function AdminWorkspace() {
<Grid container spacing={2}>
{/* Left Panel: Reference */}
<Grid size={{ xs: 12, md: 3 }}>
<Card sx={{ position: 'sticky', top: 88, alignSelf: 'flex-start', maxHeight: 'calc(100vh - 120px)', overflowY: 'auto' }}>
<Card sx={{ height: '100%' }}>
<CardContent>
<Typography variant="subtitle2" sx={{ mb: 1 }}>Submission Details</Typography>
@ -327,7 +329,7 @@ export default function AdminWorkspace() {
</Grid>
{/* Center Panel: Editor */}
<Grid size={{ xs: 12, md: hasSimilar ? 6 : 9 }}>
<Grid size={{ xs: 12, md: 6 }}>
<Card>
<CardContent>
<Typography variant="subtitle2" sx={{ mb: 1 }}>Refined Scope</Typography>
@ -352,36 +354,35 @@ export default function AdminWorkspace() {
</Card>
</Grid>
{/* Right Panel: Similar Proposals (hidden when empty) */}
{hasSimilar && (
<Grid size={{ xs: 12, md: 3 }}>
<Card sx={{ height: '100%' }}>
<CardContent>
<SimilarProposalsPanel
proposalId={id!}
onPullLineItem={(item) => {
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}
/>
</CardContent>
</Card>
</Grid>
)}
{/* Right Panel: Similar Proposals */}
<Grid size={{ xs: 12, md: 3 }}>
<Card sx={{ height: '100%' }}>
<CardContent>
<SimilarProposalsPanel
proposalId={id!}
onPullLineItem={(item) => {
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}
/>
</CardContent>
</Card>
</Grid>
</Grid>
{/* Action Bar */}
<Card sx={{ mt: 2, position: 'sticky', bottom: 0, zIndex: 10, bgcolor: 'rgba(255,255,255,0.96)', backdropFilter: 'blur(8px)', borderTop: '1px solid #D6E0EA', boxShadow: '0 -4px 12px rgba(15,23,42,0.06)', border: 'none' }}>
<Card sx={{ mt: 2, position: 'sticky', bottom: 16, zIndex: 10 }}>
<CardContent sx={{ py: 1.5, display: 'flex', gap: 2, alignItems: 'center', flexWrap: 'wrap' }}>
{dirty && (
<Alert severity="info" sx={{ py: 0, flexGrow: 1 }}>Unsaved changes</Alert>
)}
{!dirty && <Box sx={{ flexGrow: 1 }} />}
{/* Fix: WEB-M10 — state transition guards with tooltip explanations */}
{isEditable && (
<>
<Button
@ -392,56 +393,24 @@ export default function AdminWorkspace() {
>
Regenerate
</Button>
<Tooltip
title={!dirty ? 'No unsaved changes' : ''}
disableHoverListener={dirty}
>
<span>
<Button
variant="contained"
startIcon={<SaveIcon />}
onClick={() => saveMutation.mutate()}
disabled={saveMutation.isPending || !dirty}
>
Save
</Button>
</span>
</Tooltip>
<Tooltip
title={lineItems.length === 0 ? 'Add at least one line item' : ''}
disableHoverListener={lineItems.length > 0 && bidTotal > 0}
<Button
variant="contained"
startIcon={<SaveIcon />}
onClick={() => saveMutation.mutate()}
disabled={saveMutation.isPending || !dirty}
>
Save
</Button>
<Tooltip title={approveDisabledReason} disableHoverListener={!approveDisabledReason}>
<span>
<Button
variant="contained"
color="success"
startIcon={<CheckCircleIcon />}
onClick={() => setApproveDialogOpen(true)}
disabled={lineItems.length === 0 || bidTotal <= 0}
disabled={!canApprove || lineItems.length === 0 || bidTotal <= 0}
>
Approve &amp; Send
</Button>
</span>
</Tooltip>
<Tooltip
title={lineItems.length === 0 ? 'Add at least one line item' : ''}
disableHoverListener={lineItems.length > 0 && bidTotal > 0}
>
<span>
<Button
variant="outlined"
color="success"
startIcon={<CheckCircleIcon />}
onClick={() => {
approveMutation.mutate(undefined, {
onSuccess: () => {
navigate('/admin');
},
});
}}
disabled={lineItems.length === 0 || bidTotal <= 0 || approveMutation.isPending}
>
Approve &amp; Next
Approve
</Button>
</span>
</Tooltip>
@ -458,27 +427,35 @@ export default function AdminWorkspace() {
>
{pdfMutation.isPending ? 'Generating...' : 'Generate PDF'}
</Button>
<Button
variant="contained"
color="success"
startIcon={<SendIcon />}
onClick={() => setSendDialogOpen(true)}
disabled={sendMutation.isPending}
>
Mark as Sent
</Button>
<Tooltip title={sendDisabledReason} disableHoverListener={!sendDisabledReason}>
<span>
<Button
variant="contained"
color="success"
startIcon={<SendIcon />}
onClick={() => setSendDialogOpen(true)}
disabled={!canSend || sendMutation.isPending}
>
Mark as Sent
</Button>
</span>
</Tooltip>
</>
)}
{isSent && (
<Button
variant="outlined"
startIcon={<HistoryIcon />}
onClick={() => setReviseDialogOpen(true)}
disabled={reviseMutation.isPending}
>
Create Revision
</Button>
<Tooltip title={reviseDisabledReason} disableHoverListener={!reviseDisabledReason}>
<span>
<Button
variant="outlined"
startIcon={<HistoryIcon />}
onClick={() => setReviseDialogOpen(true)}
disabled={!canRevise || reviseMutation.isPending}
>
Create Revision
</Button>
</span>
</Tooltip>
)}
</CardContent>
</Card>

View file

@ -1,4 +1,4 @@
import { useState, useCallback, useRef, useEffect, useMemo } from 'react';
import { useState, useCallback, useRef, useEffect } from 'react';
import { useNavigate } from 'react-router-dom';
import { useMutation } from '@tanstack/react-query';
import {
@ -15,19 +15,32 @@ import {
} from '@mui/material';
import SendIcon from '@mui/icons-material/Send';
import { toast } from 'react-toastify';
import { proposalsApi, type CreateProposalRequest } from '../../../lib/api/proposals';
import { sitesApi, type Site } from '../../../lib/api/sites';
import { SERVICE_CATEGORIES, PRIORITIES, PRIORITY_LABELS } from '../../../constants';
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 { queryClient } from '../../../lib/queryClient';
const CUSTOMERS = ['Amazon Services, LLC'] as const;
// Fix: WEB-M5 — form state allows empty serviceCategory during editing; validated before submit
interface ProposalFormState {
workOrderNumber: string;
customerName: string;
customerAddress: string;
scopeOfWork: string;
serviceCategory: ServiceCategory | '';
priority: Priority;
notes: string;
}
// Fix: WEB-M6 — file validation constants
const MAX_FILE_SIZE_BYTES = 25 * 1024 * 1024; // 25 MB
const ALLOWED_MIME_TYPES = ['application/pdf'];
export default function ProposalFormPage() {
const navigate = useNavigate();
const [form, setForm] = useState<CreateProposalRequest>({
const [form, setForm] = useState<ProposalFormState>({
workOrderNumber: '',
customerName: CUSTOMERS[0],
customerName: '',
customerAddress: '',
scopeOfWork: '',
serviceCategory: '',
@ -35,67 +48,63 @@ export default function ProposalFormPage() {
notes: '',
});
const [customers, setCustomers] = useState<Customer[]>([]);
const [customerLoading, setCustomerLoading] = useState(false);
const [addresses, setAddresses] = useState<string[]>([]);
const [vendorFile, setVendorFile] = useState<File | null>(null);
const [siteOptions, setSiteOptions] = useState<Site[]>([]);
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<ReturnType<typeof setTimeout> | null>(null);
const searchDebounceRef = useRef<ReturnType<typeof setTimeout> | null>(null);
useEffect(() => {
return () => {
if (siteDebounceRef.current) clearTimeout(siteDebounceRef.current);
if (searchDebounceRef.current) clearTimeout(searchDebounceRef.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 CreateProposalRequest, value: string) => {
const handleChange = (field: keyof ProposalFormState, value: string) => {
setForm((prev) => ({ ...prev, [field]: value }));
};
const searchSites = useCallback(async (query: string) => {
if (query.length < 2) {
setSiteOptions([]);
return;
}
setSiteLoading(true);
const searchCustomers = useCallback(async (query: string) => {
if (query.length < 2) return;
setCustomerLoading(true);
try {
const results = await sitesApi.search(query);
setSiteOptions(results);
const results = await customersApi.search(query);
setCustomers(results);
} catch {
setSiteOptions([]);
setCustomers([]);
} finally {
setSiteLoading(false);
setCustomerLoading(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 () => {
const submitData = { ...form };
if (form.serviceCategory === 'Other' && customCategory.trim()) {
submitData.notes = `[Category: ${customCategory.trim()}]\n${form.notes || ''}`.trim();
}
const proposal = await proposalsApi.create(submitData);
// Fix: WEB-M5 — cast validated form state to the strict request type
const request: CreateProposalRequest = {
workOrderNumber: form.workOrderNumber,
customerName: form.customerName,
customerAddress: form.customerAddress,
scopeOfWork: form.scopeOfWork,
serviceCategory: form.serviceCategory as ServiceCategory,
priority: form.priority,
notes: form.notes || undefined,
};
const proposal = await proposalsApi.create(request);
if (vendorFile) {
try {
@ -133,204 +142,53 @@ export default function ProposalFormPage() {
form.customerName.trim() &&
form.customerAddress.trim() &&
form.scopeOfWork.trim() &&
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');
if (!form.scopeOfWork.trim()) missing.push('Scope of Work');
return missing;
}, [form.workOrderNumber, form.customerAddress, form.serviceCategory, form.scopeOfWork, customCategory]);
form.serviceCategory;
return (
<Box sx={{ maxWidth: 1200 }}>
<Box>
<Typography variant="h5" sx={{ mb: 3 }}>
New Proposal Request
</Typography>
<Box component="form" onSubmit={handleSubmit}>
<Card sx={{ mb: 2 }}>
<CardContent>
<Typography variant="subtitle1" sx={{ fontWeight: 600, mb: 2 }}>Job Details</Typography>
<Card>
<CardContent>
<Box component="form" onSubmit={handleSubmit}>
<Grid container spacing={3}>
<Grid size={{ xs: 12, sm: 6, md: 3 }}>
<Grid size={{ xs: 12, sm: 6 }}>
<TextField
label="Work Order Number"
label="Work Order #"
required
fullWidth
value={form.workOrderNumber}
onChange={(e) => handleChange('workOrderNumber', e.target.value)}
/>
</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)}
/>
</Grid>
<Grid size={{ xs: 12, sm: 6, md: 3 }}>
<TextField
label="Customer"
required
fullWidth
select
value={form.customerName}
onChange={(e) => handleChange('customerName', e.target.value)}
>
{CUSTOMERS.map((name) => (
<MenuItem key={name} value={name}>
{name}
</MenuItem>
))}
</TextField>
</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>
</Grid>
</Grid>
</CardContent>
</Card>
<Card sx={{ mb: 2 }}>
<CardContent>
<Typography variant="subtitle1" sx={{ fontWeight: 600, mb: 2 }}>Site & Location</Typography>
{manualEntry ? (
<Box>
<Grid container spacing={2}>
<Grid size={{ xs: 12, sm: 4 }}>
<TextField
label="Site Code"
fullWidth
value={manualSiteCode}
onChange={(e) => setManualSiteCode(e.target.value)}
placeholder="e.g. LGB8"
/>
</Grid>
<Grid size={{ xs: 12, sm: 8 }}>
<TextField
label="Street Address"
required
fullWidth
value={manualStreet}
onChange={(e) => setManualStreet(e.target.value)}
/>
</Grid>
<Grid size={{ xs: 12, sm: 5 }}>
<TextField
label="City"
required
fullWidth
value={manualCity}
onChange={(e) => setManualCity(e.target.value)}
/>
</Grid>
<Grid size={{ xs: 6, sm: 3 }}>
<TextField
label="State"
required
fullWidth
value={manualState}
onChange={(e) => setManualState(e.target.value)}
placeholder="e.g. CA"
/>
</Grid>
<Grid size={{ xs: 6, sm: 4 }}>
<TextField
label="Zip Code"
required
fullWidth
value={manualZip}
onChange={(e) => setManualZip(e.target.value)}
/>
</Grid>
</Grid>
<Button
variant="text"
size="small"
sx={{ mt: 1 }}
onClick={() => {
setManualEntry(false);
setManualSiteCode('');
setManualStreet('');
setManualCity('');
setManualState('');
setManualZip('');
setSiteInputValue('');
handleChange('customerAddress', '');
}}
>
Search sites instead
</Button>
</Box>
) : (
<Box>
<Grid size={{ xs: 12, sm: 6 }}>
<Autocomplete
options={siteOptions}
filterOptions={(x) => x}
getOptionLabel={(opt) =>
opt.fullAddress
? `${opt.siteCode} — ${opt.fullAddress}`
: opt.siteCode
}
loading={siteLoading}
inputValue={siteInputValue}
freeSolo
options={customers}
getOptionLabel={(opt) => (typeof opt === 'string' ? opt : opt.name)}
loading={customerLoading}
onInputChange={(_, value, reason) => {
if (reason === 'input') {
setSiteInputValue(value);
if (siteDebounceRef.current) clearTimeout(siteDebounceRef.current);
siteDebounceRef.current = setTimeout(() => searchSites(value), 300);
handleChange('customerName', value);
}
if (searchDebounceRef.current) clearTimeout(searchDebounceRef.current);
searchDebounceRef.current = setTimeout(() => searchCustomers(value), 300);
}}
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)'
}
onChange={handleCustomerSelect}
renderInput={(params) => (
<TextField
{...params}
label="Site"
label="Customer"
required
placeholder="Search by site code..."
slotProps={{
input: {
...params.InputProps,
endAdornment: (
<>
{siteLoading && <CircularProgress size={20} />}
{customerLoading && <CircularProgress size={20} />}
{params.InputProps.endAdornment}
</>
),
@ -339,23 +197,35 @@ export default function ProposalFormPage() {
/>
)}
/>
<Button
variant="text"
size="small"
sx={{ mt: 0.5 }}
onClick={() => setManualEntry(true)}
>
Enter address manually
</Button>
</Box>
)}
</CardContent>
</Card>
</Grid>
<Grid size={{ xs: 12 }}>
{addresses.length > 1 ? (
<TextField
label="Customer Address"
required
fullWidth
select
value={form.customerAddress}
onChange={(e) => handleChange('customerAddress', e.target.value)}
>
{addresses.map((addr) => (
<MenuItem key={addr} value={addr}>
{addr}
</MenuItem>
))}
</TextField>
) : (
<TextField
label="Customer Address"
required
fullWidth
value={form.customerAddress}
onChange={(e) => handleChange('customerAddress', e.target.value)}
/>
)}
</Grid>
<Card sx={{ mb: 2 }}>
<CardContent>
<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"
@ -373,20 +243,22 @@ export default function ProposalFormPage() {
</TextField>
</Grid>
{form.serviceCategory === 'Other' ? (
<Grid size={{ xs: 12, sm: 6 }}>
<TextField
label="Specify Category"
required
fullWidth
value={customCategory}
onChange={(e) => setCustomCategory(e.target.value)}
placeholder="e.g. Fire Protection"
/>
</Grid>
) : (
<Grid size={{ sm: 6 }} sx={{ display: { xs: 'none', sm: 'block' } }} />
)}
<Grid size={{ xs: 12, sm: 6 }}>
<TextField
label="Priority"
required
fullWidth
select
value={form.priority}
onChange={(e) => handleChange('priority', e.target.value)}
>
{PRIORITIES.map((p) => (
<MenuItem key={p} value={p}>
{p}
</MenuItem>
))}
</TextField>
</Grid>
<Grid size={{ xs: 12 }}>
<TextField
@ -418,34 +290,49 @@ export default function ProposalFormPage() {
<input
type="file"
hidden
accept=".pdf"
onChange={(e) => setVendorFile(e.target.files?.[0] || null)}
accept=".pdf,application/pdf"
onChange={(e) => {
// Fix: WEB-M6 — validate file size and MIME type before accepting
const file = e.target.files?.[0] || null;
if (!file) {
setVendorFile(null);
return;
}
if (!file.name.toLowerCase().endsWith('.pdf') || !ALLOWED_MIME_TYPES.includes(file.type)) {
toast.error('Only PDF files are accepted.');
e.target.value = '';
return;
}
if (file.size > MAX_FILE_SIZE_BYTES) {
toast.error('File size exceeds the 25 MB limit. Please choose a smaller file.');
e.target.value = '';
return;
}
setVendorFile(file);
}}
/>
</Button>
</Grid>
</Grid>
</CardContent>
</Card>
<Box sx={{ display: 'flex', gap: 2, justifyContent: 'flex-end', alignItems: 'center' }}>
{!isValid && missingFields.length > 0 && (
<Typography variant="caption" color="text.secondary">
Complete required fields to submit: {missingFields.join(', ')}
</Typography>
)}
<Button variant="text" onClick={() => navigate(-1)}>
Cancel
</Button>
<Button
type="submit"
variant="contained"
startIcon={<SendIcon />}
disabled={!isValid || createMutation.isPending}
>
{createMutation.isPending ? 'Submitting...' : 'Submit Proposal'}
</Button>
</Box>
</Box>
<Grid size={{ xs: 12 }}>
<Box sx={{ display: 'flex', gap: 2, justifyContent: 'flex-end' }}>
<Button variant="text" onClick={() => navigate(-1)}>
Cancel
</Button>
<Button
type="submit"
variant="contained"
startIcon={<SendIcon />}
disabled={!isValid || createMutation.isPending}
>
{createMutation.isPending ? 'Submitting...' : 'Submit Proposal'}
</Button>
</Box>
</Grid>
</Grid>
</Box>
</CardContent>
</Card>
</Box>
);
}