diff --git a/backend/src/controllers/assignmentController.js b/backend/src/controllers/assignmentController.js index 3920250..b635729 100644 --- a/backend/src/controllers/assignmentController.js +++ b/backend/src/controllers/assignmentController.js @@ -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, diff --git a/backend/src/routes/classes.js b/backend/src/routes/classes.js index f6d0794..059bc7a 100644 --- a/backend/src/routes/classes.js +++ b/backend/src/routes/classes.js @@ -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.get('/:id/journal', requireRole('teacher','admin'), ctrl.classJournal); 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.delete('/:id/members/:uid', requireRole('teacher','admin'), ctrl.kickMember); router.post('/:id/assignments', requireRole('teacher','admin'), validate(assignmentSchema), assignCtrl.createAssignment); diff --git a/frontend/classes.html b/frontend/classes.html index 6f0ef5e..3f01e5f 100644 --- a/frontend/classes.html +++ b/frontend/classes.html @@ -109,6 +109,29 @@ .deadline-soon { background: rgba(255,179,71,0.12); color: var(--amber); } .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-wrap { position: relative; flex: 1; max-width: 360px; } .student-search-wrap .form-input { width: 100%; } @@ -628,6 +651,7 @@ + @@ -667,6 +691,11 @@
+ +
+
+
+
@@ -1199,6 +1228,102 @@ }).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 = '
'; + try { + _debtData = await LS.classOutstanding(currentClass.id); + renderDebts(); + } catch (e) { + el.innerHTML = `
Не удалось загрузить: ${esc(e.message || '')}
`; + } + } + + function debtChips(c) { + return ['overdue', 'in_progress', 'revision', 'not_started'] + .filter(k => c[k] > 0) + .map(k => `${DEBT_STATUS[k].label}: ${c[k]}`) + .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 ? `${p.status === 'overdue' ? 'просрочено ' : 'до '}${fmtDate(p.deadline)}` : ''; + const scopeTag = p.scope === 'direct' ? 'личное' : ''; + return `
+ +
+
${esc(p.title)}
+
${st.label}${dl}${scopeTag}
+
+ +
`; + } + + 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 = `
Задолженностей нет — все ученики всё сдали
`; + if (window.lucide) lucide.createIcons(); + return; + } + let html = `
+ Должников: ${summary.debtors} из ${summary.students_total} + Просроченных позиций: ${summary.overdue} +
`; + html += withDebt.map(s => ` +
+
+
${esc(s.name)}
${esc(s.email || '')}
+
${debtChips(s.counts)}
+
+ ${s.pending.map(p => debtItemHtml(s, p)).join('')} +
`).join(''); + const clear = summary.students_total - withDebt.length; + if (clear > 0) html += `
Остальные ${clear} ученик(ов) — без задолженностей.
`; + 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 ══ */ function switchDetailTab(btn) { const name = btn.dataset.tab; @@ -1208,6 +1333,7 @@ document.getElementById('dtab-' + name).classList.add('active'); if (name === 'announce') loadAnnouncements(); if (name === 'dash') loadClassDashboard(); + if (name === 'debts') loadDebts(); if (name === 'journal') loadJournal(); if (name === 'settings') loadSettings(); if (name === 'works') loadClassWorks(); diff --git a/js/api.js b/js/api.js index 91e1a7e..e964401 100644 --- a/js/api.js +++ b/js/api.js @@ -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 regenerateInviteCode(classId) { return req('POST', `/classes/${classId}/new-code`); } 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 createDirectAssignment(data) { return req('POST', '/assignments', 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, getQuestions, createQuestion, duplicateQuestion, updateQuestion, deleteQuestion, importQuestions, getClasses, createClass, getClassDetail, updateClass, deleteClass, kickMember, addClassMember, createAssignment, createDirectAssignment, updateAssignment, deleteAssignment, - regenerateInviteCode, classJournal, + regenerateInviteCode, classJournal, classOutstanding, joinClass, myClasses, getStudents, classFeed, getAnnouncements, createAnnouncement, deleteAnnouncement, getNotifications, markNotifRead, markAllNotifsRead, connectSSE,