feat(classes): вкладка «Долги» — что висит у учеников + удаление ДЗ класса/ученика

Новый read-only эндпоинт GET /api/classes/:id/outstanding (teacher/admin, ownership):
по каждому ученику класса — его незакрытые задания (классовые + личные от этого учителя)
со статусом не начато / в процессе / на доработке / просрочено и дедлайном. Логика «сдано»
совпадает с /homework (тест — завершён/исчерпаны попытки; учебник — всё прочитано;
загрузка/файл — есть сдача не на доработке). Общий запрос вынесен в assignmentRowsForUser(uid)
— им же теперь питается /assignments/my (поведение не изменилось, +поле created_by).

Фронт (classes.html): вкладка «Долги» в карточке класса — сводка «должников X из Y,
просрочено Z», по каждому должнику карточка со статус-чипами и списком зависших заданий;
бейдж с числом просрочек на вкладке. Удаление прямо из списка: личное → «у ученика»,
классовое → «у всего класса» (через DELETE /assignments/:id, ownership на бэке).

Verified: classOutstanding смоук на живой БД (14 учеников/58 позиций/42 просрочено,
ownership 403/404/admin); node --check; lint:routes 0.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Maxim Dolgolyov
2026-06-23 13:46:45 +03:00
parent 73ba5a3530
commit 5a4bc48027
4 changed files with 221 additions and 5 deletions
@@ -256,9 +256,9 @@ function teacherAssignments(req, res) {
res.json(rows);
}
/* ── GET /api/assignments/my ── student: all pending/done assignments ──── */
function myAssignments(req, res) {
const uid = req.user.id;
/* Собрать все задания пользователя (классовые + личные) с вычисленным статусом.
Переиспользуется в /assignments/my и в обзоре задолженностей класса. */
function assignmentRowsForUser(uid) {
const rows = db.prepare(`
SELECT * FROM (
SELECT a.id, a.title, a.subject_slug, a.mode, a.count, a.deadline, a.created_at,
@@ -267,6 +267,7 @@ function myAssignments(req, res) {
tb.slug AS textbook_slug, tb.title AS textbook_title, tb.color AS textbook_color, tb.para_count AS textbook_para_count,
tp.paragraphs_read AS textbook_read,
c.name AS class_name, c.id AS class_id, u.name AS teacher_name,
a.created_by AS created_by,
latest.session_id,
ts.score, ts.total, ts.status AS session_status,
ROUND(CAST(ts.score AS REAL) / ts.total * 100) AS percent,
@@ -295,6 +296,7 @@ function myAssignments(req, res) {
tb.slug AS textbook_slug, tb.title AS textbook_title, tb.color AS textbook_color, tb.para_count AS textbook_para_count,
tp.paragraphs_read AS textbook_read,
'Личное задание' AS class_name, 0 AS class_id, u.name AS teacher_name,
a.created_by AS created_by,
latest.session_id,
ts.score, ts.total, ts.status AS session_status,
ROUND(CAST(ts.score AS REAL) / ts.total * 100) AS percent,
@@ -334,8 +336,93 @@ function myAssignments(req, res) {
// Strip raw paragraphs_read JSON from response (not needed by client)
delete r.textbook_read;
}
return rows;
}
res.json(rows);
/* ── GET /api/assignments/my ── student: all pending/done assignments ──── */
function myAssignments(req, res) {
res.json(assignmentRowsForUser(req.user.id));
}
/* Тип задания — те же признаки, что на /homework (asgnType). */
function _assignTypeOf(r) {
if (r.textbook_id) return 'textbook';
if (r.file_id) return 'file';
if (r.is_homework && (r.count == null || r.count <= 1) && (!r.subject_slug || r.subject_slug === 'other')) return 'upload';
return 'test';
}
/* ── GET /api/classes/:id/outstanding ── что «висит» у каждого ученика класса ──
Учитель/админ видят по каждому ученику его НЕзакрытые задания (классовые + личные
от этого учителя) со статусом: не начато / в процессе / на доработке / просрочено. */
function classOutstanding(req, res) {
const cid = req.params.id;
const cls = db.prepare('SELECT id, name, teacher_id FROM classes WHERE id = ?').get(cid);
if (!cls) return res.status(404).json({ error: 'Not found' });
if (req.user.role !== 'admin' && cls.teacher_id !== req.user.id)
return res.status(403).json({ error: 'Forbidden' });
const members = db.prepare(`
SELECT u.id, u.name, u.email FROM class_members cm
JOIN users u ON u.id = cm.user_id WHERE cm.class_id = ? ORDER BY u.name
`).all(cid);
// Последняя сдача по (задание, ученик) в этом классе — для upload/file done-статуса.
const subRows = db.prepare(`
SELECT s.assignment_id, s.student_id, s.status
FROM submissions s
JOIN (SELECT assignment_id, student_id, MAX(id) AS mid FROM submissions
WHERE class_id = ? GROUP BY assignment_id, student_id) last ON last.mid = s.id
`).all(cid);
const subMap = new Map();
for (const s of subRows) subMap.set(s.assignment_id + '_' + s.student_id, s.status);
const now = Date.now();
const cidNum = Number(cid);
const RANK = { overdue: 0, revision: 1, in_progress: 2, not_started: 3 };
const students = members.map(m => {
// Только задания ЭТОГО класса + личные, созданные учителем этого класса.
const rows = assignmentRowsForUser(m.id).filter(r =>
r.class_id === cidNum || (r.class_id === 0 && r.created_by === cls.teacher_id)
);
const pending = [];
for (const r of rows) {
const type = _assignTypeOf(r);
const overdue = r.deadline && new Date(r.deadline).getTime() < now;
let done = false, status = overdue ? 'overdue' : 'not_started';
if (type === 'textbook') {
done = !!(r.textbook_all_read || r.completed_at);
} else if (type === 'upload' || type === 'file') {
const st = subMap.get(r.id + '_' + m.id);
if (st && st !== 'revision') done = true; // сдал — на проверке/принято
else if (st === 'revision') status = 'revision'; // вернули на доработку
} else { // test
const maxAtt = r.max_attempts || 0, used = r.attempts_used || 0;
if (maxAtt > 0 && used >= maxAtt) done = true;
else if (r.session_status === 'completed' && r.mode !== 'repeat') done = true;
else if (r.session_status === 'in_progress') status = 'in_progress';
}
if (!done) pending.push({
assignment_id: r.id, title: r.title, type, deadline: r.deadline,
status, is_homework: r.is_homework ? 1 : 0,
scope: r.class_id === cidNum ? 'class' : 'direct',
});
}
pending.sort((a, b) => (RANK[a.status] - RANK[b.status]) ||
((a.deadline ? new Date(a.deadline).getTime() : Infinity) -
(b.deadline ? new Date(b.deadline).getTime() : Infinity)));
const counts = { total: pending.length, overdue: 0, in_progress: 0, not_started: 0, revision: 0 };
pending.forEach(p => { counts[p.status]++; });
return { id: m.id, name: m.name, email: m.email, pending, counts };
});
const summary = {
students_total: members.length,
debtors: students.filter(s => s.counts.total > 0).length,
overdue: students.reduce((a, s) => a + s.counts.overdue, 0),
};
res.json({ className: cls.name, summary, students });
}
/* Parse "1-5", "1,3,7", "1-3,5,7-9" → [1,2,3,4,5] or [1,3,7] etc.
@@ -732,6 +819,7 @@ module.exports = {
deleteAssignment,
teacherAssignments,
myAssignments,
classOutstanding,
startAssignment,
assignmentResults,
assignmentQuestionStats,