mirror of
https://github.com/Sea-Haven-Industries/proposal-system.git
synced 2026-09-30 10:03:14 +00:00
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:
parent
669e9c0e43
commit
51ca6df403
5 changed files with 570 additions and 248 deletions
|
|
@ -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';
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue