mirror of
https://github.com/Sea-Haven-Industries/proposal-system.git
synced 2026-10-07 03:32:02 +00:00
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:
parent
3e9623b6fc
commit
9859d89895
7 changed files with 305 additions and 83 deletions
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -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"
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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 }}>
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue