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

View file

@ -1,13 +1,17 @@
import apiClient from './client'; 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 { export interface CreateProposalRequest {
workOrderNumber: string; workOrderNumber: string;
poNumber?: string; poNumber?: string;
customerName: string; customerName: string;
customerAddress: string; customerAddress: string;
scopeOfWork: string; scopeOfWork: string;
serviceCategory: string; serviceCategory: ServiceCategory;
priority: string; priority: Priority;
notes?: string; notes?: string;
} }

View file

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

View file

@ -18,12 +18,10 @@ import {
CircularProgress, CircularProgress,
Alert, Alert,
Tooltip, Tooltip,
Link,
} from '@mui/material'; } from '@mui/material';
import ArrowBackIcon from '@mui/icons-material/ArrowBack'; import ArrowBackIcon from '@mui/icons-material/ArrowBack';
import SaveIcon from '@mui/icons-material/Save'; import SaveIcon from '@mui/icons-material/Save';
import CheckCircleIcon from '@mui/icons-material/CheckCircle'; import CheckCircleIcon from '@mui/icons-material/CheckCircle';
import NavigateNextIcon from '@mui/icons-material/NavigateNext';
import SendIcon from '@mui/icons-material/Send'; import SendIcon from '@mui/icons-material/Send';
import RefreshIcon from '@mui/icons-material/Refresh'; import RefreshIcon from '@mui/icons-material/Refresh';
import HistoryIcon from '@mui/icons-material/History'; 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 LineItemEditor, { type EditableLineItem } from '../../../components/admin/LineItemEditor';
import VendorDataPanel from '../../../components/admin/VendorDataPanel'; import VendorDataPanel from '../../../components/admin/VendorDataPanel';
import SimilarProposalsPanel from '../../../components/admin/SimilarProposalsPanel'; import SimilarProposalsPanel from '../../../components/admin/SimilarProposalsPanel';
import { STATUS_CHIP_STYLES } from '../../../constants'; import { STATUS_COLORS } from '../../../constants';
export default function AdminWorkspace() { export default function AdminWorkspace() {
const { id } = useParams<{ id: string }>(); const { id } = useParams<{ id: string }>();
@ -50,7 +48,8 @@ export default function AdminWorkspace() {
const [reviseDialogOpen, setReviseDialogOpen] = useState(false); const [reviseDialogOpen, setReviseDialogOpen] = useState(false);
const [dirty, setDirty] = 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], queryKey: [QUERY_KEYS.proposals, id],
queryFn: () => proposalsApi.getById(id!), queryFn: () => proposalsApi.getById(id!),
enabled: !!id, enabled: !!id,
@ -62,14 +61,6 @@ export default function AdminWorkspace() {
enabled: !!id, enabled: !!id,
}); });
const { data: similarProposals } = useQuery({
queryKey: ['similarProposals', id],
queryFn: () => adminApi.getSimilar(id!),
enabled: !!id,
});
const hasSimilar = similarProposals && similarProposals.length > 0;
useEffect(() => { useEffect(() => {
if (fetchedLineItems) { if (fetchedLineItems) {
setLineItems( setLineItems(
@ -135,9 +126,6 @@ export default function AdminWorkspace() {
setDirty(false); setDirty(false);
toast.success('Changes saved'); toast.success('Changes saved');
}, },
onError: (error: Error) => {
toast.error(`Save failed: ${error.message}`);
},
}); });
const approveMutation = useMutation({ const approveMutation = useMutation({
@ -154,9 +142,6 @@ export default function AdminWorkspace() {
setDirty(false); setDirty(false);
toast.success('Proposal approved'); toast.success('Proposal approved');
}, },
onError: (error: Error) => {
toast.error(`Approval failed: ${error.message}`);
},
}); });
const sendMutation = useMutation({ const sendMutation = useMutation({
@ -166,10 +151,6 @@ export default function AdminWorkspace() {
setSendDialogOpen(false); setSendDialogOpen(false);
toast.success('Proposal marked as sent'); 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({ const reviseMutation = useMutation({
@ -179,10 +160,6 @@ export default function AdminWorkspace() {
setReviseDialogOpen(false); setReviseDialogOpen(false);
toast.success('Revision created'); toast.success('Revision created');
}, },
// Fix: WEB-H6 — add missing onError handler
onError: (error: Error) => {
toast.error(`Revision failed: ${error.message}`);
},
}); });
const regenerateMutation = useMutation({ const regenerateMutation = useMutation({
@ -190,10 +167,6 @@ export default function AdminWorkspace() {
onSuccess: () => { onSuccess: () => {
toast.info('AI suggestion generation started'); 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({ 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) { if (!proposal) {
return ( return (
<Box> <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 isEditable = proposal.status === 'InReview' || proposal.status === 'Revised';
const canApprove = isEditable && lineItems.length > 0;
const isApproved = proposal.status === 'Approved'; const isApproved = proposal.status === 'Approved';
const canSend = isApproved;
const isSent = proposal.status === 'Sent'; const isSent = proposal.status === 'Sent';
const canRevise = isSent;
const bidTotal = lineItems.reduce((sum, li) => sum + li.totalPrice, 0); 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 ( return (
<Box> <Box>
{/* Header */} {/* Header */}
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 2 }}> <Box sx={{ display: 'flex', alignItems: 'center', gap: 2, mb: 2 }}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5, flexGrow: 1 }}> <Button startIcon={<ArrowBackIcon />} onClick={() => navigate('/admin')}>
<Link Back
component="button" </Button>
underline="hover" <Typography variant="h5" sx={{ flexGrow: 1 }}>
onClick={() => navigate('/admin')} {proposal.proposalNumber}
sx={{ fontSize: 14, color: '#0B5A73', cursor: 'pointer' }} </Typography>
>
Admin Queue
</Link>
<NavigateNextIcon sx={{ fontSize: 16, color: 'text.secondary' }} />
<Typography sx={{ fontSize: 26, fontWeight: 800 }}>
{proposal.proposalNumber}
</Typography>
</Box>
<Chip label={`Rev ${proposal.currentRevision}`} size="small" variant="outlined" /> <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> </Box>
{/* Proposal Info Bar */} {/* Proposal Info Bar */}
@ -292,7 +294,7 @@ export default function AdminWorkspace() {
<Grid container spacing={2}> <Grid container spacing={2}>
{/* Left Panel: Reference */} {/* Left Panel: Reference */}
<Grid size={{ xs: 12, md: 3 }}> <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> <CardContent>
<Typography variant="subtitle2" sx={{ mb: 1 }}>Submission Details</Typography> <Typography variant="subtitle2" sx={{ mb: 1 }}>Submission Details</Typography>
@ -327,7 +329,7 @@ export default function AdminWorkspace() {
</Grid> </Grid>
{/* Center Panel: Editor */} {/* Center Panel: Editor */}
<Grid size={{ xs: 12, md: hasSimilar ? 6 : 9 }}> <Grid size={{ xs: 12, md: 6 }}>
<Card> <Card>
<CardContent> <CardContent>
<Typography variant="subtitle2" sx={{ mb: 1 }}>Refined Scope</Typography> <Typography variant="subtitle2" sx={{ mb: 1 }}>Refined Scope</Typography>
@ -352,36 +354,35 @@ export default function AdminWorkspace() {
</Card> </Card>
</Grid> </Grid>
{/* Right Panel: Similar Proposals (hidden when empty) */} {/* Right Panel: Similar Proposals */}
{hasSimilar && ( <Grid size={{ xs: 12, md: 3 }}>
<Grid size={{ xs: 12, md: 3 }}> <Card sx={{ height: '100%' }}>
<Card sx={{ height: '100%' }}> <CardContent>
<CardContent> <SimilarProposalsPanel
<SimilarProposalsPanel proposalId={id!}
proposalId={id!} onPullLineItem={(item) => {
onPullLineItem={(item) => { const maxSort = lineItems.length > 0 ? Math.max(...lineItems.map((i) => i.sortOrder)) : 0;
const maxSort = lineItems.length > 0 ? Math.max(...lineItems.map((i) => i.sortOrder)) : 0; handleLineItemsChange([
handleLineItemsChange([ ...lineItems,
...lineItems, { ...item, id: null, sortOrder: maxSort + 1, source: 'Historical' },
{ ...item, id: null, sortOrder: maxSort + 1, source: 'Historical' }, ]);
]); }}
}} disabled={!isEditable}
disabled={!isEditable} />
/> </CardContent>
</CardContent> </Card>
</Card> </Grid>
</Grid>
)}
</Grid> </Grid>
{/* Action Bar */} {/* 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' }}> <CardContent sx={{ py: 1.5, display: 'flex', gap: 2, alignItems: 'center', flexWrap: 'wrap' }}>
{dirty && ( {dirty && (
<Alert severity="info" sx={{ py: 0, flexGrow: 1 }}>Unsaved changes</Alert> <Alert severity="info" sx={{ py: 0, flexGrow: 1 }}>Unsaved changes</Alert>
)} )}
{!dirty && <Box sx={{ flexGrow: 1 }} />} {!dirty && <Box sx={{ flexGrow: 1 }} />}
{/* Fix: WEB-M10 — state transition guards with tooltip explanations */}
{isEditable && ( {isEditable && (
<> <>
<Button <Button
@ -392,56 +393,24 @@ export default function AdminWorkspace() {
> >
Regenerate Regenerate
</Button> </Button>
<Tooltip <Button
title={!dirty ? 'No unsaved changes' : ''} variant="contained"
disableHoverListener={dirty} startIcon={<SaveIcon />}
> onClick={() => saveMutation.mutate()}
<span> disabled={saveMutation.isPending || !dirty}
<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}
> >
Save
</Button>
<Tooltip title={approveDisabledReason} disableHoverListener={!approveDisabledReason}>
<span> <span>
<Button <Button
variant="contained" variant="contained"
color="success" color="success"
startIcon={<CheckCircleIcon />} startIcon={<CheckCircleIcon />}
onClick={() => setApproveDialogOpen(true)} onClick={() => setApproveDialogOpen(true)}
disabled={lineItems.length === 0 || bidTotal <= 0} disabled={!canApprove || lineItems.length === 0 || bidTotal <= 0}
> >
Approve &amp; Send Approve
</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
</Button> </Button>
</span> </span>
</Tooltip> </Tooltip>
@ -458,27 +427,35 @@ export default function AdminWorkspace() {
> >
{pdfMutation.isPending ? 'Generating...' : 'Generate PDF'} {pdfMutation.isPending ? 'Generating...' : 'Generate PDF'}
</Button> </Button>
<Button <Tooltip title={sendDisabledReason} disableHoverListener={!sendDisabledReason}>
variant="contained" <span>
color="success" <Button
startIcon={<SendIcon />} variant="contained"
onClick={() => setSendDialogOpen(true)} color="success"
disabled={sendMutation.isPending} startIcon={<SendIcon />}
> onClick={() => setSendDialogOpen(true)}
Mark as Sent disabled={!canSend || sendMutation.isPending}
</Button> >
Mark as Sent
</Button>
</span>
</Tooltip>
</> </>
)} )}
{isSent && ( {isSent && (
<Button <Tooltip title={reviseDisabledReason} disableHoverListener={!reviseDisabledReason}>
variant="outlined" <span>
startIcon={<HistoryIcon />} <Button
onClick={() => setReviseDialogOpen(true)} variant="outlined"
disabled={reviseMutation.isPending} startIcon={<HistoryIcon />}
> onClick={() => setReviseDialogOpen(true)}
Create Revision disabled={!canRevise || reviseMutation.isPending}
</Button> >
Create Revision
</Button>
</span>
</Tooltip>
)} )}
</CardContent> </CardContent>
</Card> </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 { useNavigate } from 'react-router-dom';
import { useMutation } from '@tanstack/react-query'; import { useMutation } from '@tanstack/react-query';
import { import {
@ -15,19 +15,32 @@ 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 } from '../../../lib/api/proposals'; import { proposalsApi, type CreateProposalRequest, type ServiceCategory, type Priority } from '../../../lib/api/proposals';
import { sitesApi, type Site } from '../../../lib/api/sites'; import { customersApi, type Customer } from '../../../lib/api/customers';
import { SERVICE_CATEGORIES, PRIORITIES, PRIORITY_LABELS } from '../../../constants'; import { SERVICE_CATEGORIES, PRIORITIES } from '../../../constants';
import { queryClient } from '../../../lib/queryClient'; 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() { export default function ProposalFormPage() {
const navigate = useNavigate(); const navigate = useNavigate();
const [form, setForm] = useState<CreateProposalRequest>({ const [form, setForm] = useState<ProposalFormState>({
workOrderNumber: '', workOrderNumber: '',
customerName: CUSTOMERS[0], customerName: '',
customerAddress: '', customerAddress: '',
scopeOfWork: '', scopeOfWork: '',
serviceCategory: '', serviceCategory: '',
@ -35,67 +48,63 @@ export default function ProposalFormPage() {
notes: '', notes: '',
}); });
const [customers, setCustomers] = useState<Customer[]>([]);
const [customerLoading, setCustomerLoading] = useState(false);
const [addresses, setAddresses] = useState<string[]>([]);
const [vendorFile, setVendorFile] = useState<File | null>(null); const [vendorFile, setVendorFile] = useState<File | null>(null);
const [siteOptions, setSiteOptions] = useState<Site[]>([]); const searchDebounceRef = useRef<ReturnType<typeof setTimeout> | null>(null);
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);
useEffect(() => { useEffect(() => {
return () => { return () => {
if (siteDebounceRef.current) clearTimeout(siteDebounceRef.current); if (searchDebounceRef.current) clearTimeout(searchDebounceRef.current);
}; };
}, []); }, []);
useEffect(() => { const handleChange = (field: keyof ProposalFormState, value: string) => {
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) => {
setForm((prev) => ({ ...prev, [field]: value })); setForm((prev) => ({ ...prev, [field]: value }));
}; };
const searchSites = useCallback(async (query: string) => { const searchCustomers = useCallback(async (query: string) => {
if (query.length < 2) { if (query.length < 2) return;
setSiteOptions([]); setCustomerLoading(true);
return;
}
setSiteLoading(true);
try { try {
const results = await sitesApi.search(query); const results = await customersApi.search(query);
setSiteOptions(results); setCustomers(results);
} catch { } catch {
setSiteOptions([]); setCustomers([]);
} finally { } 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({ const createMutation = useMutation({
mutationFn: async () => { mutationFn: async () => {
const submitData = { ...form }; // Fix: WEB-M5 — cast validated form state to the strict request type
if (form.serviceCategory === 'Other' && customCategory.trim()) { const request: CreateProposalRequest = {
submitData.notes = `[Category: ${customCategory.trim()}]\n${form.notes || ''}`.trim(); workOrderNumber: form.workOrderNumber,
} customerName: form.customerName,
const proposal = await proposalsApi.create(submitData); 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) { if (vendorFile) {
try { try {
@ -133,204 +142,53 @@ export default function ProposalFormPage() {
form.customerName.trim() && form.customerName.trim() &&
form.customerAddress.trim() && form.customerAddress.trim() &&
form.scopeOfWork.trim() && form.scopeOfWork.trim() &&
form.serviceCategory && 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]);
return ( return (
<Box sx={{ maxWidth: 1200 }}> <Box>
<Typography variant="h5" sx={{ mb: 3 }}> <Typography variant="h5" sx={{ mb: 3 }}>
New Proposal Request New Proposal Request
</Typography> </Typography>
<Box component="form" onSubmit={handleSubmit}> <Card>
<Card sx={{ mb: 2 }}> <CardContent>
<CardContent> <Box component="form" onSubmit={handleSubmit}>
<Typography variant="subtitle1" sx={{ fontWeight: 600, mb: 2 }}>Job Details</Typography>
<Grid container spacing={3}> <Grid container spacing={3}>
<Grid size={{ xs: 12, sm: 6, md: 3 }}> <Grid size={{ xs: 12, sm: 6 }}>
<TextField <TextField
label="Work Order Number" label="Work Order #"
required required
fullWidth fullWidth
value={form.workOrderNumber} value={form.workOrderNumber}
onChange={(e) => handleChange('workOrderNumber', e.target.value)} onChange={(e) => handleChange('workOrderNumber', e.target.value)}
/> />
</Grid> </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 }}> <Grid size={{ xs: 12, sm: 6 }}>
<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>
<Autocomplete <Autocomplete
options={siteOptions} freeSolo
filterOptions={(x) => x} options={customers}
getOptionLabel={(opt) => getOptionLabel={(opt) => (typeof opt === 'string' ? opt : opt.name)}
opt.fullAddress loading={customerLoading}
? `${opt.siteCode} — ${opt.fullAddress}`
: opt.siteCode
}
loading={siteLoading}
inputValue={siteInputValue}
onInputChange={(_, value, reason) => { onInputChange={(_, value, reason) => {
if (reason === 'input') { if (reason === 'input') {
setSiteInputValue(value); handleChange('customerName', 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={(_, value) => { onChange={handleCustomerSelect}
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) => ( renderInput={(params) => (
<TextField <TextField
{...params} {...params}
label="Site" label="Customer"
required required
placeholder="Search by site code..."
slotProps={{ slotProps={{
input: { input: {
...params.InputProps, ...params.InputProps,
endAdornment: ( endAdornment: (
<> <>
{siteLoading && <CircularProgress size={20} />} {customerLoading && <CircularProgress size={20} />}
{params.InputProps.endAdornment} {params.InputProps.endAdornment}
</> </>
), ),
@ -339,23 +197,35 @@ export default function ProposalFormPage() {
/> />
)} )}
/> />
<Button </Grid>
variant="text"
size="small" <Grid size={{ xs: 12 }}>
sx={{ mt: 0.5 }} {addresses.length > 1 ? (
onClick={() => setManualEntry(true)} <TextField
> label="Customer Address"
Enter address manually required
</Button> fullWidth
</Box> select
)} value={form.customerAddress}
</CardContent> onChange={(e) => handleChange('customerAddress', e.target.value)}
</Card> >
{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 }}> <Grid size={{ xs: 12, sm: 6 }}>
<TextField <TextField
label="Service Category" label="Service Category"
@ -373,20 +243,22 @@ export default function ProposalFormPage() {
</TextField> </TextField>
</Grid> </Grid>
{form.serviceCategory === 'Other' ? ( <Grid size={{ xs: 12, sm: 6 }}>
<Grid size={{ xs: 12, sm: 6 }}> <TextField
<TextField label="Priority"
label="Specify Category" required
required fullWidth
fullWidth select
value={customCategory} value={form.priority}
onChange={(e) => setCustomCategory(e.target.value)} onChange={(e) => handleChange('priority', e.target.value)}
placeholder="e.g. Fire Protection" >
/> {PRIORITIES.map((p) => (
</Grid> <MenuItem key={p} value={p}>
) : ( {p}
<Grid size={{ sm: 6 }} sx={{ display: { xs: 'none', sm: 'block' } }} /> </MenuItem>
)} ))}
</TextField>
</Grid>
<Grid size={{ xs: 12 }}> <Grid size={{ xs: 12 }}>
<TextField <TextField
@ -418,34 +290,49 @@ export default function ProposalFormPage() {
<input <input
type="file" type="file"
hidden hidden
accept=".pdf" accept=".pdf,application/pdf"
onChange={(e) => setVendorFile(e.target.files?.[0] || null)} 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> </Button>
</Grid> </Grid>
</Grid>
</CardContent>
</Card>
<Box sx={{ display: 'flex', gap: 2, justifyContent: 'flex-end', alignItems: 'center' }}> <Grid size={{ xs: 12 }}>
{!isValid && missingFields.length > 0 && ( <Box sx={{ display: 'flex', gap: 2, justifyContent: 'flex-end' }}>
<Typography variant="caption" color="text.secondary"> <Button variant="text" onClick={() => navigate(-1)}>
Complete required fields to submit: {missingFields.join(', ')} Cancel
</Typography> </Button>
)} <Button
<Button variant="text" onClick={() => navigate(-1)}> type="submit"
Cancel variant="contained"
</Button> startIcon={<SendIcon />}
<Button disabled={!isValid || createMutation.isPending}
type="submit" >
variant="contained" {createMutation.isPending ? 'Submitting...' : 'Submit Proposal'}
startIcon={<SendIcon />} </Button>
disabled={!isValid || createMutation.isPending} </Box>
> </Grid>
{createMutation.isPending ? 'Submitting...' : 'Submit Proposal'} </Grid>
</Button> </Box>
</Box> </CardContent>
</Box> </Card>
</Box> </Box>
); );
} }