From 57122ee70232e3267617d97195bc96186742fee1 Mon Sep 17 00:00:00 2001 From: Adam Moussa <166072409+amoussa1229@users.noreply.github.com> Date: Wed, 27 May 2026 17:46:11 -0400 Subject: [PATCH] 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. --- web/src/lib/api/client.ts | 5 +- web/src/lib/api/proposals.ts | 8 +- web/src/main.tsx | 16 +- .../pages/admin/workspace/AdminWorkspace.tsx | 233 +++++----- .../pages/proposals/form/ProposalFormPage.tsx | 433 +++++++----------- 5 files changed, 279 insertions(+), 416 deletions(-) diff --git a/web/src/lib/api/client.ts b/web/src/lib/api/client.ts index c96e7ee..ee19f11 100644 --- a/web/src/lib/api/client.ts +++ b/web/src/lib/api/client.ts @@ -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.')); diff --git a/web/src/lib/api/proposals.ts b/web/src/lib/api/proposals.ts index c495a43..cfebec5 100644 --- a/web/src/lib/api/proposals.ts +++ b/web/src/lib/api/proposals.ts @@ -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; } diff --git a/web/src/main.tsx b/web/src/main.tsx index bdd5cfc..f56fab5 100644 --- a/web/src/main.tsx +++ b/web/src/main.tsx @@ -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( - - - - + {/* Fix: WEB-M13 — ToastContainer inside BrowserRouter so toasts render in the correct React tree */} + + + + diff --git a/web/src/pages/admin/workspace/AdminWorkspace.tsx b/web/src/pages/admin/workspace/AdminWorkspace.tsx index 7508df2..4d23eab 100644 --- a/web/src/pages/admin/workspace/AdminWorkspace.tsx +++ b/web/src/pages/admin/workspace/AdminWorkspace.tsx @@ -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({ + // Fix: WEB-M7 — capture error state for failed fetch + const { data: proposal, isLoading, isError: isProposalError, error: proposalError, refetch: refetchProposal } = useQuery({ 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 ( + + refetchProposal()}> + Retry + + } + > + Failed to load proposal{proposalError instanceof Error ? `: ${proposalError.message}` : '.'} + + + + ); + } + if (!proposal) { return ( @@ -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 ( {/* Header */} - - - navigate('/admin')} - sx={{ fontSize: 14, color: '#0B5A73', cursor: 'pointer' }} - > - Admin Queue - - - - {proposal.proposalNumber} - - + + + + {proposal.proposalNumber} + - + {/* Proposal Info Bar */} @@ -292,7 +294,7 @@ export default function AdminWorkspace() { {/* Left Panel: Reference */} - + Submission Details @@ -327,7 +329,7 @@ export default function AdminWorkspace() { {/* Center Panel: Editor */} - + Refined Scope @@ -352,36 +354,35 @@ export default function AdminWorkspace() { - {/* 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} - /> - - - - )} + {/* 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} + /> + + + {/* Action Bar */} - + {dirty && ( Unsaved changes )} {!dirty && } + {/* Fix: WEB-M10 — state transition guards with tooltip explanations */} {isEditable && ( <> - - - - - - 0 && bidTotal > 0} + + - - - 0 && bidTotal > 0} - > - - @@ -458,27 +427,35 @@ export default function AdminWorkspace() { > {pdfMutation.isPending ? 'Generating...' : 'Generate PDF'} - + + + + + )} {isSent && ( - + + + + + )} diff --git a/web/src/pages/proposals/form/ProposalFormPage.tsx b/web/src/pages/proposals/form/ProposalFormPage.tsx index 1523371..602ee21 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, 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({ + const [form, setForm] = useState({ workOrderNumber: '', - customerName: CUSTOMERS[0], + customerName: '', customerAddress: '', scopeOfWork: '', serviceCategory: '', @@ -35,67 +48,63 @@ export default function ProposalFormPage() { notes: '', }); + const [customers, setCustomers] = useState([]); + const [customerLoading, setCustomerLoading] = useState(false); + const [addresses, setAddresses] = useState([]); const [vendorFile, setVendorFile] = useState(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); + const searchDebounceRef = useRef | 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 ( - + 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)} - /> - - - - - ) : ( - + 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) => ( - {siteLoading && } + {customerLoading && } {params.InputProps.endAdornment} ), @@ -339,23 +197,35 @@ export default function ProposalFormPage() { /> )} /> - - - )} - - + + + + {addresses.length > 1 ? ( + handleChange('customerAddress', e.target.value)} + > + {addresses.map((addr) => ( + + {addr} + + ))} + + ) : ( + handleChange('customerAddress', e.target.value)} + /> + )} + - - - Work Details - - {form.serviceCategory === 'Other' ? ( - - setCustomCategory(e.target.value)} - placeholder="e.g. Fire Protection" - /> - - ) : ( - - )} + + handleChange('priority', e.target.value)} + > + {PRIORITIES.map((p) => ( + + {p} + + ))} + + - - - - - {!isValid && missingFields.length > 0 && ( - - Complete required fields to submit: {missingFields.join(', ')} - - )} - - - - + + + + + + + + + + ); }