mirror of
https://github.com/Sea-Haven-Industries/proposal-system.git
synced 2026-10-04 12:32:02 +00:00
269 lines
9.2 KiB
TypeScript
269 lines
9.2 KiB
TypeScript
|
|
import { useNavigate } from 'react-router-dom';
|
||
|
|
import { useQuery } from '@tanstack/react-query';
|
||
|
|
import {
|
||
|
|
Box,
|
||
|
|
Typography,
|
||
|
|
Card,
|
||
|
|
CardContent,
|
||
|
|
Grid,
|
||
|
|
Table,
|
||
|
|
TableBody,
|
||
|
|
TableCell,
|
||
|
|
TableContainer,
|
||
|
|
TableHead,
|
||
|
|
TableRow,
|
||
|
|
TablePagination,
|
||
|
|
Chip,
|
||
|
|
TextField,
|
||
|
|
InputAdornment,
|
||
|
|
MenuItem,
|
||
|
|
Skeleton,
|
||
|
|
} from '@mui/material';
|
||
|
|
import SearchIcon from '@mui/icons-material/Search';
|
||
|
|
import PendingActionsIcon from '@mui/icons-material/PendingActions';
|
||
|
|
import CheckCircleIcon from '@mui/icons-material/CheckCircle';
|
||
|
|
import TimerIcon from '@mui/icons-material/Timer';
|
||
|
|
import DescriptionIcon from '@mui/icons-material/Description';
|
||
|
|
import { usePaginatedList } from '../../../hooks/usePaginatedList';
|
||
|
|
import { proposalsApi, type ProposalListItem } from '../../../lib/api/proposals';
|
||
|
|
import { adminApi, type DashboardStats } from '../../../lib/api/admin';
|
||
|
|
import { QUERY_KEYS } from '../../../constants/queryKeys';
|
||
|
|
import { SERVICE_CATEGORIES, PRIORITIES } from '../../../constants';
|
||
|
|
|
||
|
|
const STATUS_COLORS: Record<string, 'default' | 'info' | 'warning' | 'success'> = {
|
||
|
|
Draft: 'default',
|
||
|
|
InReview: 'info',
|
||
|
|
Approved: 'success',
|
||
|
|
Sent: 'success',
|
||
|
|
Revised: 'warning',
|
||
|
|
};
|
||
|
|
|
||
|
|
const PRIORITY_COLORS: Record<string, 'default' | 'warning' | 'error'> = {
|
||
|
|
Standard: 'default',
|
||
|
|
Urgent: 'warning',
|
||
|
|
Emergency: 'error',
|
||
|
|
};
|
||
|
|
|
||
|
|
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' });
|
||
|
|
}
|
||
|
|
|
||
|
|
function StatCard({ icon, label, value, color }: { icon: React.ReactNode; label: string; value: string; color: 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={{ color, display: 'flex', justifyContent: 'center', mb: 1 }}>{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 AdminDashboard() {
|
||
|
|
const navigate = useNavigate();
|
||
|
|
|
||
|
|
const { data: stats, isLoading: statsLoading } = useQuery<DashboardStats>({
|
||
|
|
queryKey: [QUERY_KEYS.dashboard],
|
||
|
|
queryFn: adminApi.getDashboard,
|
||
|
|
});
|
||
|
|
|
||
|
|
const {
|
||
|
|
rows,
|
||
|
|
search,
|
||
|
|
setSearch,
|
||
|
|
page,
|
||
|
|
setPage,
|
||
|
|
pageSize,
|
||
|
|
setPageSize,
|
||
|
|
totalCount,
|
||
|
|
loading,
|
||
|
|
err,
|
||
|
|
} = usePaginatedList<ProposalListItem>(proposalsApi.getAll);
|
||
|
|
|
||
|
|
return (
|
||
|
|
<Box>
|
||
|
|
<Typography variant="h5" sx={{ mb: 3 }}>Admin Dashboard</Typography>
|
||
|
|
|
||
|
|
<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"
|
||
|
|
/>
|
||
|
|
)}
|
||
|
|
</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"
|
||
|
|
/>
|
||
|
|
)}
|
||
|
|
</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"
|
||
|
|
/>
|
||
|
|
)}
|
||
|
|
</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"
|
||
|
|
/>
|
||
|
|
)}
|
||
|
|
</Grid>
|
||
|
|
</Grid>
|
||
|
|
|
||
|
|
<Card>
|
||
|
|
<CardContent sx={{ pb: 0 }}>
|
||
|
|
<Box sx={{ display: 'flex', gap: 2, mb: 2, flexWrap: 'wrap' }}>
|
||
|
|
<TextField
|
||
|
|
size="small"
|
||
|
|
placeholder="Search..."
|
||
|
|
value={search}
|
||
|
|
onChange={(e) => setSearch(e.target.value)}
|
||
|
|
sx={{ width: 240 }}
|
||
|
|
slotProps={{
|
||
|
|
input: {
|
||
|
|
startAdornment: (
|
||
|
|
<InputAdornment position="start">
|
||
|
|
<SearchIcon fontSize="small" />
|
||
|
|
</InputAdornment>
|
||
|
|
),
|
||
|
|
},
|
||
|
|
}}
|
||
|
|
/>
|
||
|
|
<TextField
|
||
|
|
size="small"
|
||
|
|
select
|
||
|
|
label="Category"
|
||
|
|
value=""
|
||
|
|
sx={{ width: 160 }}
|
||
|
|
onChange={() => {}}
|
||
|
|
>
|
||
|
|
<MenuItem value="">All</MenuItem>
|
||
|
|
{SERVICE_CATEGORIES.map((c) => (
|
||
|
|
<MenuItem key={c} value={c}>{c}</MenuItem>
|
||
|
|
))}
|
||
|
|
</TextField>
|
||
|
|
<TextField
|
||
|
|
size="small"
|
||
|
|
select
|
||
|
|
label="Priority"
|
||
|
|
value=""
|
||
|
|
sx={{ width: 140 }}
|
||
|
|
onChange={() => {}}
|
||
|
|
>
|
||
|
|
<MenuItem value="">All</MenuItem>
|
||
|
|
{PRIORITIES.map((p) => (
|
||
|
|
<MenuItem key={p} value={p}>{p}</MenuItem>
|
||
|
|
))}
|
||
|
|
</TextField>
|
||
|
|
</Box>
|
||
|
|
|
||
|
|
{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>
|
||
|
|
<TableCell>Submitted By</TableCell>
|
||
|
|
</TableRow>
|
||
|
|
</TableHead>
|
||
|
|
<TableBody>
|
||
|
|
{loading
|
||
|
|
? Array.from({ length: 5 }).map((_, i) => (
|
||
|
|
<TableRow key={i}>
|
||
|
|
{Array.from({ length: 9 }).map((_, j) => (
|
||
|
|
<TableCell key={j}><Skeleton variant="text" /></TableCell>
|
||
|
|
))}
|
||
|
|
</TableRow>
|
||
|
|
))
|
||
|
|
: rows.map((row) => (
|
||
|
|
<TableRow
|
||
|
|
key={row.id}
|
||
|
|
hover
|
||
|
|
sx={{ cursor: 'pointer' }}
|
||
|
|
onClick={() => navigate(`/admin/proposals/${row.id}`)}
|
||
|
|
>
|
||
|
|
<TableCell sx={{ fontWeight: 600 }}>{row.proposalNumber}</TableCell>
|
||
|
|
<TableCell>{row.customerName}</TableCell>
|
||
|
|
<TableCell>{row.workOrderNumber}</TableCell>
|
||
|
|
<TableCell>{row.serviceCategory}</TableCell>
|
||
|
|
<TableCell>
|
||
|
|
<Chip label={row.priority} size="small" color={PRIORITY_COLORS[row.priority] || 'default'} />
|
||
|
|
</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>
|
||
|
|
<TableCell>{row.submittedByName || '-'}</TableCell>
|
||
|
|
</TableRow>
|
||
|
|
))}
|
||
|
|
{!loading && rows.length === 0 && (
|
||
|
|
<TableRow>
|
||
|
|
<TableCell colSpan={9} align="center" sx={{ py: 4 }}>
|
||
|
|
<Typography color="text.secondary">No proposals in queue</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>
|
||
|
|
);
|
||
|
|
}
|