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:
@@ -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 @@
|
||||
<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="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="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>
|
||||
@@ -667,6 +691,11 @@
|
||||
<div class="assign-list" id="d-assignments"></div>
|
||||
</div>
|
||||
|
||||
<!-- Debts (что висит у учеников) -->
|
||||
<div class="tab-pane" id="dtab-debts">
|
||||
<div id="debts-content"><div class="spinner"></div></div>
|
||||
</div>
|
||||
|
||||
<!-- Journal -->
|
||||
<div class="tab-pane" id="dtab-journal">
|
||||
<div id="journal-content"><div class="spinner"></div></div>
|
||||
@@ -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 = '<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 ══ */
|
||||
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();
|
||||
|
||||
Reference in New Issue
Block a user