Fix frontend navigation bugs, differentiate Dashboard from Proposals list

- Fix double nav selection by adding isNavActive() with ALL_NAV_PATHS set
- Fix /admin/users routing to placeholder instead of redirect to /
- Fix ProposalDetailPage Back button navigating to / instead of /proposals
- Differentiate Dashboard (KPI cards + recent 5) from ProposalListPage (full paginated table)
- Dashboard now uses dedicated /proposals/stats endpoint for accurate counts
- Fix adminApi.getPdf dead code (axios rejects before status check)
- Wire up PDF generation button in AdminWorkspace
- Adjust layout: 220px drawer, 10px content padding, 64px toolbar height
This commit is contained in:
Adam Moussa 2026-05-16 22:11:08 -04:00
parent 3e9623b6fc
commit 9859d89895
7 changed files with 305 additions and 83 deletions

View file

@ -1,6 +1,6 @@
import { Routes, Route, Navigate } from 'react-router-dom'; import { Routes, Route, Navigate } from 'react-router-dom';
import { useSelector } from 'react-redux'; import { useSelector } from 'react-redux';
import { Box, Toolbar } from '@mui/material'; import { Box, Toolbar, Typography } from '@mui/material';
import { selectSidebarOpen } from './app/slices/uiSlice'; import { selectSidebarOpen } from './app/slices/uiSlice';
import type { RootState } from './app/store'; import type { RootState } from './app/store';
import ProtectedRoute from './components/ProtectedRoute'; import ProtectedRoute from './components/ProtectedRoute';
@ -15,7 +15,7 @@ import ProposalListPage from './pages/proposals/list/ProposalListPage';
import AdminDashboard from './pages/admin/dashboard/AdminDashboard'; import AdminDashboard from './pages/admin/dashboard/AdminDashboard';
import AdminWorkspace from './pages/admin/workspace/AdminWorkspace'; import AdminWorkspace from './pages/admin/workspace/AdminWorkspace';
const DRAWER_WIDTH = 240; const DRAWER_WIDTH = 220;
export default function App() { export default function App() {
const sidebarOpen = useSelector((state: RootState) => selectSidebarOpen(state)); const sidebarOpen = useSelector((state: RootState) => selectSidebarOpen(state));
@ -36,12 +36,13 @@ export default function App() {
component="main" component="main"
sx={{ sx={{
flexGrow: 1, flexGrow: 1,
p: 3, p: '10px',
ml: sidebarOpen ? `${DRAWER_WIDTH}px` : 0, ml: sidebarOpen ? `${DRAWER_WIDTH}px` : 0,
transition: 'margin-left 225ms cubic-bezier(0.4, 0, 0.6, 1)', transition: 'margin-left 250ms ease',
minHeight: '100vh',
}} }}
> >
<Toolbar /> <Toolbar sx={{ minHeight: 64 }} />
<Routes> <Routes>
<Route path="/" element={<Dashboard />} /> <Route path="/" element={<Dashboard />} />
<Route path="/proposals" element={<ProposalListPage />} /> <Route path="/proposals" element={<ProposalListPage />} />
@ -52,6 +53,7 @@ export default function App() {
<Route path="/admin" element={<AdminDashboard />} /> <Route path="/admin" element={<AdminDashboard />} />
<Route path="/admin/proposals" element={<AdminDashboard />} /> <Route path="/admin/proposals" element={<AdminDashboard />} />
<Route path="/admin/proposals/:id" element={<AdminWorkspace />} /> <Route path="/admin/proposals/:id" element={<AdminWorkspace />} />
<Route path="/admin/users" element={<Typography variant="h5" sx={{ p: 2 }}>User Management — Coming Soon</Typography>} />
<Route path="*" element={<Navigate to="/" replace />} /> <Route path="*" element={<Navigate to="/" replace />} />
</Routes> </Routes>

View file

@ -64,4 +64,13 @@ export const adminApi = {
const res = await apiClient.get(`/proposals/${id}/similar`); const res = await apiClient.get(`/proposals/${id}/similar`);
return res.data; return res.data;
}, },
getPdf: async (id: string): Promise<{ downloadUrl: string; expiresAt: string } | null> => {
try {
const res = await apiClient.get(`/proposals/${id}/pdf`);
return res.data;
} catch {
return null;
}
},
}; };

View file

@ -97,4 +97,16 @@ export const proposalsApi = {
const res = await apiClient.post(`/proposals/${proposalId}/attachments?fileName=${encodeURIComponent(fileName)}`); const res = await apiClient.post(`/proposals/${proposalId}/attachments?fileName=${encodeURIComponent(fileName)}`);
return res.data; return res.data;
}, },
getStats: async (): Promise<ProposalStats> => {
const res = await apiClient.get('/proposals/stats');
return res.data;
},
}; };
export interface ProposalStats {
totalCount: number;
inReviewCount: number;
approvedCount: number;
sentCount: number;
}

