Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
900e2613b4 |
@@ -10,6 +10,7 @@ import {
|
|||||||
} from '@mui/icons-material';
|
} from '@mui/icons-material';
|
||||||
import { useAuth } from '../../contexts/AuthContext';
|
import { useAuth } from '../../contexts/AuthContext';
|
||||||
import { ResultsWidget } from '../exam';
|
import { ResultsWidget } from '../exam';
|
||||||
|
import MarkbookPanel from './MarkbookPanel';
|
||||||
|
|
||||||
const API_BASE = import.meta.env.VITE_API_BASE || import.meta.env.VITE_API_URL || '/api';
|
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 */}
|
||||||
<Tabs value={tab} onChange={(_, v) => setTab(v)} sx={{ borderBottom: 1, borderColor: 'divider', mb: 2 }}>
|
<Tabs value={tab} onChange={(_, v) => setTab(v)} sx={{ borderBottom: 1, borderColor: 'divider', mb: 2 }}>
|
||||||
<Tab label={`Students (${cls.student_count})`} />
|
<Tab label={`Students (${cls.student_count})`} />
|
||||||
|
<Tab label="Markbook" />
|
||||||
<Tab label={`Requests${pendingCount > 0 ? ` (${pendingCount})` : ''}`} />
|
<Tab label={`Requests${pendingCount > 0 ? ` (${pendingCount})` : ''}`} />
|
||||||
<Tab label={`Teachers (${cls.teachers.length})`} />
|
<Tab label={`Teachers (${cls.teachers.length})`} />
|
||||||
</Tabs>
|
</Tabs>
|
||||||
@@ -332,8 +334,13 @@ const ClassDetailPage: React.FC = () => {
|
|||||||
</Box>
|
</Box>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Enrollment requests tab */}
|
{/* Markbook tab */}
|
||||||
{tab === 1 && (
|
{tab === 1 && (
|
||||||
|
<MarkbookPanel classId={cls.id} accessToken={accessToken || ''} />
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Enrollment requests tab */}
|
||||||
|
{tab === 2 && (
|
||||||
<Box>
|
<Box>
|
||||||
{cls.enrollment_requests.length === 0 ? (
|
{cls.enrollment_requests.length === 0 ? (
|
||||||
<Typography color="text.secondary" sx={{ py: 4, textAlign: 'center' }}>
|
<Typography color="text.secondary" sx={{ py: 4, textAlign: 'center' }}>
|
||||||
@@ -392,7 +399,7 @@ const ClassDetailPage: React.FC = () => {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Teachers tab */}
|
{/* Teachers tab */}
|
||||||
{tab === 2 && (
|
{tab === 3 && (
|
||||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1 }}>
|
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1 }}>
|
||||||
{cls.teachers.length === 0 ? (
|
{cls.teachers.length === 0 ? (
|
||||||
<Typography color="text.secondary" sx={{ py: 4, textAlign: 'center' }}>
|
<Typography color="text.secondary" sx={{ py: 4, textAlign: 'center' }}>
|
||||||
|
|||||||
@@ -39,7 +39,6 @@ interface LessonPlan {
|
|||||||
year_group: string | null;
|
year_group: string | null;
|
||||||
duration_minutes: number | null;
|
duration_minutes: number | null;
|
||||||
context_notes: string | null;
|
context_notes: string | null;
|
||||||
homework: string | null;
|
|
||||||
objectives: Objective[];
|
objectives: Objective[];
|
||||||
activities: Activity[];
|
activities: Activity[];
|
||||||
is_public: boolean;
|
is_public: boolean;
|
||||||
@@ -89,7 +88,6 @@ const LessonPlanDetailPage: React.FC = () => {
|
|||||||
const [yearGroup, setYearGroup] = useState('');
|
const [yearGroup, setYearGroup] = useState('');
|
||||||
const [duration, setDuration] = useState('');
|
const [duration, setDuration] = useState('');
|
||||||
const [contextNotes, setContextNotes] = useState('');
|
const [contextNotes, setContextNotes] = useState('');
|
||||||
const [homework, setHomework] = useState('');
|
|
||||||
const [isPublic, setIsPublic] = useState(false);
|
const [isPublic, setIsPublic] = useState(false);
|
||||||
const [objectives, setObjectives] = useState<Objective[]>([]);
|
const [objectives, setObjectives] = useState<Objective[]>([]);
|
||||||
const [activities, setActivities] = useState<Activity[]>([]);
|
const [activities, setActivities] = useState<Activity[]>([]);
|
||||||
@@ -112,7 +110,6 @@ const LessonPlanDetailPage: React.FC = () => {
|
|||||||
setYearGroup(data.year_group || '');
|
setYearGroup(data.year_group || '');
|
||||||
setDuration(data.duration_minutes?.toString() || '');
|
setDuration(data.duration_minutes?.toString() || '');
|
||||||
setContextNotes(data.context_notes || '');
|
setContextNotes(data.context_notes || '');
|
||||||
setHomework(data.homework || '');
|
|
||||||
setIsPublic(data.is_public || false);
|
setIsPublic(data.is_public || false);
|
||||||
setObjectives(data.objectives || []);
|
setObjectives(data.objectives || []);
|
||||||
setActivities(data.activities || []);
|
setActivities(data.activities || []);
|
||||||
@@ -131,7 +128,7 @@ const LessonPlanDetailPage: React.FC = () => {
|
|||||||
// ── Save ──────────────────────────────────────────────────────────────────
|
// ── Save ──────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
const save = useCallback(async (
|
const save = useCallback(async (
|
||||||
overrides?: Partial<{ title: string; subject: string; year_group: string; duration_minutes: number | null; context_notes: string; homework: string; is_public: boolean; objectives: Objective[]; activities: Activity[] }>
|
overrides?: Partial<{ title: string; subject: string; year_group: string; duration_minutes: number | null; context_notes: string; is_public: boolean; objectives: Objective[]; activities: Activity[] }>
|
||||||
) => {
|
) => {
|
||||||
if (!accessToken || !planId) return;
|
if (!accessToken || !planId) return;
|
||||||
setSaving(true);
|
setSaving(true);
|
||||||
@@ -146,7 +143,6 @@ const LessonPlanDetailPage: React.FC = () => {
|
|||||||
year_group: yearGroup || null,
|
year_group: yearGroup || null,
|
||||||
duration_minutes: duration ? parseInt(duration, 10) : null,
|
duration_minutes: duration ? parseInt(duration, 10) : null,
|
||||||
context_notes: contextNotes || null,
|
context_notes: contextNotes || null,
|
||||||
homework: homework || '',
|
|
||||||
is_public: isPublic,
|
is_public: isPublic,
|
||||||
objectives,
|
objectives,
|
||||||
activities,
|
activities,
|
||||||
@@ -160,7 +156,7 @@ const LessonPlanDetailPage: React.FC = () => {
|
|||||||
} finally {
|
} finally {
|
||||||
setSaving(false);
|
setSaving(false);
|
||||||
}
|
}
|
||||||
}, [accessToken, planId, title, subject, yearGroup, duration, contextNotes, homework, isPublic, objectives, activities]);
|
}, [accessToken, planId, title, subject, yearGroup, duration, contextNotes, isPublic, objectives, activities]);
|
||||||
|
|
||||||
const scheduleAutoSave = useCallback(() => {
|
const scheduleAutoSave = useCallback(() => {
|
||||||
if (autoSaveTimer.current) clearTimeout(autoSaveTimer.current);
|
if (autoSaveTimer.current) clearTimeout(autoSaveTimer.current);
|
||||||
@@ -363,22 +359,6 @@ const LessonPlanDetailPage: React.FC = () => {
|
|||||||
/>
|
/>
|
||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
{/* Homework */}
|
|
||||||
<Box sx={{ mb: 3 }}>
|
|
||||||
<Typography variant="subtitle2" fontWeight={600} sx={{ mb: 0.75 }}>
|
|
||||||
Homework
|
|
||||||
</Typography>
|
|
||||||
<TextField
|
|
||||||
value={homework}
|
|
||||||
onChange={e => { setHomework(e.target.value); scheduleAutoSave(); }}
|
|
||||||
multiline
|
|
||||||
minRows={2}
|
|
||||||
fullWidth
|
|
||||||
size="small"
|
|
||||||
placeholder="Homework to carry with this plan when assigned to a taught lesson…"
|
|
||||||
/>
|
|
||||||
</Box>
|
|
||||||
|
|
||||||
<Divider sx={{ mb: 3 }} />
|
<Divider sx={{ mb: 3 }} />
|
||||||
|
|
||||||
{/* Objectives */}
|
{/* Objectives */}
|
||||||
|
|||||||
@@ -21,7 +21,6 @@ interface PlanSummary {
|
|||||||
duration_minutes: number | null;
|
duration_minutes: number | null;
|
||||||
objectives: { id: string; text: string; bloom_level?: string }[];
|
objectives: { id: string; text: string; bloom_level?: string }[];
|
||||||
activities: { id: string; section: string; title: string }[];
|
activities: { id: string; section: string; title: string }[];
|
||||||
homework: string | null;
|
|
||||||
is_public: boolean;
|
is_public: boolean;
|
||||||
created_at: string;
|
created_at: string;
|
||||||
updated_at: string;
|
updated_at: string;
|
||||||
@@ -145,7 +144,6 @@ function PlanCard({ plan, onOpen, onDelete }: {
|
|||||||
</Box>
|
</Box>
|
||||||
<Typography variant="caption" color="text.secondary">
|
<Typography variant="caption" color="text.secondary">
|
||||||
{objCount} objective{objCount !== 1 ? 's' : ''} · {actCount} activit{actCount !== 1 ? 'ies' : 'y'}
|
{objCount} objective{objCount !== 1 ? 's' : ''} · {actCount} activit{actCount !== 1 ? 'ies' : 'y'}
|
||||||
{plan.homework ? ' · homework set' : ''}
|
|
||||||
</Typography>
|
</Typography>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
<CardActions sx={{ pt: 0, px: 1.5, pb: 1.5, justifyContent: 'flex-end' }} onClick={e => e.stopPropagation()}>
|
<CardActions sx={{ pt: 0, px: 1.5, pb: 1.5, justifyContent: 'flex-end' }} onClick={e => e.stopPropagation()}>
|
||||||
|
|||||||
@@ -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;
|
||||||
@@ -22,7 +22,6 @@ interface Lesson {
|
|||||||
week_cycle: string;
|
week_cycle: string;
|
||||||
day_of_week: string;
|
day_of_week: string;
|
||||||
status: string;
|
status: string;
|
||||||
homework: string | null;
|
|
||||||
teacher_name: string | null;
|
teacher_name: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -96,11 +95,6 @@ function LessonCard({ lesson }: { lesson: Lesson }) {
|
|||||||
sx={{ height: 16, fontSize: '0.65rem' }}
|
sx={{ height: 16, fontSize: '0.65rem' }}
|
||||||
/>
|
/>
|
||||||
</Box>
|
</Box>
|
||||||
{lesson.homework && (
|
|
||||||
<Typography variant="caption" sx={{ color: 'primary.main', fontSize: '0.68rem', fontWeight: 600 }}>
|
|
||||||
Homework: {lesson.homework.length > 80 ? lesson.homework.slice(0, 80) + '…' : lesson.homework}
|
|
||||||
</Typography>
|
|
||||||
)}
|
|
||||||
{lesson.teacher_name && (
|
{lesson.teacher_name && (
|
||||||
<Typography variant="caption" sx={{ color: 'text.secondary', fontSize: '0.68rem' }}>
|
<Typography variant="caption" sx={{ color: 'text.secondary', fontSize: '0.68rem' }}>
|
||||||
{lesson.teacher_name}
|
{lesson.teacher_name}
|
||||||
|
|||||||
@@ -30,7 +30,6 @@ interface Lesson {
|
|||||||
day_of_week: string;
|
day_of_week: string;
|
||||||
status: string;
|
status: string;
|
||||||
lesson_plan: Record<string, any>;
|
lesson_plan: Record<string, any>;
|
||||||
homework: string | null;
|
|
||||||
notes: string | null;
|
notes: string | null;
|
||||||
whiteboard_room_id: string | null;
|
whiteboard_room_id: string | null;
|
||||||
}
|
}
|
||||||
@@ -77,18 +76,16 @@ const STATUS_COLORS: Record<string, 'default' | 'primary' | 'success' | 'error'
|
|||||||
interface EditDialogProps {
|
interface EditDialogProps {
|
||||||
lesson: Lesson | null;
|
lesson: Lesson | null;
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
onSave: (id: string, updates: { homework?: string; notes?: string; status?: string }) => Promise<void>;
|
onSave: (id: string, updates: { notes?: string; status?: string }) => Promise<void>;
|
||||||
}
|
}
|
||||||
|
|
||||||
function LessonEditDialog({ lesson, onClose, onSave }: EditDialogProps) {
|
function LessonEditDialog({ lesson, onClose, onSave }: EditDialogProps) {
|
||||||
const [homework, setHomework] = useState('');
|
|
||||||
const [notes, setNotes] = useState('');
|
const [notes, setNotes] = useState('');
|
||||||
const [status, setStatus] = useState('planned');
|
const [status, setStatus] = useState('planned');
|
||||||
const [saving, setSaving] = useState(false);
|
const [saving, setSaving] = useState(false);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (lesson) {
|
if (lesson) {
|
||||||
setHomework(lesson.homework || '');
|
|
||||||
setNotes(lesson.notes || '');
|
setNotes(lesson.notes || '');
|
||||||
setStatus(lesson.status || 'planned');
|
setStatus(lesson.status || 'planned');
|
||||||
}
|
}
|
||||||
@@ -97,7 +94,7 @@ function LessonEditDialog({ lesson, onClose, onSave }: EditDialogProps) {
|
|||||||
const handleSave = async () => {
|
const handleSave = async () => {
|
||||||
if (!lesson) return;
|
if (!lesson) return;
|
||||||
setSaving(true);
|
setSaving(true);
|
||||||
await onSave(lesson.id, { homework, notes, status });
|
await onSave(lesson.id, { notes, status });
|
||||||
setSaving(false);
|
setSaving(false);
|
||||||
onClose();
|
onClose();
|
||||||
};
|
};
|
||||||
@@ -130,16 +127,6 @@ function LessonEditDialog({ lesson, onClose, onSave }: EditDialogProps) {
|
|||||||
<MenuItem value="substituted">Substituted</MenuItem>
|
<MenuItem value="substituted">Substituted</MenuItem>
|
||||||
</Select>
|
</Select>
|
||||||
</FormControl>
|
</FormControl>
|
||||||
<TextField
|
|
||||||
label="Homework"
|
|
||||||
value={homework}
|
|
||||||
onChange={e => setHomework(e.target.value)}
|
|
||||||
multiline
|
|
||||||
minRows={2}
|
|
||||||
fullWidth
|
|
||||||
size="small"
|
|
||||||
placeholder="Homework for this lesson…"
|
|
||||||
/>
|
|
||||||
<TextField
|
<TextField
|
||||||
label="Notes"
|
label="Notes"
|
||||||
value={notes}
|
value={notes}
|
||||||
@@ -222,11 +209,6 @@ function LessonCard({ lesson, onEdit, onAssignPlan }: LessonCardProps) {
|
|||||||
sx={{ height: 16, fontSize: '0.65rem' }}
|
sx={{ height: 16, fontSize: '0.65rem' }}
|
||||||
/>
|
/>
|
||||||
</Box>
|
</Box>
|
||||||
{lesson.homework && (
|
|
||||||
<Typography variant="caption" sx={{ color: 'primary.main', fontSize: '0.68rem', fontWeight: 600, mt: 0.25 }}>
|
|
||||||
Homework: {lesson.homework.length > 80 ? lesson.homework.slice(0, 80) + '…' : lesson.homework}
|
|
||||||
</Typography>
|
|
||||||
)}
|
|
||||||
{lesson.notes && (
|
{lesson.notes && (
|
||||||
<Typography variant="caption" sx={{ color: 'text.secondary', fontSize: '0.68rem', fontStyle: 'italic', mt: 0.25 }}>
|
<Typography variant="caption" sx={{ color: 'text.secondary', fontSize: '0.68rem', fontStyle: 'italic', mt: 0.25 }}>
|
||||||
{lesson.notes.length > 80 ? lesson.notes.slice(0, 80) + '…' : lesson.notes}
|
{lesson.notes.length > 80 ? lesson.notes.slice(0, 80) + '…' : lesson.notes}
|
||||||
@@ -305,7 +287,7 @@ const TaughtLessonsPage: React.FC = () => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleSaveLesson = async (id: string, updates: { homework?: string; notes?: string; status?: string }) => {
|
const handleSaveLesson = async (id: string, updates: { notes?: string; status?: string }) => {
|
||||||
if (!accessToken) return;
|
if (!accessToken) return;
|
||||||
await fetch(`${API_BASE}/timetable/lessons/${id}`, {
|
await fetch(`${API_BASE}/timetable/lessons/${id}`, {
|
||||||
method: 'PATCH',
|
method: 'PATCH',
|
||||||
|
|||||||
@@ -29,112 +29,106 @@ export class CCPlannedLessonNodeShapeUtil extends CCBaseShapeUtil<CCPlannedLesso
|
|||||||
|
|
||||||
renderContent = (shape: CCPlannedLessonNodeShape) => {
|
renderContent = (shape: CCPlannedLessonNodeShape) => {
|
||||||
const styles = getNodeStyles(shape.type)
|
const styles = getNodeStyles(shape.type)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div style={styles.container}>
|
<div style={styles.container}>
|
||||||
<NodeProperty
|
<NodeProperty
|
||||||
label="Subject Class"
|
label="Subject Class"
|
||||||
value={shape.props.subject_class}
|
value={shape.props.subject_class}
|
||||||
labelStyle={styles.property.label}
|
labelStyle={styles.property.label}
|
||||||
valueStyle={styles.property.value}
|
valueStyle={styles.property.value}
|
||||||
/>
|
/>
|
||||||
<NodeProperty
|
<NodeProperty
|
||||||
label="Date"
|
label="Date"
|
||||||
value={shape.props.date}
|
value={shape.props.date}
|
||||||
labelStyle={styles.property.label}
|
labelStyle={styles.property.label}
|
||||||
valueStyle={styles.property.value}
|
valueStyle={styles.property.value}
|
||||||
/>
|
/>
|
||||||
<NodeProperty
|
<NodeProperty
|
||||||
label="Start Time"
|
label="Start Time"
|
||||||
value={shape.props.start_time}
|
value={shape.props.start_time}
|
||||||
labelStyle={styles.property.label}
|
labelStyle={styles.property.label}
|
||||||
valueStyle={styles.property.value}
|
valueStyle={styles.property.value}
|
||||||
/>
|
/>
|
||||||
<NodeProperty
|
<NodeProperty
|
||||||
label="End Time"
|
label="End Time"
|
||||||
value={shape.props.end_time}
|
value={shape.props.end_time}
|
||||||
labelStyle={styles.property.label}
|
labelStyle={styles.property.label}
|
||||||
valueStyle={styles.property.value}
|
valueStyle={styles.property.value}
|
||||||
/>
|
/>
|
||||||
<NodeProperty
|
<NodeProperty
|
||||||
label="Period Code"
|
label="Period Code"
|
||||||
value={shape.props.period_code}
|
value={shape.props.period_code}
|
||||||
labelStyle={styles.property.label}
|
labelStyle={styles.property.label}
|
||||||
valueStyle={styles.property.value}
|
valueStyle={styles.property.value}
|
||||||
/>
|
/>
|
||||||
<NodeProperty
|
<NodeProperty
|
||||||
label="Year Group"
|
label="Year Group"
|
||||||
value={shape.props.year_group}
|
value={shape.props.year_group}
|
||||||
labelStyle={styles.property.label}
|
labelStyle={styles.property.label}
|
||||||
valueStyle={styles.property.value}
|
valueStyle={styles.property.value}
|
||||||
/>
|
/>
|
||||||
<NodeProperty
|
<NodeProperty
|
||||||
label="Subject"
|
label="Subject"
|
||||||
value={shape.props.subject}
|
value={shape.props.subject}
|
||||||
labelStyle={styles.property.label}
|
labelStyle={styles.property.label}
|
||||||
valueStyle={styles.property.value}
|
valueStyle={styles.property.value}
|
||||||
/>
|
/>
|
||||||
<NodeProperty
|
<NodeProperty
|
||||||
label="Teacher Code"
|
label="Teacher Code"
|
||||||
value={shape.props.teacher_code}
|
value={shape.props.teacher_code}
|
||||||
labelStyle={styles.property.label}
|
labelStyle={styles.property.label}
|
||||||
valueStyle={styles.property.value}
|
valueStyle={styles.property.value}
|
||||||
/>
|
/>
|
||||||
<NodeProperty
|
<NodeProperty
|
||||||
label="Planning Status"
|
label="Planning Status"
|
||||||
value={shape.props.planning_status}
|
value={shape.props.planning_status}
|
||||||
labelStyle={styles.property.label}
|
labelStyle={styles.property.label}
|
||||||
valueStyle={styles.property.value}
|
valueStyle={styles.property.value}
|
||||||
/>
|
/>
|
||||||
<NodeProperty
|
<NodeProperty
|
||||||
label="Homework"
|
|
||||||
value={shape.props.homework}
|
|
||||||
labelStyle={styles.property.label}
|
|
||||||
valueStyle={styles.property.value}
|
|
||||||
/>
|
|
||||||
<NodeProperty
|
|
||||||
label="Topic Code"
|
label="Topic Code"
|
||||||
value={shape.props.topic_code}
|
value={shape.props.topic_code}
|
||||||
labelStyle={styles.property.label}
|
labelStyle={styles.property.label}
|
||||||
valueStyle={styles.property.value}
|
valueStyle={styles.property.value}
|
||||||
/>
|
/>
|
||||||
<NodeProperty
|
<NodeProperty
|
||||||
label="Topic Name"
|
label="Topic Name"
|
||||||
value={shape.props.topic_name}
|
value={shape.props.topic_name}
|
||||||
labelStyle={styles.property.label}
|
labelStyle={styles.property.label}
|
||||||
valueStyle={styles.property.value}
|
valueStyle={styles.property.value}
|
||||||
/>
|
/>
|
||||||
<NodeProperty
|
<NodeProperty
|
||||||
label="Lesson Code"
|
label="Lesson Code"
|
||||||
value={shape.props.lesson_code}
|
value={shape.props.lesson_code}
|
||||||
labelStyle={styles.property.label}
|
labelStyle={styles.property.label}
|
||||||
valueStyle={styles.property.value}
|
valueStyle={styles.property.value}
|
||||||
/>
|
/>
|
||||||
<NodeProperty
|
<NodeProperty
|
||||||
label="Lesson Name"
|
label="Lesson Name"
|
||||||
value={shape.props.lesson_name}
|
value={shape.props.lesson_name}
|
||||||
labelStyle={styles.property.label}
|
labelStyle={styles.property.label}
|
||||||
valueStyle={styles.property.value}
|
valueStyle={styles.property.value}
|
||||||
/>
|
/>
|
||||||
<NodeProperty
|
<NodeProperty
|
||||||
label="Learning Statement Codes"
|
label="Learning Statement Codes"
|
||||||
value={shape.props.learning_statement_codes}
|
value={shape.props.learning_statement_codes}
|
||||||
labelStyle={styles.property.label}
|
labelStyle={styles.property.label}
|
||||||
valueStyle={styles.property.value}
|
valueStyle={styles.property.value}
|
||||||
/>
|
/>
|
||||||
<NodeProperty
|
<NodeProperty
|
||||||
label="Learning Statements"
|
label="Learning Statements"
|
||||||
value={shape.props.learning_statements}
|
value={shape.props.learning_statements}
|
||||||
labelStyle={styles.property.label}
|
labelStyle={styles.property.label}
|
||||||
valueStyle={styles.property.value}
|
valueStyle={styles.property.value}
|
||||||
/>
|
/>
|
||||||
<NodeProperty
|
<NodeProperty
|
||||||
label="Learning Resource Codes"
|
label="Learning Resource Codes"
|
||||||
value={shape.props.learning_resource_codes}
|
value={shape.props.learning_resource_codes}
|
||||||
labelStyle={styles.property.label}
|
labelStyle={styles.property.label}
|
||||||
valueStyle={styles.property.value}
|
valueStyle={styles.property.value}
|
||||||
/>
|
/>
|
||||||
<NodeProperty
|
<NodeProperty
|
||||||
label="Learning Resources"
|
label="Learning Resources"
|
||||||
value={shape.props.learning_resources}
|
value={shape.props.learning_resources}
|
||||||
labelStyle={styles.property.label}
|
labelStyle={styles.property.label}
|
||||||
@@ -143,4 +137,4 @@ export class CCPlannedLessonNodeShapeUtil extends CCBaseShapeUtil<CCPlannedLesso
|
|||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -5,8 +5,7 @@ import { CCGraphShape, GraphShapeType } from './cc-graph-types'
|
|||||||
// Helper function to create version IDs for a shape type
|
// Helper function to create version IDs for a shape type
|
||||||
const createVersions = (shapeType: GraphShapeType) => {
|
const createVersions = (shapeType: GraphShapeType) => {
|
||||||
return createShapePropsMigrationIds(shapeType, {
|
return createShapePropsMigrationIds(shapeType, {
|
||||||
Initial: 1, // All shapes start at version 1 as required by TLDraw
|
Initial: 1 // All shapes start at version 1 as required by TLDraw
|
||||||
AddPlannedLessonHomework: 2,
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -22,13 +21,6 @@ const createMigrationSequence = (shapeType: GraphShapeType) => {
|
|||||||
return props
|
return props
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
...(shapeType === 'cc-planned-lesson-node' ? [{
|
|
||||||
id: versions.AddPlannedLessonHomework,
|
|
||||||
up: (props: CCGraphShape['props']) => ({
|
|
||||||
homework: '',
|
|
||||||
...props,
|
|
||||||
}),
|
|
||||||
}] : []),
|
|
||||||
],
|
],
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -199,7 +199,6 @@ export const ccGraphShapeProps = {
|
|||||||
subject: T.string,
|
subject: T.string,
|
||||||
teacher_code: T.string,
|
teacher_code: T.string,
|
||||||
planning_status: T.string,
|
planning_status: T.string,
|
||||||
homework: T.string,
|
|
||||||
topic_code: T.string,
|
topic_code: T.string,
|
||||||
topic_name: T.string,
|
topic_name: T.string,
|
||||||
lesson_code: T.string,
|
lesson_code: T.string,
|
||||||
@@ -561,7 +560,6 @@ export const getDefaultCCPlannedLessonNodeProps = () => ({
|
|||||||
subject: '',
|
subject: '',
|
||||||
teacher_code: '',
|
teacher_code: '',
|
||||||
planning_status: '',
|
planning_status: '',
|
||||||
homework: '',
|
|
||||||
topic_code: '',
|
topic_code: '',
|
||||||
topic_name: '',
|
topic_name: '',
|
||||||
lesson_code: '',
|
lesson_code: '',
|
||||||
|
|||||||
@@ -204,7 +204,6 @@ export type CCPlannedLessonNodeProps = CCGraphShapeProps & {
|
|||||||
subject: string
|
subject: string
|
||||||
teacher_code: string
|
teacher_code: string
|
||||||
planning_status: string
|
planning_status: string
|
||||||
homework: string
|
|
||||||
topic_code: string
|
topic_code: string
|
||||||
topic_name: string
|
topic_name: string
|
||||||
lesson_code: string
|
lesson_code: string
|
||||||
|
|||||||
Reference in New Issue
Block a user