Add class markbook tab
This commit is contained in:
parent
c346493a34
commit
900e2613b4
@ -10,6 +10,7 @@ import {
|
||||
} from '@mui/icons-material';
|
||||
import { useAuth } from '../../contexts/AuthContext';
|
||||
import { ResultsWidget } from '../exam';
|
||||
import MarkbookPanel from './MarkbookPanel';
|
||||
|
||||
const API_BASE = import.meta.env.VITE_API_BASE || import.meta.env.VITE_API_URL || '/api';
|
||||
|
||||
@ -270,6 +271,7 @@ const ClassDetailPage: React.FC = () => {
|
||||
{/* Tabs */}
|
||||
<Tabs value={tab} onChange={(_, v) => setTab(v)} sx={{ borderBottom: 1, borderColor: 'divider', mb: 2 }}>
|
||||
<Tab label={`Students (${cls.student_count})`} />
|
||||
<Tab label="Markbook" />
|
||||
<Tab label={`Requests${pendingCount > 0 ? ` (${pendingCount})` : ''}`} />
|
||||
<Tab label={`Teachers (${cls.teachers.length})`} />
|
||||
</Tabs>
|
||||
@ -332,8 +334,13 @@ const ClassDetailPage: React.FC = () => {
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{/* Enrollment requests tab */}
|
||||
{/* Markbook tab */}
|
||||
{tab === 1 && (
|
||||
<MarkbookPanel classId={cls.id} accessToken={accessToken || ''} />
|
||||
)}
|
||||
|
||||
{/* Enrollment requests tab */}
|
||||
{tab === 2 && (
|
||||
<Box>
|
||||
{cls.enrollment_requests.length === 0 ? (
|
||||
<Typography color="text.secondary" sx={{ py: 4, textAlign: 'center' }}>
|
||||
@ -392,7 +399,7 @@ const ClassDetailPage: React.FC = () => {
|
||||
)}
|
||||
|
||||
{/* Teachers tab */}
|
||||
{tab === 2 && (
|
||||
{tab === 3 && (
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1 }}>
|
||||
{cls.teachers.length === 0 ? (
|
||||
<Typography color="text.secondary" sx={{ py: 4, textAlign: 'center' }}>
|
||||
|
||||
319
src/pages/timetable/MarkbookPanel.tsx
Normal file
319
src/pages/timetable/MarkbookPanel.tsx
Normal file
@ -0,0 +1,319 @@
|
||||
import React, { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import {
|
||||
Alert,
|
||||
Box,
|
||||
Button,
|
||||
CircularProgress,
|
||||
Dialog,
|
||||
DialogActions,
|
||||
DialogContent,
|
||||
DialogTitle,
|
||||
Paper,
|
||||
Stack,
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableContainer,
|
||||
TableHead,
|
||||
TableRow,
|
||||
TextField,
|
||||
Typography,
|
||||
} from '@mui/material';
|
||||
import AddIcon from '@mui/icons-material/Add';
|
||||
import DownloadIcon from '@mui/icons-material/Download';
|
||||
|
||||
import { API_BASE } from '../../config/apiConfig';
|
||||
|
||||
interface Assessment {
|
||||
id: string;
|
||||
title: string;
|
||||
date?: string | null;
|
||||
max_marks: number;
|
||||
}
|
||||
|
||||
interface MarkbookStudent {
|
||||
student_id: string;
|
||||
student_name: string;
|
||||
row_number: number;
|
||||
marks: Record<string, number | null>;
|
||||
total: number | null;
|
||||
percentage: number | null;
|
||||
}
|
||||
|
||||
interface AssessmentSummary {
|
||||
assessment_id: string;
|
||||
entered_count: number;
|
||||
average_mark: number | null;
|
||||
average_percentage: number | null;
|
||||
}
|
||||
|
||||
interface MarkbookGrid {
|
||||
assessments: Assessment[];
|
||||
students: MarkbookStudent[];
|
||||
assessment_summaries: AssessmentSummary[];
|
||||
summary: {
|
||||
student_count: number;
|
||||
assessment_count: number;
|
||||
entered_mark_count: number;
|
||||
class_average_mark: number | null;
|
||||
};
|
||||
}
|
||||
|
||||
interface MarkbookPanelProps {
|
||||
classId: string;
|
||||
accessToken: string;
|
||||
}
|
||||
|
||||
const MARKBOOK_BASE = `${API_BASE}/api/markbook`;
|
||||
|
||||
function formatNumber(value: number | null | undefined) {
|
||||
return value === null || value === undefined ? '—' : Number(value).toFixed(Number.isInteger(value) ? 0 : 1);
|
||||
}
|
||||
|
||||
function markInputValue(value: number | null | undefined) {
|
||||
return value === null || value === undefined ? '' : String(value);
|
||||
}
|
||||
|
||||
function AddAssessmentDialog({
|
||||
open,
|
||||
saving,
|
||||
onClose,
|
||||
onCreate,
|
||||
}: {
|
||||
open: boolean;
|
||||
saving: boolean;
|
||||
onClose: () => void;
|
||||
onCreate: (payload: { title: string; date?: string; max_marks: number }) => Promise<void>;
|
||||
}) {
|
||||
const [title, setTitle] = useState('');
|
||||
const [date, setDate] = useState('');
|
||||
const [maxMarks, setMaxMarks] = useState('100');
|
||||
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
setTitle('');
|
||||
setDate('');
|
||||
setMaxMarks('100');
|
||||
}
|
||||
}, [open]);
|
||||
|
||||
const handleCreate = async () => {
|
||||
const parsedMax = Number(maxMarks);
|
||||
if (!title.trim() || !Number.isFinite(parsedMax) || parsedMax <= 0) return;
|
||||
await onCreate({ title: title.trim(), date: date || undefined, max_marks: parsedMax });
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={open} onClose={onClose} maxWidth="xs" fullWidth>
|
||||
<DialogTitle>Add assessment column</DialogTitle>
|
||||
<DialogContent sx={{ pt: 2 }}>
|
||||
<Stack spacing={2} sx={{ mt: 1 }}>
|
||||
<TextField label="Title" size="small" value={title} onChange={(e) => setTitle(e.target.value)} autoFocus />
|
||||
<TextField label="Date" type="date" size="small" value={date} onChange={(e) => setDate(e.target.value)} InputLabelProps={{ shrink: true }} />
|
||||
<TextField label="Max marks" type="number" size="small" value={maxMarks} onChange={(e) => setMaxMarks(e.target.value)} inputProps={{ min: 0, step: 0.5 }} />
|
||||
</Stack>
|
||||
</DialogContent>
|
||||
<DialogActions sx={{ px: 3, pb: 2 }}>
|
||||
<Button onClick={onClose} disabled={saving}>Cancel</Button>
|
||||
<Button onClick={handleCreate} variant="contained" disabled={saving || !title.trim()} startIcon={saving ? <CircularProgress size={16} /> : <AddIcon />}>
|
||||
Add
|
||||
</Button>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
const MarkbookPanel: React.FC<MarkbookPanelProps> = ({ classId, accessToken }) => {
|
||||
const [grid, setGrid] = useState<MarkbookGrid | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [savingKey, setSavingKey] = useState<string | null>(null);
|
||||
const [addOpen, setAddOpen] = useState(false);
|
||||
const [savingAssessment, setSavingAssessment] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const headers = useMemo(() => ({ Authorization: `Bearer ${accessToken}`, 'Content-Type': 'application/json' }), [accessToken]);
|
||||
|
||||
const load = useCallback(async () => {
|
||||
if (!classId || !accessToken) return;
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const res = await fetch(`${MARKBOOK_BASE}/classes/${classId}/grid`, { headers });
|
||||
const body = await res.json();
|
||||
if (!res.ok) throw new Error(body.detail || 'Failed to load markbook');
|
||||
setGrid(body);
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : String(e));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [accessToken, classId, headers]);
|
||||
|
||||
useEffect(() => {
|
||||
void load();
|
||||
}, [load]);
|
||||
|
||||
const createAssessment = async (payload: { title: string; date?: string; max_marks: number }) => {
|
||||
setSavingAssessment(true);
|
||||
setError(null);
|
||||
try {
|
||||
const res = await fetch(`${MARKBOOK_BASE}/classes/${classId}/assessments`, {
|
||||
method: 'POST',
|
||||
headers,
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
const body = await res.json();
|
||||
if (!res.ok) throw new Error(body.detail || 'Failed to create assessment');
|
||||
setAddOpen(false);
|
||||
await load();
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : String(e));
|
||||
} finally {
|
||||
setSavingAssessment(false);
|
||||
}
|
||||
};
|
||||
|
||||
const saveMark = async (studentId: string, assessment: Assessment, raw: string) => {
|
||||
const key = `${studentId}:${assessment.id}`;
|
||||
const trimmed = raw.trim();
|
||||
const mark = trimmed === '' ? null : Number(trimmed);
|
||||
if (mark !== null && (!Number.isFinite(mark) || mark < 0 || mark > Number(assessment.max_marks))) {
|
||||
setError(`Mark must be between 0 and ${assessment.max_marks}`);
|
||||
return;
|
||||
}
|
||||
setSavingKey(key);
|
||||
setError(null);
|
||||
try {
|
||||
const res = await fetch(`${MARKBOOK_BASE}/classes/${classId}/assessments/${assessment.id}/marks/${studentId}`, {
|
||||
method: 'PUT',
|
||||
headers,
|
||||
body: JSON.stringify({ mark }),
|
||||
});
|
||||
const body = await res.json();
|
||||
if (!res.ok) throw new Error(body.detail || 'Failed to save mark');
|
||||
await load();
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : String(e));
|
||||
} finally {
|
||||
setSavingKey(null);
|
||||
}
|
||||
};
|
||||
|
||||
const downloadCsv = async () => {
|
||||
setError(null);
|
||||
try {
|
||||
const res = await fetch(`${MARKBOOK_BASE}/classes/${classId}/csv`, { headers });
|
||||
const text = await res.text();
|
||||
if (!res.ok) throw new Error(text || 'Failed to export CSV');
|
||||
const blob = new Blob([text], { type: 'text/csv;charset=utf-8' });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = `markbook-${classId}.csv`;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
a.remove();
|
||||
URL.revokeObjectURL(url);
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : String(e));
|
||||
}
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return <Box sx={{ display: 'flex', justifyContent: 'center', py: 4 }}><CircularProgress /></Box>;
|
||||
}
|
||||
|
||||
if (!grid) {
|
||||
return <Alert severity="error">{error || 'Could not load markbook'}</Alert>;
|
||||
}
|
||||
|
||||
const summaryByAssessment = new Map(grid.assessment_summaries.map((s) => [s.assessment_id, s]));
|
||||
|
||||
return (
|
||||
<Box>
|
||||
{error && <Alert severity="error" onClose={() => setError(null)} sx={{ mb: 2 }}>{error}</Alert>}
|
||||
<Stack direction="row" spacing={1} sx={{ mb: 2 }} justifyContent="space-between" alignItems="center" flexWrap="wrap" useFlexGap>
|
||||
<Stack direction="row" spacing={1} flexWrap="wrap" useFlexGap>
|
||||
<Typography variant="body2" color="text.secondary">{grid.summary.student_count} roster students</Typography>
|
||||
<Typography variant="body2" color="text.secondary">{grid.summary.assessment_count} assessments</Typography>
|
||||
<Typography variant="body2" color="text.secondary">Class average mark {formatNumber(grid.summary.class_average_mark)}</Typography>
|
||||
</Stack>
|
||||
<Stack direction="row" spacing={1}>
|
||||
<Button size="small" variant="outlined" startIcon={<DownloadIcon />} onClick={downloadCsv} disabled={grid.assessments.length === 0}>CSV</Button>
|
||||
<Button size="small" variant="contained" startIcon={<AddIcon />} onClick={() => setAddOpen(true)}>Add assessment</Button>
|
||||
</Stack>
|
||||
</Stack>
|
||||
|
||||
{grid.assessments.length === 0 ? (
|
||||
<Paper variant="outlined" sx={{ py: 4, px: 2, textAlign: 'center' }}>
|
||||
<Typography color="text.secondary" sx={{ mb: 2 }}>No assessment columns yet.</Typography>
|
||||
<Button variant="contained" startIcon={<AddIcon />} onClick={() => setAddOpen(true)}>Add first assessment</Button>
|
||||
</Paper>
|
||||
) : (
|
||||
<TableContainer component={Paper} variant="outlined" sx={{ maxHeight: 620 }}>
|
||||
<Table size="small" stickyHeader>
|
||||
<TableHead>
|
||||
<TableRow>
|
||||
<TableCell sx={{ minWidth: 44 }}>#</TableCell>
|
||||
<TableCell sx={{ minWidth: 180 }}>Student</TableCell>
|
||||
{grid.assessments.map((assessment) => (
|
||||
<TableCell key={assessment.id} align="right" sx={{ minWidth: 130 }}>
|
||||
<Typography variant="body2" fontWeight={700}>{assessment.title}</Typography>
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
{assessment.date ? `${new Date(assessment.date).toLocaleDateString('en-GB')} · ` : ''}/{assessment.max_marks}
|
||||
</Typography>
|
||||
</TableCell>
|
||||
))}
|
||||
<TableCell align="right">Total</TableCell>
|
||||
<TableCell align="right">%</TableCell>
|
||||
</TableRow>
|
||||
</TableHead>
|
||||
<TableBody>
|
||||
{grid.students.map((student) => (
|
||||
<TableRow key={student.student_id}>
|
||||
<TableCell>{student.row_number}</TableCell>
|
||||
<TableCell>
|
||||
<Typography variant="body2" fontWeight={600}>{student.student_name || student.student_id}</Typography>
|
||||
<Typography variant="caption" color="text.secondary">{student.student_id}</Typography>
|
||||
</TableCell>
|
||||
{grid.assessments.map((assessment) => {
|
||||
const key = `${student.student_id}:${assessment.id}`;
|
||||
return (
|
||||
<TableCell key={assessment.id} align="right">
|
||||
<TextField
|
||||
size="small"
|
||||
type="number"
|
||||
defaultValue={markInputValue(student.marks[assessment.id])}
|
||||
onBlur={(e) => void saveMark(student.student_id, assessment, e.target.value)}
|
||||
disabled={savingKey === key}
|
||||
inputProps={{ min: 0, max: assessment.max_marks, step: 0.5, style: { textAlign: 'right' } }}
|
||||
sx={{ width: 86 }}
|
||||
/>
|
||||
</TableCell>
|
||||
);
|
||||
})}
|
||||
<TableCell align="right"><strong>{formatNumber(student.total)}</strong></TableCell>
|
||||
<TableCell align="right">{formatNumber(student.percentage)}</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
<TableRow sx={{ bgcolor: 'action.hover' }}>
|
||||
<TableCell />
|
||||
<TableCell><strong>Average</strong></TableCell>
|
||||
{grid.assessments.map((assessment) => {
|
||||
const summary = summaryByAssessment.get(assessment.id);
|
||||
return <TableCell key={assessment.id} align="right"><strong>{formatNumber(summary?.average_mark)}</strong></TableCell>;
|
||||
})}
|
||||
<TableCell />
|
||||
<TableCell />
|
||||
</TableRow>
|
||||
</TableBody>
|
||||
</Table>
|
||||
</TableContainer>
|
||||
)}
|
||||
|
||||
<AddAssessmentDialog open={addOpen} saving={savingAssessment} onClose={() => setAddOpen(false)} onCreate={createAssessment} />
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
export default MarkbookPanel;
|
||||
Loading…
x
Reference in New Issue
Block a user