app-ci-deploy / test-build-deploy (push) Has been cancelled
- /exam-marker/:batchId/mark — ExamMarkingPage: student queue, per-part mark entry, upsert marks - /exam-marker/:batchId/results — ExamResultsPage: results table with absent rows, CSV download - ResultsWidget on ClassDetailPage: last batch summary, class average, absent count, batch creation - New types: MarkingBatch, StudentSubmission, BatchQueueResponse, BatchResultsResponse, MarkUpsertPayload - New repo methods: createBatch, listBatches, getBatchQueue, getBatchResults, getBatchCsv, upsertMark Co-Authored-By: Claude Sonnet 4.6 <[email protected]>
178 lines
6.4 KiB
TypeScript
178 lines
6.4 KiB
TypeScript
import React, { useCallback, useEffect, useMemo, useState } from 'react';
|
|
import { useNavigate, useParams } from 'react-router-dom';
|
|
import {
|
|
Alert,
|
|
Box,
|
|
Button,
|
|
Chip,
|
|
CircularProgress,
|
|
Container,
|
|
Paper,
|
|
Stack,
|
|
Table,
|
|
TableBody,
|
|
TableCell,
|
|
TableContainer,
|
|
TableHead,
|
|
TableRow,
|
|
Typography,
|
|
} from '@mui/material';
|
|
import ArrowBackIcon from '@mui/icons-material/ArrowBack';
|
|
import DownloadIcon from '@mui/icons-material/Download';
|
|
import EditIcon from '@mui/icons-material/Edit';
|
|
|
|
import { examRepository } from '../../services/exam/examRepository';
|
|
import type { BatchResultsResponse } from '../../types/exam.types';
|
|
|
|
function formatMark(value: number | null | undefined) {
|
|
return value === null || value === undefined ? '' : String(value);
|
|
}
|
|
|
|
const ExamResultsPage: React.FC = () => {
|
|
const { batchId } = useParams<{ batchId: string }>();
|
|
const navigate = useNavigate();
|
|
const [data, setData] = useState<BatchResultsResponse | null>(null);
|
|
const [loading, setLoading] = useState(true);
|
|
const [downloading, setDownloading] = useState(false);
|
|
const [error, setError] = useState<string | null>(null);
|
|
|
|
const load = useCallback(async () => {
|
|
if (!batchId) return;
|
|
setLoading(true);
|
|
setError(null);
|
|
try {
|
|
setData(await examRepository.getBatchResults(batchId));
|
|
} catch (e) {
|
|
setError(e instanceof Error ? e.message : String(e));
|
|
} finally {
|
|
setLoading(false);
|
|
}
|
|
}, [batchId]);
|
|
|
|
useEffect(() => {
|
|
void load();
|
|
}, [load]);
|
|
|
|
const summary = useMemo(() => {
|
|
const rows = data?.results ?? [];
|
|
const presentTotals = rows
|
|
.map((r) => r.total)
|
|
.filter((v): v is number => typeof v === 'number');
|
|
const average = presentTotals.length
|
|
? presentTotals.reduce((sum, v) => sum + v, 0) / presentTotals.length
|
|
: null;
|
|
return {
|
|
total: rows.length,
|
|
absent: rows.filter((r) => r.status === 'absent' && r.total === null).length,
|
|
marked: rows.filter((r) => r.total !== null).length,
|
|
average,
|
|
};
|
|
}, [data]);
|
|
|
|
const downloadCsv = async () => {
|
|
if (!batchId) return;
|
|
setDownloading(true);
|
|
setError(null);
|
|
try {
|
|
const csv = await examRepository.getBatchCsv(batchId);
|
|
const blob = new Blob([csv], { type: 'text/csv;charset=utf-8' });
|
|
const url = URL.createObjectURL(blob);
|
|
const a = document.createElement('a');
|
|
a.href = url;
|
|
a.download = `batch-${batchId}.csv`;
|
|
document.body.appendChild(a);
|
|
a.click();
|
|
a.remove();
|
|
URL.revokeObjectURL(url);
|
|
} catch (e) {
|
|
setError(e instanceof Error ? e.message : String(e));
|
|
} finally {
|
|
setDownloading(false);
|
|
}
|
|
};
|
|
|
|
if (loading) {
|
|
return <Box sx={{ display: 'flex', justifyContent: 'center', py: 8 }}><CircularProgress /></Box>;
|
|
}
|
|
|
|
if (error || !data) {
|
|
return (
|
|
<Container maxWidth="lg" sx={{ py: 4 }}>
|
|
<Alert severity="error">{error || 'Results not found'}</Alert>
|
|
<Button sx={{ mt: 2 }} startIcon={<ArrowBackIcon />} onClick={() => navigate('/exam-marker')}>
|
|
Back to Exam Marker
|
|
</Button>
|
|
</Container>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<Container maxWidth="xl" sx={{ py: 4 }}>
|
|
<Stack spacing={3}>
|
|
<Box sx={{ display: 'flex', alignItems: 'flex-start', justifyContent: 'space-between', gap: 2, flexWrap: 'wrap' }}>
|
|
<Box>
|
|
<Button size="small" startIcon={<ArrowBackIcon />} onClick={() => navigate('/exam-marker')} sx={{ mb: 1 }}>
|
|
Exam Marker
|
|
</Button>
|
|
<Typography variant="h4" component="h1" fontWeight={700}>
|
|
{data.batch.title || 'Exam results'}
|
|
</Typography>
|
|
<Typography variant="body2" color="text.secondary">
|
|
Batch {data.batch.id} · created {new Date(data.batch.created_at).toLocaleDateString('en-GB')}
|
|
</Typography>
|
|
</Box>
|
|
<Stack direction="row" spacing={1}>
|
|
<Button variant="outlined" startIcon={<EditIcon />} onClick={() => navigate(`/exam-marker/${data.batch.id}/mark`)}>
|
|
Mark
|
|
</Button>
|
|
<Button variant="contained" startIcon={<DownloadIcon />} onClick={downloadCsv} disabled={downloading}>
|
|
{downloading ? 'Preparing…' : 'Download CSV'}
|
|
</Button>
|
|
</Stack>
|
|
</Box>
|
|
|
|
<Stack direction="row" spacing={1} flexWrap="wrap" useFlexGap>
|
|
<Chip label={`${summary.total} students`} />
|
|
<Chip label={`${summary.marked} with marks`} color="success" variant="outlined" />
|
|
<Chip label={`${summary.absent} absent/no scan`} color="warning" variant="outlined" />
|
|
<Chip label={`Class average ${summary.average === null ? '—' : summary.average.toFixed(1)}`} color="primary" />
|
|
</Stack>
|
|
|
|
<TableContainer component={Paper} variant="outlined">
|
|
<Table size="small" stickyHeader>
|
|
<TableHead>
|
|
<TableRow>
|
|
<TableCell>Student</TableCell>
|
|
<TableCell>Status</TableCell>
|
|
{data.questions.map((q) => (
|
|
<TableCell key={q.id} align="right">{q.label} / {q.max_marks}</TableCell>
|
|
))}
|
|
<TableCell align="right">Total</TableCell>
|
|
</TableRow>
|
|
</TableHead>
|
|
<TableBody>
|
|
{data.results.map((row) => (
|
|
<TableRow key={row.submission_id} sx={row.status === 'absent' && row.total === null ? { opacity: 0.72 } : undefined}>
|
|
<TableCell>
|
|
<Typography variant="body2" fontWeight={600}>{row.student_name || row.student_id || 'Unknown student'}</Typography>
|
|
{row.student_id && <Typography variant="caption" color="text.secondary">{row.student_id}</Typography>}
|
|
</TableCell>
|
|
<TableCell>
|
|
<Chip size="small" label={row.status || 'unknown'} color={row.status === 'absent' ? 'warning' : 'default'} variant="outlined" />
|
|
</TableCell>
|
|
{data.questions.map((q) => (
|
|
<TableCell key={q.id} align="right">{formatMark(row.marks[q.id])}</TableCell>
|
|
))}
|
|
<TableCell align="right"><strong>{formatMark(row.total)}</strong></TableCell>
|
|
</TableRow>
|
|
))}
|
|
</TableBody>
|
|
</Table>
|
|
</TableContainer>
|
|
</Stack>
|
|
</Container>
|
|
);
|
|
};
|
|
|
|
export default ExamResultsPage;
|