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); res.json(rows);
} }
/* ── GET /api/assignments/my ── student: all pending/done assignments ──── */ /* Собрать все задания пользователя (классовые + личные) с вычисленным статусом.
function myAssignments(req, res) { Переиспользуется в /assignments/my и в обзоре задолженностей класса. */
const uid = req.user.id; function assignmentRowsForUser(uid) {
const rows = db.prepare(` const rows = db.prepare(`
SELECT * FROM ( SELECT * FROM (
SELECT a.id, a.title, a.subject_slug, a.mode, a.count, a.deadline, a.created_at, 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, 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, tp.paragraphs_read AS textbook_read,
c.name AS class_name, c.id AS class_id, u.name AS teacher_name, c.name AS class_name, c.id AS class_id, u.name AS teacher_name,
a.created_by AS created_by,
latest.session_id, latest.session_id,
ts.score, ts.total, ts.status AS session_status, ts.score, ts.total, ts.status AS session_status,
ROUND(CAST(ts.score AS REAL) / ts.total * 100) AS percent, 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, 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, tp.paragraphs_read AS textbook_read,
'Личное задание' AS class_name, 0 AS class_id, u.name AS teacher_name, 'Личное задание' AS class_name, 0 AS class_id, u.name AS teacher_name,
a.created_by AS created_by,
latest.session_id, latest.session_id,
ts.score, ts.total, ts.status AS session_status, ts.score, ts.total, ts.status AS session_status,
ROUND(CAST(ts.score AS REAL) / ts.total * 100) AS percent, 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) // Strip raw paragraphs_read JSON from response (not needed by client)
delete r.textbook_read; 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. /* 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, deleteAssignment,
teacherAssignments, teacherAssignments,
myAssignments, myAssignments,
classOutstanding,
startAssignment, startAssignment,
assignmentResults, assignmentResults,
assignmentQuestionStats, assignmentQuestionStats,
+1
View File
@@ -37,6 +37,7 @@ router.delete('/:id', requireRole('teacher','admin'), requirePermission('
router.post('/:id/new-code', requireRole('teacher','admin'), requirePermission('classes.manage'), ctrl.regenerateCode); router.post('/:id/new-code', requireRole('teacher','admin'), requirePermission('classes.manage'), ctrl.regenerateCode);
router.get('/:id/journal', requireRole('teacher','admin'), ctrl.classJournal); router.get('/:id/journal', requireRole('teacher','admin'), ctrl.classJournal);
router.get('/:id/journal/csv', requireRole('teacher','admin'), ctrl.classJournalCsv); router.get('/:id/journal/csv', requireRole('teacher','admin'), ctrl.classJournalCsv);
router.get('/:id/outstanding', requireRole('teacher','admin'), assignCtrl.classOutstanding);
router.post('/:id/members', requireRole('teacher','admin'), ctrl.addMember); router.post('/:id/members', requireRole('teacher','admin'), ctrl.addMember);
router.delete('/:id/members/:uid', requireRole('teacher','admin'), ctrl.kickMember); router.delete('/:id/members/:uid', requireRole('teacher','admin'), ctrl.kickMember);
router.post('/:id/assignments', requireRole('teacher','admin'), validate(assignmentSchema), assignCtrl.createAssignment); router.post('/:id/assignments', requireRole('teacher','admin'), validate(assignmentSchema), assignCtrl.createAssignment);
+126
View File
@@ -109,6 +109,29 @@
.deadline-soon { background: rgba(255,179,71,0.12); color: var(--amber); } .deadline-soon { background: rgba(255,179,71,0.12); color: var(--amber); }
.deadline-over { background: rgba(241,91,181,0.1); color: var(--pink); } .deadline-over { background: rgba(241,91,181,0.1); color: var(--pink); }
/* ── Долги (что висит у учеников) ── */
.debt-summary { display: flex; gap: 16px; flex-wrap: wrap; margin-bottom: 16px; font-size: 0.84rem; color: var(--text-2); }
.debt-summary b { color: var(--text); font-family: 'Unbounded', sans-serif; }
.debt-card { border: 1px solid var(--border); border-radius: 14px; padding: 14px 18px; margin-bottom: 12px; }
.debt-card.has-over { border-color: rgba(241,91,181,0.35); }
.debt-head { display: flex; align-items: center; gap: 10px; flex-wrap: wrap; margin-bottom: 10px; }
.debt-name { font-size: 0.9rem; font-weight: 700; }
.debt-email { font-size: 0.74rem; color: var(--text-3); }
.debt-chips { display: flex; gap: 6px; flex-wrap: wrap; margin-left: auto; }
.debt-chip { font-size: 0.68rem; font-weight: 700; padding: 2px 9px; border-radius: var(--r-pill); white-space: nowrap; }
.dc-overdue { background: rgba(241,91,181,0.12); color: var(--pink); }
.dc-in_progress { background: rgba(255,179,71,0.14); color: var(--amber); }
.dc-revision { background: rgba(245,158,11,0.14); color: #d97706; }
.dc-not_started { background: rgba(15,23,42,0.06); color: var(--text-3); }
.debt-item { display: flex; align-items: center; gap: 10px; padding: 8px 0; border-top: 1px solid var(--border); }
.debt-item-title { font-size: 0.82rem; font-weight: 600; flex: 1; min-width: 0; }
.debt-item-meta { font-size: 0.72rem; color: var(--text-3); display: flex; gap: 8px; align-items: center; flex-wrap: wrap; margin-top: 2px; }
.debt-del { border: none; background: transparent; color: var(--text-3); cursor: pointer; padding: 5px; border-radius: 8px; flex-shrink: 0; }
.debt-del:hover { background: rgba(241,91,181,0.1); color: var(--pink); }
.debt-allclear { text-align: center; padding: 40px 20px; color: var(--green); font-weight: 600; }
.debt-rest { font-size: 0.78rem; color: var(--text-3); margin-top: 8px; }
.tab-badge { display: inline-flex; align-items: center; justify-content: center; min-width: 18px; height: 18px; padding: 0 5px; border-radius: 9px; background: var(--pink); color: #fff; font-size: 0.68rem; font-weight: 800; vertical-align: 1px; }
/* ── Student search ── */ /* ── Student search ── */
.student-search-wrap { position: relative; flex: 1; max-width: 360px; } .student-search-wrap { position: relative; flex: 1; max-width: 360px; }
.student-search-wrap .form-input { width: 100%; } .student-search-wrap .form-input { width: 100%; }
@@ -628,6 +651,7 @@
<button class="tab-btn active" data-tab="dash" onclick="switchDetailTab(this)">Дашборд</button> <button class="tab-btn active" data-tab="dash" onclick="switchDetailTab(this)">Дашборд</button>
<button class="tab-btn" data-tab="members" onclick="switchDetailTab(this)">Ученики</button> <button class="tab-btn" data-tab="members" onclick="switchDetailTab(this)">Ученики</button>
<button class="tab-btn" data-tab="assign" onclick="switchDetailTab(this)">Задания</button> <button class="tab-btn" data-tab="assign" onclick="switchDetailTab(this)">Задания</button>
<button class="tab-btn" data-tab="debts" onclick="switchDetailTab(this)">Долги <span class="tab-badge" id="debts-tab-badge" style="display:none"></span></button>
<button class="tab-btn" data-tab="journal" onclick="switchDetailTab(this)">Журнал</button> <button class="tab-btn" data-tab="journal" onclick="switchDetailTab(this)">Журнал</button>
<button class="tab-btn" data-tab="announce" onclick="switchDetailTab(this)">Объявления</button> <button class="tab-btn" data-tab="announce" onclick="switchDetailTab(this)">Объявления</button>
<button class="tab-btn" data-tab="works" onclick="switchDetailTab(this)"><i data-lucide="paperclip" style="width:13px;height:13px;vertical-align:-2px"></i> Работы</button> <button class="tab-btn" data-tab="works" onclick="switchDetailTab(this)"><i data-lucide="paperclip" style="width:13px;height:13px;vertical-align:-2px"></i> Работы</button>
@@ -667,6 +691,11 @@
<div class="assign-list" id="d-assignments"></div> <div class="assign-list" id="d-assignments"></div>
</div> </div>
<!-- Debts (что висит у учеников) -->
<div class="tab-pane" id="dtab-debts">
<div id="debts-content"><div class="spinner"></div></div>
</div>
<!-- Journal --> <!-- Journal -->
<div class="tab-pane" id="dtab-journal"> <div class="tab-pane" id="dtab-journal">
<div id="journal-content"><div class="spinner"></div></div> <div id="journal-content"><div class="spinner"></div></div>
@@ -1199,6 +1228,102 @@
}).join(''); }).join('');
} }
/* ══ Долги: что висит у учеников (классовые + личные задания) ══ */
const DEBT_STATUS = {
overdue: { label: 'просрочено', cls: 'dc-overdue' },
in_progress: { label: 'в процессе', cls: 'dc-in_progress' },
revision: { label: 'на доработке', cls: 'dc-revision' },
not_started: { label: 'не начато', cls: 'dc-not_started' },
};
const DEBT_TYPE_ICON = { test: 'clipboard-list', upload: 'upload', file: 'paperclip', textbook: 'book-open' };
let _debtData = null;
async function loadDebts() {
if (!currentClass) return;
const el = document.getElementById('debts-content');
el.innerHTML = '<div class="spinner"></div>';
try {
_debtData = await LS.classOutstanding(currentClass.id);
renderDebts();
} catch (e) {
el.innerHTML = `<div class="empty">Не удалось загрузить: ${esc(e.message || '')}</div>`;
}
}
function debtChips(c) {
return ['overdue', 'in_progress', 'revision', 'not_started']
.filter(k => c[k] > 0)
.map(k => `<span class="debt-chip ${DEBT_STATUS[k].cls}">${DEBT_STATUS[k].label}: ${c[k]}</span>`)
.join('');
}
function debtItemHtml(s, p) {
const st = DEBT_STATUS[p.status] || DEBT_STATUS.not_started;
const icon = DEBT_TYPE_ICON[p.type] || 'file-text';
const dl = p.deadline ? `<span>${p.status === 'overdue' ? 'просрочено ' : 'до '}${fmtDate(p.deadline)}</span>` : '';
const scopeTag = p.scope === 'direct' ? '<span style="color:var(--violet)">личное</span>' : '';
return `<div class="debt-item">
<i data-lucide="${icon}" style="width:15px;height:15px;color:var(--text-3);flex-shrink:0"></i>
<div style="flex:1;min-width:0">
<div class="debt-item-title">${esc(p.title)}</div>
<div class="debt-item-meta"><span class="debt-chip ${st.cls}">${st.label}</span>${dl}${scopeTag}</div>
</div>
<button class="debt-del" title="Удалить задание" onclick="deleteDebtAssignment(${p.assignment_id},'${p.scope}',${s.id})"><i data-lucide="trash-2" style="width:15px;height:15px"></i></button>
</div>`;
}
function renderDebts() {
const el = document.getElementById('debts-content');
if (!_debtData) { el.innerHTML = ''; return; }
const { summary, students } = _debtData;
const withDebt = students.filter(s => s.counts.total > 0);
const badge = document.getElementById('debts-tab-badge');
if (badge) {
if (summary.overdue > 0) { badge.textContent = summary.overdue; badge.style.display = ''; }
else badge.style.display = 'none';
}
if (!withDebt.length) {
el.innerHTML = `<div class="debt-allclear"><i data-lucide="check-circle-2" style="width:36px;height:36px"></i><div style="margin-top:8px">Задолженностей нет — все ученики всё сдали</div></div>`;
if (window.lucide) lucide.createIcons();
return;
}
let html = `<div class="debt-summary">
<span>Должников: <b>${summary.debtors}</b> из ${summary.students_total}</span>
<span>Просроченных позиций: <b style="color:var(--pink)">${summary.overdue}</b></span>
</div>`;
html += withDebt.map(s => `
<div class="debt-card${s.counts.overdue > 0 ? ' has-over' : ''}">
<div class="debt-head">
<div><div class="debt-name">${esc(s.name)}</div><div class="debt-email">${esc(s.email || '')}</div></div>
<div class="debt-chips">${debtChips(s.counts)}</div>
</div>
${s.pending.map(p => debtItemHtml(s, p)).join('')}
</div>`).join('');
const clear = summary.students_total - withDebt.length;
if (clear > 0) html += `<div class="debt-rest">Остальные ${clear} ученик(ов) — без задолженностей.</div>`;
el.innerHTML = html;
if (window.lucide) lucide.createIcons();
}
async function deleteDebtAssignment(id, scope, studentId) {
let title = 'задание', studentName = '';
const stu = _debtData && _debtData.students.find(s => s.id === studentId);
if (stu) {
studentName = stu.name;
const it = stu.pending.find(p => p.assignment_id === id);
if (it) title = it.title;
}
const msg = scope === 'direct'
? `Удалить персональное задание «${title}» у ученика ${studentName}?`
: `Удалить задание «${title}» у ВСЕГО класса? Оно исчезнет у всех учеников.`;
if (!await LS.confirm(msg, { title: 'Удалить задание', confirmText: 'Удалить', danger: true })) return;
try {
await LS.deleteAssignment(id);
LS.toast('Задание удалено', 'info');
await loadDebts();
} catch (e) { LS.toast(e.message || 'Ошибка', 'error'); }
}
/* ══ Detail tabs ══ */ /* ══ Detail tabs ══ */
function switchDetailTab(btn) { function switchDetailTab(btn) {
const name = btn.dataset.tab; const name = btn.dataset.tab;
@@ -1208,6 +1333,7 @@
document.getElementById('dtab-' + name).classList.add('active'); document.getElementById('dtab-' + name).classList.add('active');
if (name === 'announce') loadAnnouncements(); if (name === 'announce') loadAnnouncements();
if (name === 'dash') loadClassDashboard(); if (name === 'dash') loadClassDashboard();
if (name === 'debts') loadDebts();
if (name === 'journal') loadJournal(); if (name === 'journal') loadJournal();
if (name === 'settings') loadSettings(); if (name === 'settings') loadSettings();
if (name === 'works') loadClassWorks(); if (name === 'works') loadClassWorks();
+2 -1
View File
@@ -190,6 +190,7 @@ async function deleteClass(id) { return req('DELETE', `/classes/
async function kickMember(classId, userId) { return req('DELETE', `/classes/${classId}/members/${userId}`); } async function kickMember(classId, userId) { return req('DELETE', `/classes/${classId}/members/${userId}`); }
async function regenerateInviteCode(classId) { return req('POST', `/classes/${classId}/new-code`); } async function regenerateInviteCode(classId) { return req('POST', `/classes/${classId}/new-code`); }
async function classJournal(classId) { return req('GET', `/classes/${classId}/journal`); } async function classJournal(classId) { return req('GET', `/classes/${classId}/journal`); }
async function classOutstanding(classId) { return req('GET', `/classes/${classId}/outstanding`); }
async function createAssignment(classId, data) { return req('POST', `/classes/${classId}/assignments`, data); } async function createAssignment(classId, data) { return req('POST', `/classes/${classId}/assignments`, data); }
async function createDirectAssignment(data) { return req('POST', '/assignments', data); } async function createDirectAssignment(data) { return req('POST', '/assignments', data); }
async function updateAssignment(id, data) { return req('PUT', `/assignments/${id}`, data); } async function updateAssignment(id, data) { return req('PUT', `/assignments/${id}`, data); }
@@ -1125,7 +1126,7 @@ window.LS = {
adminGetStats, adminGetOverview, adminGlobalSearch, adminGetUsers, adminUpdateRole, adminGetUserSessions, adminGetSessions, adminGetSessionDetail, adminDeleteSession, adminClearUserSessions, adminUpdateUser, adminBanUser, adminDeleteUser, adminGetStats, adminGetOverview, adminGlobalSearch, adminGetUsers, adminUpdateRole, adminGetUserSessions, adminGetSessions, adminGetSessionDetail, adminDeleteSession, adminClearUserSessions, adminUpdateUser, adminBanUser, adminDeleteUser,
getQuestions, createQuestion, duplicateQuestion, updateQuestion, deleteQuestion, importQuestions, getQuestions, createQuestion, duplicateQuestion, updateQuestion, deleteQuestion, importQuestions,
getClasses, createClass, getClassDetail, updateClass, deleteClass, kickMember, addClassMember, createAssignment, createDirectAssignment, updateAssignment, deleteAssignment, getClasses, createClass, getClassDetail, updateClass, deleteClass, kickMember, addClassMember, createAssignment, createDirectAssignment, updateAssignment, deleteAssignment,
regenerateInviteCode, classJournal, regenerateInviteCode, classJournal, classOutstanding,
joinClass, myClasses, getStudents, classFeed, joinClass, myClasses, getStudents, classFeed,
getAnnouncements, createAnnouncement, deleteAnnouncement, getAnnouncements, createAnnouncement, deleteAnnouncement,
getNotifications, markNotifRead, markAllNotifsRead, connectSSE, getNotifications, markNotifRead, markAllNotifsRead, connectSSE,