mirror of
https://github.com/Sea-Haven-Industries/proposal-system.git
synced 2026-09-30 06:33:13 +00:00
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:
parent
42fe0823b0
commit
57122ee702
5 changed files with 279 additions and 416 deletions
|
|
@ -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.'));
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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 & 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 & 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>
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue