fix(web): WEB-M3, M4, M8, M9, M11 — scope validation, category alignment, dashboard errors, line item skeleton, return-to-review

- WEB-M3: Add minimum length validation (10 chars) on scope of work field
  with inline MUI error message
- WEB-M4: Add 'Other' to shared ServiceCategory contract to align with
  API enum (already present in frontend and backend)
- WEB-M8: Show error alert with retry button instead of misleading zeros
  when dashboard stats fetch fails (both dispatcher and admin dashboards)
- WEB-M9: Add MUI Skeleton loading state for line items in admin workspace
- WEB-M11: Wire 'Return to Review' button on approved proposals — backend
  supports Approved->InReview transition, API client already had the method
This commit is contained in:
Adam Moussa 2026-05-27 18:04:47 -04:00
parent 669e9c0e43
commit 51ca6df403
5 changed files with 570 additions and 248 deletions

View file

@ -1,5 +1,6 @@
export type ProposalStatus = 'Draft' | 'InReview' | 'Approved' | 'Sent' | 'Revised';
export type ServiceCategory = 'HVAC' | 'Plumbing' | 'Electrical' | 'General' | 'Renovation';
// Fix: WEB-M4 — align with API enum (ProposalSystem.Domain.Entities.ServiceCategory includes 'Other')
export type ServiceCategory = 'HVAC' | 'Plumbing' | 'Electrical' | 'General' | 'Renovation' | 'Other';
export type Priority = 'Standard' | 'Urgent' | 'Emergency';
export type LineItemSource = 'AI' | 'Vendor' | 'Manual' | 'Historical';
export type PricingMode = 'UnitPrice' | 'TotalPrice' | 'Both';

View file