View file

@ -146,6 +146,21 @@ export default function AdminWorkspace() {
}, },
}); });
const pdfMutation = useMutation({
mutationFn: () => adminApi.getPdf(id!),
onSuccess: (data) => {
if (data?.downloadUrl) {
window.open(data.downloadUrl, '_blank');
toast.success('PDF ready — downloading');
} else {
toast.info('PDF generation queued — try again in a few seconds');
}
},
onError: () => {
toast.error('Failed to generate PDF');
},
});
const handleLineItemsChange = (updated: EditableLineItem[]) => { const handleLineItemsChange = (updated: EditableLineItem[]) => {
setLineItems(updated); setLineItems(updated);
setDirty(true); setDirty(true);
@ -342,9 +357,10 @@ export default function AdminWorkspace() {
<Button <Button
variant="contained" variant="contained"
startIcon={<PictureAsPdfIcon />} startIcon={<PictureAsPdfIcon />}
onClick={() => toast.info('PDF generation will be available in Phase 5')} onClick={() => pdfMutation.mutate()}
disabled={pdfMutation.isPending}
> >
Generate PDF {pdfMutation.isPending ? 'Generating...' : 'Generate PDF'}
</Button> </Button>
<Button <Button
variant="contained" variant="contained"

View file

@ -1,26 +1,28 @@
import { useNavigate } from 'react-router-dom'; import { useNavigate } from 'react-router-dom';
import { useQuery } from '@tanstack/react-query';
import { import {
Box, Box,
Typography, Typography,
Card, Card,
CardContent, CardContent,
Grid,
Table, Table,
TableBody, TableBody,
TableCell, TableCell,
TableContainer, TableContainer,
TableHead, TableHead,
TableRow, TableRow,
TablePagination,
Chip, Chip,
TextField,
InputAdornment,
Button, Button,
Skeleton, Skeleton,
} from '@mui/material'; } from '@mui/material';
import SearchIcon from '@mui/icons-material/Search';
import AddCircleIcon from '@mui/icons-material/AddCircle'; import AddCircleIcon from '@mui/icons-material/AddCircle';
import { usePaginatedList } from '../../hooks/usePaginatedList'; import DescriptionIcon from '@mui/icons-material/Description';
import { proposalsApi, type ProposalListItem } from '../../lib/api/proposals'; import HourglassTopIcon from '@mui/icons-material/HourglassTop';
import CheckCircleIcon from '@mui/icons-material/CheckCircle';
import SendIcon from '@mui/icons-material/Send';
import { proposalsApi, type ProposalListItem, type ProposalStats } from '../../lib/api/proposals';
import { QUERY_KEYS } from '../../constants/queryKeys';
const STATUS_COLORS: Record<string, 'default' | 'info' | 'warning' | 'success' | 'error'> = { const STATUS_COLORS: Record<string, 'default' | 'info' | 'warning' | 'success' | 'error'> = {
Draft: 'default', Draft: 'default',
@ -38,25 +40,48 @@ function formatDate(iso: string): string {
return new Date(iso).toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' }); return new Date(iso).toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' });
} }
function KpiCard({ icon, label, value }: { icon: React.ReactNode; label: string; value: string }) {
return (
<Card sx={{ borderRadius: '8px', transition: 'transform 0.2s, box-shadow 0.2s', '&:hover': { transform: 'translateY(-2px)', boxShadow: '0 4px 8px rgba(0,0,0,0.15)' } }}>
<CardContent sx={{ textAlign: 'center', py: 2.5 }}>
<Box sx={{ display: 'flex', justifyContent: 'center', mb: 1, color: '#0c4f6f' }}>{icon}</Box>
<Typography sx={{ fontSize: '12px', color: '#666', textTransform: 'uppercase', letterSpacing: '0.5px', mb: 1 }}>
{label}
</Typography>
<Typography sx={{ fontSize: '32px', fontWeight: 700, color: '#003a5d' }}>
{value}
</Typography>
</CardContent>
</Card>
);
}
export default function Dashboard() { export default function Dashboard() {
const navigate = useNavigate(); const navigate = useNavigate();
const {
rows, const { data: recentData, isLoading: recentLoading } = useQuery<{ items: ProposalListItem[]; totalCount: number }>({
search, queryKey: [QUERY_KEYS.proposals, 'dashboard-recent'],
setSearch, queryFn: () => proposalsApi.getAll({ page: 1, pageSize: 5 }),
page, });
setPage,
pageSize, const { data: stats, isLoading: statsLoading } = useQuery<ProposalStats>({
setPageSize, queryKey: [QUERY_KEYS.proposals, 'stats'],
totalCount, queryFn: () => proposalsApi.getStats(),
loading, });
err,
} = usePaginatedList<ProposalListItem>(proposalsApi.getAll); const isLoading = recentLoading || statsLoading;
const proposals = recentData?.items ?? [];
const total = stats?.totalCount ?? 0;
const counts = {
inReview: stats?.inReviewCount ?? 0,
approved: stats?.approvedCount ?? 0,
sent: stats?.sentCount ?? 0,
};
return ( return (
<Box> <Box>
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', mb: 3 }}> <Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', mb: 2 }}>
<Typography variant="h5">My Proposals</Typography> <Typography variant="h5">Dashboard</Typography>
<Button <Button
variant="contained" variant="contained"
startIcon={<AddCircleIcon />} startIcon={<AddCircleIcon />}
@ -66,30 +91,45 @@ export default function Dashboard() {
</Button> </Button>
</Box> </Box>
<Card> <Grid container spacing={2} sx={{ mb: 3 }}>
<CardContent sx={{ pb: 0 }}> <Grid size={{ xs: 6, sm: 3 }}>
<TextField {isLoading ? (
size="small" <Skeleton variant="rectangular" height={120} sx={{ borderRadius: 2 }} />
placeholder="Search proposals..." ) : (
value={search} <KpiCard icon={<DescriptionIcon fontSize="large" />} label="Total Submitted" value={String(total)} />
onChange={(e) => setSearch(e.target.value)}
sx={{ mb: 2, width: 320 }}
slotProps={{
input: {
startAdornment: (
<InputAdornment position="start">
<SearchIcon fontSize="small" />
</InputAdornment>
),
},
}}
/>
{err && (
<Typography color="error" sx={{ mb: 2 }}>
{err}
</Typography>
)} )}
</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)} />
)}
</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)} />
)}
</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)} />
)}
</Grid>
</Grid>
<Card>
<CardContent sx={{ pb: 1 }}>
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', mb: 2 }}>
<Typography variant="h6">Recent Proposals</Typography>
<Button size="small" onClick={() => navigate('/proposals')}>
View All
</Button>
</Box>
<TableContainer> <TableContainer>
<Table size="small"> <Table size="small">
@ -97,26 +137,22 @@ export default function Dashboard() {
<TableRow> <TableRow>
<TableCell>Proposal #</TableCell> <TableCell>Proposal #</TableCell>
<TableCell>Customer</TableCell> <TableCell>Customer</TableCell>
<TableCell>WO #</TableCell>
<TableCell>Category</TableCell> <TableCell>Category</TableCell>
<TableCell>Priority</TableCell>
<TableCell>Status</TableCell> <TableCell>Status</TableCell>
<TableCell align="right">Bid Amount</TableCell> <TableCell align="right">Bid Amount</TableCell>
<TableCell>Submitted</TableCell> <TableCell>Submitted</TableCell>
</TableRow> </TableRow>
</TableHead> </TableHead>
<TableBody> <TableBody>
{loading {isLoading
? Array.from({ length: 5 }).map((_, i) => ( ? Array.from({ length: 5 }).map((_, i) => (
<TableRow key={i}> <TableRow key={i}>
{Array.from({ length: 8 }).map((_, j) => ( {Array.from({ length: 6 }).map((_, j) => (
<TableCell key={j}> <TableCell key={j}><Skeleton variant="text" /></TableCell>
<Skeleton variant="text" />
</TableCell>
))} ))}
</TableRow> </TableRow>
)) ))
: rows.map((row) => ( : proposals.map((row) => (
<TableRow <TableRow
key={row.id} key={row.id}
hover hover
@ -125,25 +161,19 @@ export default function Dashboard() {
> >
<TableCell sx={{ fontWeight: 600 }}>{row.proposalNumber}</TableCell> <TableCell sx={{ fontWeight: 600 }}>{row.proposalNumber}</TableCell>
<TableCell>{row.customerName}</TableCell> <TableCell>{row.customerName}</TableCell>
<TableCell>{row.workOrderNumber}</TableCell>
<TableCell>{row.serviceCategory}</TableCell> <TableCell>{row.serviceCategory}</TableCell>
<TableCell>{row.priority}</TableCell>
<TableCell> <TableCell>
<Chip <Chip label={row.status} size="small" color={STATUS_COLORS[row.status] || 'default'} />
label={row.status}
size="small"
color={STATUS_COLORS[row.status] || 'default'}
/>
</TableCell> </TableCell>
<TableCell align="right">{formatCurrency(row.totalBidAmount)}</TableCell> <TableCell align="right">{formatCurrency(row.totalBidAmount)}</TableCell>
<TableCell>{formatDate(row.submittedAt)}</TableCell> <TableCell>{formatDate(row.submittedAt)}</TableCell>
</TableRow> </TableRow>
))} ))}
{!loading && rows.length === 0 && ( {!isLoading && proposals.length === 0 && (
<TableRow> <TableRow>
<TableCell colSpan={8} align="center" sx={{ py: 4 }}> <TableCell colSpan={6} align="center" sx={{ py: 4 }}>
<Typography color="text.secondary"> <Typography color="text.secondary">
No proposals found. Create your first one! No proposals yet. Submit your first one!
</Typography> </Typography>
</TableCell> </TableCell>
</TableRow> </TableRow>
@ -151,16 +181,6 @@ export default function Dashboard() {
</TableBody> </TableBody>
</Table> </Table>
</TableContainer> </TableContainer>
<TablePagination
component="div"
count={totalCount}
page={page - 1}
onPageChange={(_, newPage) => setPage(newPage + 1)}
rowsPerPage={pageSize}
onRowsPerPageChange={(e) => setPageSize(parseInt(e.target.value, 10))}
rowsPerPageOptions={[12, 24, 48]}
/>
</CardContent> </CardContent>
</Card> </Card>
</Box> </Box>

View file

@ -77,8 +77,8 @@ export default function ProposalDetailPage() {
<Typography color="error"> <Typography color="error">
{error instanceof Error ? error.message : 'Proposal not found'} {error instanceof Error ? error.message : 'Proposal not found'}
</Typography> </Typography>
<Button startIcon={<ArrowBackIcon />} onClick={() => navigate('/')} sx={{ mt: 2 }}> <Button startIcon={<ArrowBackIcon />} onClick={() => navigate('/proposals')} sx={{ mt: 2 }}>
Back to Dashboard Back to Proposals
</Button> </Button>
</Box> </Box>
); );
@ -89,7 +89,7 @@ export default function ProposalDetailPage() {
return ( return (
<Box> <Box>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 2, mb: 3 }}> <Box sx={{ display: 'flex', alignItems: 'center', gap: 2, mb: 3 }}>
<Button startIcon={<ArrowBackIcon />} onClick={() => navigate('/')}> <Button startIcon={<ArrowBackIcon />} onClick={() => navigate('/proposals')}>
Back Back
</Button> </Button>
<Typography variant="h5" sx={{ flexGrow: 1 }}> <Typography variant="h5" sx={{ flexGrow: 1 }}>

View file

@ -1,5 +1,168 @@
import Dashboard from '../../dashboard/Dashboard'; import { useNavigate } from 'react-router-dom';
import {
Box,
Typography,
Card,
CardContent,
Table,
TableBody,
TableCell,
TableContainer,
TableHead,
TableRow,
TablePagination,
Chip,
TextField,
InputAdornment,
Button,
Skeleton,
} from '@mui/material';
import SearchIcon from '@mui/icons-material/Search';
import AddCircleIcon from '@mui/icons-material/AddCircle';
import { usePaginatedList } from '../../../hooks/usePaginatedList';
import { proposalsApi, type ProposalListItem } from '../../../lib/api/proposals';
const STATUS_COLORS: Record<string, 'default' | 'info' | 'warning' | 'success' | 'error'> = {
Draft: 'default',
InReview: 'info',
Approved: 'success',
Sent: 'success',
Revised: 'warning',
};
function formatCurrency(amount: number): string {
return new Intl.NumberFormat('en-US', { style: 'currency', currency: 'USD' }).format(amount);
}
function formatDate(iso: string): string {
return new Date(iso).toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' });
}
export default function ProposalListPage() { export default function ProposalListPage() {
return <Dashboard />; const navigate = useNavigate();
const {
rows,
search,
setSearch,
page,
setPage,
pageSize,
setPageSize,
totalCount,
loading,
err,
} = usePaginatedList<ProposalListItem>(proposalsApi.getAll);
return (
<Box>
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', mb: 2 }}>
<Typography variant="h5">My Proposals</Typography>
<Button
variant="contained"
startIcon={<AddCircleIcon />}
onClick={() => navigate('/proposals/new')}
>
New Proposal
</Button>
</Box>
<Card>
<CardContent sx={{ pb: 0 }}>
<TextField
size="small"
placeholder="Search proposals..."
value={search}
onChange={(e) => setSearch(e.target.value)}
sx={{ mb: 2, width: 320 }}
slotProps={{
input: {
startAdornment: (
<InputAdornment position="start">
<SearchIcon fontSize="small" />
</InputAdornment>
),
},
}}
/>
{err && (
<Typography color="error" sx={{ mb: 2 }}>
{err}
</Typography>
)}
<TableContainer>
<Table size="small">
<TableHead>
<TableRow>
<TableCell>Proposal #</TableCell>
<TableCell>Customer</TableCell>
<TableCell>WO #</TableCell>
<TableCell>Category</TableCell>
<TableCell>Priority</TableCell>
<TableCell>Status</TableCell>
<TableCell align="right">Bid Amount</TableCell>
<TableCell>Submitted</TableCell>
</TableRow>
</TableHead>
<TableBody>
{loading
? Array.from({ length: 5 }).map((_, i) => (
<TableRow key={i}>
{Array.from({ length: 8 }).map((_, j) => (
<TableCell key={j}>
<Skeleton variant="text" />
</TableCell>
))}
</TableRow>
))
: rows.map((row) => (
<TableRow
key={row.id}
hover
sx={{ cursor: 'pointer' }}
onClick={() => navigate(`/proposals/${row.id}`)}
>
<TableCell sx={{ fontWeight: 600 }}>{row.proposalNumber}</TableCell>
<TableCell>{row.customerName}</TableCell>
<TableCell>{row.workOrderNumber}</TableCell>
<TableCell>{row.serviceCategory}</TableCell>
<TableCell>{row.priority}</TableCell>
<TableCell>
<Chip
label={row.status}
size="small"
color={STATUS_COLORS[row.status] || 'default'}
/>
</TableCell>
<TableCell align="right">{formatCurrency(row.totalBidAmount)}</TableCell>
<TableCell>{formatDate(row.submittedAt)}</TableCell>
</TableRow>
))}
{!loading && rows.length === 0 && (
<TableRow>
<TableCell colSpan={8} align="center" sx={{ py: 4 }}>
<Typography color="text.secondary">
No proposals found. Create your first one!
</Typography>
</TableCell>
</TableRow>
)}
</TableBody>
</Table>
</TableContainer>
<TablePagination
component="div"
count={totalCount}
page={page - 1}
onPageChange={(_, newPage) => setPage(newPage + 1)}
rowsPerPage={pageSize}
onRowsPerPageChange={(e) => setPageSize(parseInt(e.target.value, 10))}
rowsPerPageOptions={[12, 24, 48]}
/>
</CardContent>
</Card>
</Box>
);
} }