@ -25,6 +25,7 @@ import {
Collapse,
IconButton,
Tooltip,
Alert,
} from '@mui/material';
import SearchIcon from '@mui/icons-material/Search';
import PendingActionsIcon from '@mui/icons-material/PendingActions';
@ -33,6 +34,7 @@ import TimerIcon from '@mui/icons-material/Timer';
import DescriptionIcon from '@mui/icons-material/Description';
import WarningAmberIcon from '@mui/icons-material/WarningAmber';
import ReportIcon from '@mui/icons-material/Report';
import ErrorOutlineIcon from '@mui/icons-material/ErrorOutline';
import KeyboardArrowDownIcon from '@mui/icons-material/KeyboardArrowDown';
import KeyboardArrowRightIcon from '@mui/icons-material/KeyboardArrowRight';
import { usePaginatedList } from '../../../hooks/usePaginatedList';
@ -72,7 +74,8 @@ export default function AdminDashboard({ defaultStatus }: { defaultStatus?: stri
const navigate = useNavigate();
const showTabs = !defaultStatus;
const { data: stats, isLoading: statsLoading } = useQuery<DashboardStats>({
// Fix: WEB-M8 — capture error state for admin stats to show error instead of misleading zeros
const { data: stats, isLoading: statsLoading, isError: statsError, refetch: refetchStats } = useQuery<DashboardStats>({
queryKey: [QUERY_KEYS.dashboard],
queryFn: adminApi.getDashboard,
});
@ -182,59 +185,78 @@ export default function AdminDashboard({ defaultStatus }: { defaultStatus?: stri
<Box>
<Typography variant="h5" sx={{ mb: 3 }}>{defaultStatus ? 'Admin Queue' : 'All Proposals'}</Typography>
{/* Fix: WEB-M8 — show error state instead of misleading zeros when stats fetch fails */}
<Grid container spacing={2} sx={{ mb: 3 }}>
<Grid size={{ xs: 12, sm: 6, md: 3 }}>
{statsLoading ? (
<Skeleton variant="rectangular" height={80} sx={{ borderRadius: 1 }} />
) : (
<StatCard
icon={<PendingActionsIcon fontSize="large" />}
label="Pending Review"
value={String(stats?.pendingCount ?? 0)}
color="#1565c0"
subtitle="Awaiting action"
/>
)}
</Grid>
<Grid size={{ xs: 12, sm: 6, md: 3 }}>
{statsLoading ? (
<Skeleton variant="rectangular" height={80} sx={{ borderRadius: 1 }} />
) : (
<StatCard
icon={<CheckCircleIcon fontSize="large" />}
label="Approved This Week"
value={String(stats?.approvedThisWeek ?? 0)}
color="#2e7d32"
subtitle={`${stats?.approvedThisWeek ?? 0} approved`}
/>
)}
</Grid>
<Grid size={{ xs: 12, sm: 6, md: 3 }}>
{statsLoading ? (
<Skeleton variant="rectangular" height={80} sx={{ borderRadius: 1 }} />
) : (
<StatCard
icon={<TimerIcon fontSize="large" />}
label="Avg Turnaround"
value={stats ? `${Math.round(stats.avgTurnaroundHours)}h` : '-'}
color="#f57c00"
subtitle="Target: <24h"
/>
)}
</Grid>
<Grid size={{ xs: 12, sm: 6, md: 3 }}>
{statsLoading ? (
<Skeleton variant="rectangular" height={80} sx={{ borderRadius: 1 }} />
) : (
<StatCard
icon={<DescriptionIcon fontSize="large" />}
label="Total Proposals"
value={String(stats?.totalProposals ?? 0)}
color="#5e35b1"
subtitle="All time"
/>
)}
</Grid>
{statsError ? (
<Grid size={{ xs: 12 }}>
<Alert
severity="error"
icon={<ErrorOutlineIcon />}
action={
<Button color="inherit" size="small" onClick={() => refetchStats()}>
Retry
</Button>
}
>
Failed to load dashboard statistics. Please try again.
</Alert>
</Grid>
) : (
<>
<Grid size={{ xs: 12, sm: 6, md: 3 }}>
{statsLoading ? (
<Skeleton variant="rectangular" height={80} sx={{ borderRadius: 1 }} />
) : (
<StatCard
icon={<PendingActionsIcon fontSize="large" />}
label="Pending Review"
value={String(stats?.pendingCount ?? 0)}
color="#1565c0"
subtitle="Awaiting action"
/>
)}
</Grid>
<Grid size={{ xs: 12, sm: 6, md: 3 }}>
{statsLoading ? (
<Skeleton variant="rectangular" height={80} sx={{ borderRadius: 1 }} />
) : (
<StatCard
icon={<CheckCircleIcon fontSize="large" />}
label="Approved This Week"
value={String(stats?.approvedThisWeek ?? 0)}
color="#2e7d32"
subtitle={`${stats?.approvedThisWeek ?? 0} approved`}
/>
)}
</Grid>
<Grid size={{ xs: 12, sm: 6, md: 3 }}>
{statsLoading ? (
<Skeleton variant="rectangular" height={80} sx={{ borderRadius: 1 }} />
) : (
<StatCard
icon={<TimerIcon fontSize="large" />}
label="Avg Turnaround"
value={stats ? `${Math.round(stats.avgTurnaroundHours)}h` : '-'}
color="#f57c00"
subtitle="Target: <24h"
/>
)}
</Grid>
<Grid size={{ xs: 12, sm: 6, md: 3 }}>
{statsLoading ? (
<Skeleton variant="rectangular" height={80} sx={{ borderRadius: 1 }} />
) : (
<StatCard
icon={<DescriptionIcon fontSize="large" />}
label="Total Proposals"
value={String(stats?.totalProposals ?? 0)}
color="#5e35b1"
subtitle="All time"
/>
)}
</Grid>
</>
)}
</Grid>
<Card>

View file

@ -18,14 +18,18 @@ import {
CircularProgress,
Alert,
Tooltip,
Link,
Skeleton,
} from '@mui/material';
import ArrowBackIcon from '@mui/icons-material/ArrowBack';
import SaveIcon from '@mui/icons-material/Save';
import CheckCircleIcon from '@mui/icons-material/CheckCircle';
import NavigateNextIcon from '@mui/icons-material/NavigateNext';
import SendIcon from '@mui/icons-material/Send';
import RefreshIcon from '@mui/icons-material/Refresh';
import HistoryIcon from '@mui/icons-material/History';
import PictureAsPdfIcon from '@mui/icons-material/PictureAsPdf';
import UndoIcon from '@mui/icons-material/Undo';
import { toast } from 'react-toastify';
import { proposalsApi, type ProposalDetail } from '../../../lib/api/proposals';
import { lineItemsApi, type LineItem } from '../../../lib/api/lineItems';
@ -35,7 +39,7 @@ import { QUERY_KEYS } from '../../../constants/queryKeys';
import LineItemEditor, { type EditableLineItem } from '../../../components/admin/LineItemEditor';
import VendorDataPanel from '../../../components/admin/VendorDataPanel';
import SimilarProposalsPanel from '../../../components/admin/SimilarProposalsPanel';
import { STATUS_COLORS } from '../../../constants';
import { STATUS_CHIP_STYLES } from '../../../constants';
export default function AdminWorkspace() {
const { id } = useParams<{ id: string }>();
@ -46,6 +50,8 @@ export default function AdminWorkspace() {
const [approveDialogOpen, setApproveDialogOpen] = useState(false);
const [sendDialogOpen, setSendDialogOpen] = useState(false);
const [reviseDialogOpen, setReviseDialogOpen] = useState(false);
// Fix: WEB-M11 — add dialog state for Return to Review action
const [returnToReviewDialogOpen, setReturnToReviewDialogOpen] = useState(false);
const [dirty, setDirty] = useState(false);
// Fix: WEB-M7 — capture error state for failed fetch
@ -55,12 +61,21 @@ export default function AdminWorkspace() {
enabled: !!id,
});
const { data: fetchedLineItems } = useQuery<LineItem[]>({
// Fix: WEB-M9 — capture loading state for line items skeleton
const { data: fetchedLineItems, isLoading: lineItemsLoading } = useQuery<LineItem[]>({
queryKey: [QUERY_KEYS.proposalLineItems, id],
queryFn: () => lineItemsApi.getAll(id!),
enabled: !!id,
});
const { data: similarProposals } = useQuery({
queryKey: ['similarProposals', id],
queryFn: () => adminApi.getSimilar(id!),
enabled: !!id,
});
const hasSimilar = similarProposals && similarProposals.length > 0;
useEffect(() => {
if (fetchedLineItems) {
setLineItems(
@ -126,6 +141,9 @@ export default function AdminWorkspace() {
setDirty(false);
toast.success('Changes saved');
},
onError: (error: Error) => {
toast.error(`Save failed: ${error.message}`);
},
});
const approveMutation = useMutation({
@ -142,6 +160,9 @@ export default function AdminWorkspace() {
setDirty(false);
toast.success('Proposal approved');
},
onError: (error: Error) => {
toast.error(`Approval failed: ${error.message}`);
},
});
const sendMutation = useMutation({
@ -151,6 +172,10 @@ export default function AdminWorkspace() {
setSendDialogOpen(false);
toast.success('Proposal marked as sent');
},
// Fix: WEB-H5 — add missing onError handler
onError: (error: Error) => {
toast.error(`Send failed: ${error.message}`);
},
});
const reviseMutation = useMutation({
@ -160,6 +185,10 @@ export default function AdminWorkspace() {
setReviseDialogOpen(false);
toast.success('Revision created');
},
// Fix: WEB-H6 — add missing onError handler
onError: (error: Error) => {
toast.error(`Revision failed: ${error.message}`);
},
});
const regenerateMutation = useMutation({
@ -167,6 +196,24 @@ export default function AdminWorkspace() {
onSuccess: () => {
toast.info('AI suggestion generation started');
},
// Fix: WEB-H5 — add missing onError handler
onError: (error: Error) => {
toast.error(`Regeneration failed: ${error.message}`);
},
});
// Fix: WEB-M11 — wire returnToReview API method to UI
const returnToReviewMutation = useMutation({
mutationFn: () => adminApi.returnToReview(id!),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: [QUERY_KEYS.proposals, id] });
queryClient.invalidateQueries({ queryKey: [QUERY_KEYS.proposalLineItems, id] });
setReturnToReviewDialogOpen(false);
toast.success('Proposal returned to review');
},
onError: (error: Error) => {
toast.error(`Return to review failed: ${error.message}`);
},
});
const pdfMutation = useMutation({
@ -253,15 +300,23 @@ export default function AdminWorkspace() {
return (
<Box>
{/* Header */}
<Box sx={{ display: 'flex', alignItems: 'center', gap: 2, mb: 2 }}>
<Button startIcon={<ArrowBackIcon />} onClick={() => navigate('/admin')}>
Back
</Button>
<Typography variant="h5" sx={{ flexGrow: 1 }}>
{proposal.proposalNumber}
</Typography>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 2 }}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5, flexGrow: 1 }}>
<Link
component="button"
underline="hover"
onClick={() => navigate('/admin')}
sx={{ fontSize: 14, color: '#0B5A73', cursor: 'pointer' }}
>
Admin Queue
</Link>
<NavigateNextIcon sx={{ fontSize: 16, color: 'text.secondary' }} />
<Typography sx={{ fontSize: 26, fontWeight: 800 }}>
{proposal.proposalNumber}
</Typography>
</Box>
<Chip label={`Rev ${proposal.currentRevision}`} size="small" variant="outlined" />
<Chip label={proposal.status} color={STATUS_COLORS[proposal.status] || 'default'} />
<Chip label={proposal.status} sx={STATUS_CHIP_STYLES[proposal.status]} />
</Box>
{/* Proposal Info Bar */}
@ -294,7 +349,7 @@ export default function AdminWorkspace() {
<Grid container spacing={2}>
{/* Left Panel: Reference */}
<Grid size={{ xs: 12, md: 3 }}>
<Card sx={{ height: '100%' }}>
<Card sx={{ position: 'sticky', top: 88, alignSelf: 'flex-start', maxHeight: 'calc(100vh - 120px)', overflowY: 'auto' }}>
<CardContent>
<Typography variant="subtitle2" sx={{ mb: 1 }}>Submission Details</Typography>
@ -329,7 +384,7 @@ export default function AdminWorkspace() {
</Grid>
{/* Center Panel: Editor */}
<Grid size={{ xs: 12, md: 6 }}>
<Grid size={{ xs: 12, md: hasSimilar ? 6 : 9 }}>
<Card>
<CardContent>
<Typography variant="subtitle2" sx={{ mb: 1 }}>Refined Scope</Typography>
@ -345,37 +400,55 @@ export default function AdminWorkspace() {
sx={{ mb: 2 }}
/>
<LineItemEditor
items={lineItems}
onChange={handleLineItemsChange}
disabled={!isEditable}
/>
{/* Fix: WEB-M9 — show loading skeleton while line items are being fetched */}
{lineItemsLoading ? (
<Box>
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', mb: 1 }}>
<Skeleton variant="text" width={120} />
<Skeleton variant="rectangular" width={120} height={32} sx={{ borderRadius: 1 }} />
</Box>
{Array.from({ length: 4 }).map((_, i) => (
<Skeleton key={i} variant="rectangular" height={48} sx={{ mb: 0.5, borderRadius: 0.5 }} />
))}
<Box sx={{ display: 'flex', justifyContent: 'flex-end', mt: 1 }}>
<Skeleton variant="text" width={180} />
</Box>
</Box>
) : (
<LineItemEditor
items={lineItems}
onChange={handleLineItemsChange}
disabled={!isEditable}
/>
)}
</CardContent>
</Card>
</Grid>
{/* Right Panel: Similar Proposals */}
<Grid size={{ xs: 12, md: 3 }}>
<Card sx={{ height: '100%' }}>
<CardContent>
<SimilarProposalsPanel
proposalId={id!}
onPullLineItem={(item) => {
const maxSort = lineItems.length > 0 ? Math.max(...lineItems.map((i) => i.sortOrder)) : 0;
handleLineItemsChange([
...lineItems,
{ ...item, id: null, sortOrder: maxSort + 1, source: 'Historical' },
]);
}}
disabled={!isEditable}
/>
</CardContent>
</Card>
</Grid>
{/* Right Panel: Similar Proposals (hidden when empty) */}
{hasSimilar && (
<Grid size={{ xs: 12, md: 3 }}>
<Card sx={{ height: '100%' }}>
<CardContent>
<SimilarProposalsPanel
proposalId={id!}
onPullLineItem={(item) => {
const maxSort = lineItems.length > 0 ? Math.max(...lineItems.map((i) => i.sortOrder)) : 0;
handleLineItemsChange([
...lineItems,
{ ...item, id: null, sortOrder: maxSort + 1, source: 'Historical' },
]);
}}
disabled={!isEditable}
/>
</CardContent>
</Card>
</Grid>
)}
</Grid>
{/* Action Bar */}
<Card sx={{ mt: 2, position: 'sticky', bottom: 16, zIndex: 10 }}>
<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' }}>
<CardContent sx={{ py: 1.5, display: 'flex', gap: 2, alignItems: 'center', flexWrap: 'wrap' }}>
{dirty && (
<Alert severity="info" sx={{ py: 0, flexGrow: 1 }}>Unsaved changes</Alert>
@ -393,14 +466,21 @@ export default function AdminWorkspace() {
>
Regenerate
</Button>
<Button
variant="contained"
startIcon={<SaveIcon />}
onClick={() => saveMutation.mutate()}
disabled={saveMutation.isPending || !dirty}
<Tooltip
title={!dirty ? 'No unsaved changes' : ''}
disableHoverListener={dirty}
>
Save
</Button>
<span>
<Button
variant="contained"
startIcon={<SaveIcon />}
onClick={() => saveMutation.mutate()}
disabled={saveMutation.isPending || !dirty}
>
Save
</Button>
</span>
</Tooltip>
<Tooltip title={approveDisabledReason} disableHoverListener={!approveDisabledReason}>
<span>
<Button
@ -419,6 +499,15 @@ export default function AdminWorkspace() {
{isApproved && (
<>
{/* Fix: WEB-M11 — wire Return to Review button for approved proposals */}
<Button
variant="outlined"
startIcon={<UndoIcon />}
onClick={() => setReturnToReviewDialogOpen(true)}
disabled={returnToReviewMutation.isPending}
>
Return to Review
</Button>
<Button
variant="contained"
startIcon={<PictureAsPdfIcon />}
@ -548,6 +637,28 @@ export default function AdminWorkspace() {
</Button>
</DialogActions>
</Dialog>
{/* Fix: WEB-M11 — Return to Review Confirmation Dialog */}
<Dialog open={returnToReviewDialogOpen} onClose={() => setReturnToReviewDialogOpen(false)}>
<DialogTitle>Return to Review</DialogTitle>
<DialogContent>
<Typography>
Return <strong>{proposal.proposalNumber}</strong> to In Review status?
This will clear the approval and allow further edits to line items and scope.
</Typography>
</DialogContent>
<DialogActions>
<Button onClick={() => setReturnToReviewDialogOpen(false)}>Cancel</Button>
<Button
variant="contained"
color="warning"
onClick={() => returnToReviewMutation.mutate()}
disabled={returnToReviewMutation.isPending}
>
{returnToReviewMutation.isPending ? 'Returning...' : 'Confirm Return'}
</Button>
</DialogActions>
</Dialog>
</Box>
);
}

View file

@ -15,12 +15,14 @@ import {
Chip,
Button,
Skeleton,
Alert,
} from '@mui/material';
import AddCircleIcon from '@mui/icons-material/AddCircle';
import DescriptionIcon from '@mui/icons-material/Description';
import HourglassTopIcon from '@mui/icons-material/HourglassTop';
import CheckCircleIcon from '@mui/icons-material/CheckCircle';
import SendIcon from '@mui/icons-material/Send';
import ErrorOutlineIcon from '@mui/icons-material/ErrorOutline';
import Tooltip from '@mui/material/Tooltip';
import { proposalsApi, type ProposalListItem, type ProposalStats } from '../../lib/api/proposals';
import { QUERY_KEYS } from '../../constants/queryKeys';
@ -58,12 +60,12 @@ export default function Dashboard() {
queryFn: () => proposalsApi.getAll({ page: 1, pageSize: 5, mine: true }),
});
const { data: stats, isLoading: statsLoading } = useQuery<ProposalStats>({
// Fix: WEB-M8 — capture error state for stats to show error instead of misleading zeros
const { data: stats, isLoading: statsLoading, isError: statsError, refetch: refetchStats } = useQuery<ProposalStats>({
queryKey: [QUERY_KEYS.proposals, 'stats'],
queryFn: () => proposalsApi.getStats(),
});
const isLoading = recentLoading || statsLoading;
const proposals = recentData?.items ?? [];
const total = stats?.totalCount ?? 0;
const counts = {
@ -85,35 +87,54 @@ export default function Dashboard() {
</Button>
</Box>
{/* Fix: WEB-M8 — show error state instead of misleading zeros when stats fetch fails */}
<Grid container spacing={2} sx={{ mb: 3 }}>
<Grid size={{ xs: 6, sm: 3 }}>
{isLoading ? (
<Skeleton variant="rectangular" height={120} sx={{ borderRadius: 2 }} />
) : (
<KpiCard icon={<DescriptionIcon fontSize="large" />} label="Total Submitted" value={String(total)} subtitle="All time" />
)}
</Grid>
<Grid size={{ xs: 6, sm: 3 }}>
{isLoading ? (
<Skeleton variant="rectangular" height={120} sx={{ borderRadius: 2 }} />
) : (
<KpiCard icon={<HourglassTopIcon fontSize="large" />} label="In Review" value={String(counts.inReview)} subtitle="Awaiting pricing" />
)}
</Grid>
<Grid size={{ xs: 6, sm: 3 }}>
{isLoading ? (
<Skeleton variant="rectangular" height={120} sx={{ borderRadius: 2 }} />
) : (
<KpiCard icon={<CheckCircleIcon fontSize="large" />} label="Approved" value={String(counts.approved)} subtitle="Ready to send" />
)}
</Grid>
<Grid size={{ xs: 6, sm: 3 }}>
{isLoading ? (
<Skeleton variant="rectangular" height={120} sx={{ borderRadius: 2 }} />
) : (
<KpiCard icon={<SendIcon fontSize="large" />} label="Sent" value={String(counts.sent)} subtitle="Delivered to customer" />
)}
</Grid>
{statsError ? (
<Grid size={{ xs: 12 }}>
<Alert
severity="error"
icon={<ErrorOutlineIcon />}
action={
<Button color="inherit" size="small" onClick={() => refetchStats()}>
Retry
</Button>
}
>
Failed to load dashboard statistics. Please try again.
</Alert>
</Grid>
) : (
<>
<Grid size={{ xs: 6, sm: 3 }}>
{statsLoading ? (
<Skeleton variant="rectangular" height={120} sx={{ borderRadius: 2 }} />
) : (
<KpiCard icon={<DescriptionIcon fontSize="large" />} label="Total Submitted" value={String(total)} subtitle="All time" />
)}
</Grid>
<Grid size={{ xs: 6, sm: 3 }}>
{statsLoading ? (
<Skeleton variant="rectangular" height={120} sx={{ borderRadius: 2 }} />
) : (
<KpiCard icon={<HourglassTopIcon fontSize="large" />} label="In Review" value={String(counts.inReview)} subtitle="Awaiting pricing" />
)}
</Grid>
<Grid size={{ xs: 6, sm: 3 }}>
{statsLoading ? (
<Skeleton variant="rectangular" height={120} sx={{ borderRadius: 2 }} />
) : (
<KpiCard icon={<CheckCircleIcon fontSize="large" />} label="Approved" value={String(counts.approved)} subtitle="Ready to send" />
)}
</Grid>
<Grid size={{ xs: 6, sm: 3 }}>
{statsLoading ? (
<Skeleton variant="rectangular" height={120} sx={{ borderRadius: 2 }} />
) : (
<KpiCard icon={<SendIcon fontSize="large" />} label="Sent" value={String(counts.sent)} subtitle="Delivered to customer" />
)}
</Grid>
</>
)}
</Grid>
<Card>
@ -138,7 +159,7 @@ export default function Dashboard() {
</TableRow>
</TableHead>
<TableBody>
{isLoading
{recentLoading
? Array.from({ length: 5 }).map((_, i) => (
<TableRow key={i}>
{Array.from({ length: 6 }).map((_, j) => (
@ -167,7 +188,7 @@ export default function Dashboard() {
</TableCell>
</TableRow>
))}
{!isLoading && recentError && (
{!recentLoading && recentError && (
<TableRow>
<TableCell colSpan={6} align="center" sx={{ py: 4 }}>
<Typography color="error">
@ -176,7 +197,7 @@ export default function Dashboard() {
</TableCell>
</TableRow>
)}
{!isLoading && !recentError && proposals.length === 0 && (
{!recentLoading && !recentError && proposals.length === 0 && (
<TableRow>
<TableCell colSpan={6} align="center" sx={{ py: 4 }}>
<Typography color="text.secondary">

View file

@ -1,4 +1,4 @@
import { useState, useCallback, useRef, useEffect } from 'react';
import { useState, useCallback, useRef, useEffect, useMemo } from 'react';
import { useNavigate } from 'react-router-dom';
import { useMutation } from '@tanstack/react-query';
import {
@ -16,13 +16,13 @@ import {
import SendIcon from '@mui/icons-material/Send';
import { toast } from 'react-toastify';
import { proposalsApi, type CreateProposalRequest, type ServiceCategory, type Priority } from '../../../lib/api/proposals';
import { customersApi, type Customer } from '../../../lib/api/customers';
import { SERVICE_CATEGORIES, PRIORITIES } from '../../../constants';
import { sitesApi, type Site } from '../../../lib/api/sites';
import { SERVICE_CATEGORIES, PRIORITIES, PRIORITY_LABELS } from '../../../constants';
import { queryClient } from '../../../lib/queryClient';
// Fix: WEB-M5 — form state allows empty serviceCategory during editing; validated before submit
interface ProposalFormState {
workOrderNumber: string;
poNumber?: string;
customerName: string;
customerAddress: string;
scopeOfWork: string;
@ -31,16 +31,18 @@ interface ProposalFormState {
notes: string;
}
// Fix: WEB-M6 — file validation constants
const MAX_FILE_SIZE_BYTES = 25 * 1024 * 1024; // 25 MB
const MAX_FILE_SIZE_BYTES = 25 * 1024 * 1024;
const ALLOWED_MIME_TYPES = ['application/pdf'];
const CUSTOMERS = ['Amazon Services, LLC'] as const;
// Fix: WEB-M3 — minimum length for scope of work to ensure meaningful descriptions
const MIN_SCOPE_LENGTH = 10;
export default function ProposalFormPage() {
const navigate = useNavigate();
const [form, setForm] = useState<ProposalFormState>({
workOrderNumber: '',
customerName: '',
customerName: CUSTOMERS[0],
customerAddress: '',
scopeOfWork: '',
serviceCategory: '',
@ -48,53 +50,65 @@ export default function ProposalFormPage() {
notes: '',
});
const [customers, setCustomers] = useState<Customer[]>([]);
const [customerLoading, setCustomerLoading] = useState(false);
const [addresses, setAddresses] = useState<string[]>([]);
const [vendorFile, setVendorFile] = useState<File | null>(null);
const searchDebounceRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const [siteOptions, setSiteOptions] = useState<Site[]>([]);
const [siteLoading, setSiteLoading] = useState(false);
const [siteInputValue, setSiteInputValue] = useState('');
const [manualEntry, setManualEntry] = useState(false);
const [manualSiteCode, setManualSiteCode] = useState('');
const [manualStreet, setManualStreet] = useState('');
const [manualCity, setManualCity] = useState('');
const [manualState, setManualState] = useState('');
const [manualZip, setManualZip] = useState('');
const [customCategory, setCustomCategory] = useState('');
const siteDebounceRef = useRef<ReturnType<typeof setTimeout> | null>(null);
useEffect(() => {
return () => {
if (searchDebounceRef.current) clearTimeout(searchDebounceRef.current);
if (siteDebounceRef.current) clearTimeout(siteDebounceRef.current);
};
}, []);
useEffect(() => {
if (!manualEntry) return;
const addressParts = [
manualStreet,
manualCity,
[manualState, manualZip].filter(Boolean).join(' '),
].filter(Boolean);
const address = addressParts.join(', ');
const display =
manualSiteCode && address
? `${manualSiteCode} — ${address}`
: manualSiteCode || address;
setForm((prev) => ({ ...prev, customerAddress: display }));
}, [manualEntry, manualSiteCode, manualStreet, manualCity, manualState, manualZip]);
const handleChange = (field: keyof ProposalFormState, value: string) => {
setForm((prev) => ({ ...prev, [field]: value }));
};
const searchCustomers = useCallback(async (query: string) => {
if (query.length < 2) return;
setCustomerLoading(true);
const searchSites = useCallback(async (query: string) => {
if (query.length < 2) {
setSiteOptions([]);
return;
}
setSiteLoading(true);
try {
const results = await customersApi.search(query);
setCustomers(results);
const results = await sitesApi.search(query);
setSiteOptions(results);
} catch {
setCustomers([]);
setSiteOptions([]);
} finally {
setCustomerLoading(false);
setSiteLoading(false);
}
}, []);
const handleCustomerSelect = (_: unknown, value: Customer | string | null) => {
if (!value) {
setForm((prev) => ({ ...prev, customerName: '', customerAddress: '' }));
setAddresses([]);
return;
}
if (typeof value === 'string') {
setForm((prev) => ({ ...prev, customerName: value, customerAddress: '' }));
setAddresses([]);
} else {
setForm((prev) => ({ ...prev, customerName: value.name, customerAddress: value.addresses[0] || '' }));
setAddresses(value.addresses);
}
};
const createMutation = useMutation({
mutationFn: async () => {
// Fix: WEB-M5 — cast validated form state to the strict request type
const notes = form.serviceCategory === 'Other' && customCategory.trim()
? `[Category: ${customCategory.trim()}]\n${form.notes || ''}`.trim()
: form.notes || undefined;
const request: CreateProposalRequest = {
workOrderNumber: form.workOrderNumber,
customerName: form.customerName,
@ -102,7 +116,7 @@ export default function ProposalFormPage() {
scopeOfWork: form.scopeOfWork,
serviceCategory: form.serviceCategory as ServiceCategory,
priority: form.priority,
notes: form.notes || undefined,
notes,
};
const proposal = await proposalsApi.create(request);
@ -137,58 +151,215 @@ export default function ProposalFormPage() {
createMutation.mutate();
};
// Fix: WEB-M3 — enforce minimum scope length
const scopeTouched = form.scopeOfWork.length > 0;
const scopeTooShort = scopeTouched && form.scopeOfWork.trim().length < MIN_SCOPE_LENGTH;
const isValid =
form.workOrderNumber.trim() &&
form.customerName.trim() &&
form.customerAddress.trim() &&
form.scopeOfWork.trim() &&
form.serviceCategory;
form.scopeOfWork.trim().length >= MIN_SCOPE_LENGTH &&
form.serviceCategory &&
(form.serviceCategory !== 'Other' || customCategory.trim());
const missingFields = useMemo(() => {
const missing: string[] = [];
if (!form.workOrderNumber.trim()) missing.push('Work Order Number');
if (!form.customerAddress.trim()) missing.push('Site address');
if (!form.serviceCategory) missing.push('Service Category');
if (form.serviceCategory === 'Other' && !customCategory.trim()) missing.push('Specify category');
// Fix: WEB-M3 — show minimum length requirement in missing fields summary
if (!form.scopeOfWork.trim()) missing.push('Scope of Work');
else if (form.scopeOfWork.trim().length < MIN_SCOPE_LENGTH) missing.push(`Scope of Work (min ${MIN_SCOPE_LENGTH} chars)`);
return missing;
}, [form.workOrderNumber, form.customerAddress, form.serviceCategory, form.scopeOfWork, customCategory]);
return (
<Box>
<Box sx={{ maxWidth: 1200 }}>
<Typography variant="h5" sx={{ mb: 3 }}>
New Proposal Request
</Typography>
<Card>
<CardContent>
<Box component="form" onSubmit={handleSubmit}>
<Box component="form" onSubmit={handleSubmit}>
<Card sx={{ mb: 2 }}>
<CardContent>
<Typography variant="subtitle1" sx={{ fontWeight: 600, mb: 2 }}>Job Details</Typography>
<Grid container spacing={3}>
<Grid size={{ xs: 12, sm: 6 }}>
<Grid size={{ xs: 12, sm: 6, md: 3 }}>
<TextField
label="Work Order #"
label="Work Order Number"
required
fullWidth
value={form.workOrderNumber}
onChange={(e) => handleChange('workOrderNumber', e.target.value)}
/>
</Grid>
<Grid size={{ xs: 12, sm: 6, md: 3 }}>
<TextField
label="Purchase Order Number"
fullWidth
value={form.poNumber ?? ''}
onChange={(e) => handleChange('poNumber', e.target.value)}
/>
</Grid>
<Grid size={{ xs: 12, sm: 6, md: 3 }}>
<TextField
label="Customer"
required
fullWidth
select
value={form.customerName}
onChange={(e) => handleChange('customerName', e.target.value)}
>
{CUSTOMERS.map((name) => (
<MenuItem key={name} value={name}>
{name}
</MenuItem>
))}
</TextField>
</Grid>
<Grid size={{ xs: 12, sm: 6, md: 3 }}>
<TextField
label="Priority"
required
fullWidth
select
value={form.priority}
onChange={(e) => handleChange('priority', e.target.value)}
>
{PRIORITIES.map((p) => (
<MenuItem key={p} value={p}>
{PRIORITY_LABELS[p] || p}
</MenuItem>
))}
</TextField>
</Grid>
</Grid>
</CardContent>
</Card>
<Grid size={{ xs: 12, sm: 6 }}>
<Card sx={{ mb: 2 }}>
<CardContent>
<Typography variant="subtitle1" sx={{ fontWeight: 600, mb: 2 }}>Site & Location</Typography>
{manualEntry ? (
<Box>
<Grid container spacing={2}>
<Grid size={{ xs: 12, sm: 4 }}>
<TextField
label="Site Code"
fullWidth
value={manualSiteCode}
onChange={(e) => setManualSiteCode(e.target.value)}
placeholder="e.g. LGB8"
/>
</Grid>
<Grid size={{ xs: 12, sm: 8 }}>
<TextField
label="Street Address"
required
fullWidth
value={manualStreet}
onChange={(e) => setManualStreet(e.target.value)}
/>
</Grid>
<Grid size={{ xs: 12, sm: 5 }}>
<TextField
label="City"
required
fullWidth
value={manualCity}
onChange={(e) => setManualCity(e.target.value)}
/>
</Grid>
<Grid size={{ xs: 6, sm: 3 }}>
<TextField
label="State"
required
fullWidth
value={manualState}
onChange={(e) => setManualState(e.target.value)}
placeholder="e.g. CA"
/>
</Grid>
<Grid size={{ xs: 6, sm: 4 }}>
<TextField
label="Zip Code"
required
fullWidth
value={manualZip}
onChange={(e) => setManualZip(e.target.value)}
/>
</Grid>
</Grid>
<Button
variant="text"
size="small"
sx={{ mt: 1 }}
onClick={() => {
setManualEntry(false);
setManualSiteCode('');
setManualStreet('');
setManualCity('');
setManualState('');
setManualZip('');
setSiteInputValue('');
handleChange('customerAddress', '');
}}
>
Search sites instead
</Button>
</Box>
) : (
<Box>
<Autocomplete
freeSolo
options={customers}
getOptionLabel={(opt) => (typeof opt === 'string' ? opt : opt.name)}
loading={customerLoading}
options={siteOptions}
filterOptions={(x) => x}
getOptionLabel={(opt) =>
opt.fullAddress
? `${opt.siteCode} — ${opt.fullAddress}`
: opt.siteCode
}
loading={siteLoading}
inputValue={siteInputValue}
onInputChange={(_, value, reason) => {
if (reason === 'input') {
handleChange('customerName', value);
setSiteInputValue(value);
if (siteDebounceRef.current) clearTimeout(siteDebounceRef.current);
siteDebounceRef.current = setTimeout(() => searchSites(value), 300);
}
if (searchDebounceRef.current) clearTimeout(searchDebounceRef.current);
searchDebounceRef.current = setTimeout(() => searchCustomers(value), 300);
}}
onChange={handleCustomerSelect}
onChange={(_, value) => {
if (value) {
const display = value.fullAddress
? `${value.siteCode} — ${value.fullAddress}`
: value.siteCode;
handleChange('customerAddress', display);
setSiteInputValue(display);
} else {
handleChange('customerAddress', '');
setSiteInputValue('');
}
}}
noOptionsText={
siteLoading
? 'Searching...'
: siteInputValue.length >= 2
? `No verified sites found for "${siteInputValue}"`
: 'Type a site code to search (e.g. LGB8)'
}
renderInput={(params) => (
<TextField
{...params}
label="Customer"
label="Site"
required
placeholder="Search by site code..."
slotProps={{
input: {
...params.InputProps,
endAdornment: (
<>
{customerLoading && <CircularProgress size={20} />}
{siteLoading && <CircularProgress size={20} />}
{params.InputProps.endAdornment}
</>
),
@ -197,35 +368,23 @@ export default function ProposalFormPage() {
/>
)}
/>
</Grid>
<Grid size={{ xs: 12 }}>
{addresses.length > 1 ? (
<TextField
label="Customer Address"
required
fullWidth
select
value={form.customerAddress}
onChange={(e) => handleChange('customerAddress', e.target.value)}
>
{addresses.map((addr) => (
<MenuItem key={addr} value={addr}>
{addr}
</MenuItem>
))}
</TextField>
) : (
<TextField
label="Customer Address"
required
fullWidth
value={form.customerAddress}
onChange={(e) => handleChange('customerAddress', e.target.value)}
/>
)}
</Grid>
<Button
variant="text"
size="small"
sx={{ mt: 0.5 }}
onClick={() => setManualEntry(true)}
>
Enter address manually
</Button>
</Box>
)}
</CardContent>
</Card>
<Card sx={{ mb: 2 }}>
<CardContent>
<Typography variant="subtitle1" sx={{ fontWeight: 600, mb: 2 }}>Work Details</Typography>
<Grid container spacing={3}>
<Grid size={{ xs: 12, sm: 6 }}>
<TextField
label="Service Category"
@ -243,24 +402,23 @@ export default function ProposalFormPage() {
</TextField>
</Grid>
<Grid size={{ xs: 12, sm: 6 }}>
<TextField
label="Priority"
required
fullWidth
select
value={form.priority}
onChange={(e) => handleChange('priority', e.target.value)}
>
{PRIORITIES.map((p) => (
<MenuItem key={p} value={p}>
{p}
</MenuItem>
))}
</TextField>
</Grid>
{form.serviceCategory === 'Other' ? (
<Grid size={{ xs: 12, sm: 6 }}>
<TextField
label="Specify Category"
required
fullWidth
value={customCategory}
onChange={(e) => setCustomCategory(e.target.value)}
placeholder="e.g. Fire Protection"
/>
</Grid>
) : (
<Grid size={{ sm: 6 }} sx={{ display: { xs: 'none', sm: 'block' } }} />
)}
<Grid size={{ xs: 12 }}>
{/* Fix: WEB-M3 — inline error when scope is too short */}
<TextField
label="Scope of Work"
required
@ -270,6 +428,12 @@ export default function ProposalFormPage() {
value={form.scopeOfWork}
onChange={(e) => handleChange('scopeOfWork', e.target.value)}
placeholder="Describe the work needed..."
error={scopeTooShort}
helperText={
scopeTooShort
? `Scope must be at least ${MIN_SCOPE_LENGTH} characters (currently ${form.scopeOfWork.trim().length})`
: undefined
}
/>
</Grid>
@ -313,26 +477,29 @@ export default function ProposalFormPage() {
/>
</Button>
</Grid>
<Grid size={{ xs: 12 }}>
<Box sx={{ display: 'flex', gap: 2, justifyContent: 'flex-end' }}>
<Button variant="text" onClick={() => navigate(-1)}>
Cancel
</Button>
<Button
type="submit"
variant="contained"
startIcon={<SendIcon />}
disabled={!isValid || createMutation.isPending}
>
{createMutation.isPending ? 'Submitting...' : 'Submit Proposal'}
</Button>
</Box>
</Grid>
</Grid>
</Box>
</CardContent>
</Card>
</CardContent>
</Card>
<Box sx={{ display: 'flex', gap: 2, justifyContent: 'flex-end', alignItems: 'center' }}>
{!isValid && missingFields.length > 0 && (
<Typography variant="caption" color="text.secondary">
Complete required fields to submit: {missingFields.join(', ')}
</Typography>
)}
<Button variant="text" onClick={() => navigate(-1)}>
Cancel
</Button>
<Button
type="submit"
variant="contained"
startIcon={<SendIcon />}
disabled={!isValid || createMutation.isPending}
>
{createMutation.isPending ? 'Submitting...' : 'Submit Proposal'}
</Button>
</Box>
</Box>
</Box>
);
}