Files
Maxim Dolgolyov 92030b462c feat(admin): phase 2 — split admin.js into 13 section modules
Replace ~3500L admin.js monolith with thin orchestrator (~700L) +

14 IIFE-wrapped per-section modules under /js/admin/sections/.

Section modules expose AdminSections.<name>.init/reload (lazy init via

switchTab/router) and re-expose onclick handlers via window.X for

backward compat. Shared helpers (MODES/DIFFS, fmtDate, pctClass,

renderMath, qTypeBadge, pagination) live in /js/admin/_shared.js

exposed on window.AdminCtx.

switchTab now dispatches to AdminSections via ROUTE_TO_SECTION map;

non-extracted system tabs (topics/audit/errors/health/classroom/avatars)

remain inline in admin.js. user-panel overlay markup untouched — Phase 6

will remove it.
2026-05-16 22:50:14 +03:00

478 lines
22 KiB
JavaScript

'use strict';
/* admin → assignments section (классные/индивидуальные задания) */
(function () {
'use strict';
let inited = false;
let allAssignments = [];
let editingAId = null;
const SUBJ_NAMES = { bio:'Биология', chem:'Химия', math:'Математика', phys:'Физика' };
const SUBJ_COLORS_A = { bio:'#9B5DE5', chem:'#06D6A0', math:'#06B6D4', phys:'#F59E0B' };
const SUBJ_ICONS_A = { bio:'dna', chem:'flask-conical', math:'calculator', phys:'zap' };
let _aFilter = 'all';
// create-modal state
let _afSrc = 'random';
let _afLoadedTests = [];
let _acSrc = 'random';
let _acTarget = 'class';
let _acFileId = null, _acAllFiles = null;
let _acStudentId = null, _acAllStudents = null;
async function load() {
document.getElementById('a-body').innerHTML = '<div class="spinner"></div>';
try {
allAssignments = await LS.teacherAssignments();
renderAssignments();
} catch (e) {
document.getElementById('a-body').innerHTML = `<div class="error">Ошибка: ${esc(e.message)}</div>`;
}
}
function setAFilter(f) {
_aFilter = f;
document.querySelectorAll('.a-f-chip').forEach(c =>
c.classList.toggle('active', c.textContent.trim() === {all:'Все',active:'Активные',overdue:'Просрочены',done:'Завершены'}[f])
);
renderAssignments();
}
function aClassify(a) {
const pct = a.total_members ? Math.round(a.completed_count / a.total_members * 100) : null;
if (pct === 100) return 'done';
if (a.deadline && new Date(a.deadline) < new Date()) return 'overdue';
return 'active';
}
function renderAssignments() {
const { MODES, pctClass } = AdminCtx;
const subjF = document.getElementById('a-subject').value;
const searchF = document.getElementById('a-search').value.toLowerCase();
const sortF = document.getElementById('a-sort')?.value || 'date';
let list = allAssignments.filter(a => {
if (subjF && a.subject_slug !== subjF) return false;
if (searchF && !a.title.toLowerCase().includes(searchF)) return false;
if (_aFilter === 'active' && aClassify(a) !== 'active') return false;
if (_aFilter === 'overdue' && aClassify(a) !== 'overdue') return false;
if (_aFilter === 'done' && aClassify(a) !== 'done') return false;
return true;
});
list = [...list].sort((a, b) => {
if (sortF === 'deadline') {
const da = a.deadline ? new Date(a.deadline) : new Date(9e15);
const db = b.deadline ? new Date(b.deadline) : new Date(9e15);
return da - db;
}
if (sortF === 'progress_asc') {
const pa = a.total_members ? a.completed_count / a.total_members : 0;
const pb = b.total_members ? b.completed_count / b.total_members : 0;
return pa - pb;
}
if (sortF === 'progress_desc') {
const pa = a.total_members ? a.completed_count / a.total_members : 0;
const pb = b.total_members ? b.completed_count / b.total_members : 0;
return pb - pa;
}
return 0;
});
const all = allAssignments;
const nActive = all.filter(a => aClassify(a) === 'active').length;
const nOverdue = all.filter(a => aClassify(a) === 'overdue').length;
const nDone = all.filter(a => aClassify(a) === 'done').length;
document.getElementById('a-summary').innerHTML = [
`<span class="a-sum-chip s-all">Всего: ${all.length}</span>`,
nActive ? `<span class="a-sum-chip s-active">Активных: ${nActive}</span>` : '',
nOverdue ? `<span class="a-sum-chip s-overdue">Просрочено: ${nOverdue}</span>` : '',
nDone ? `<span class="a-sum-chip s-done">Завершено: ${nDone}</span>` : '',
].join('');
document.getElementById('a-count').textContent = `${list.length} заданий`;
const container = document.getElementById('a-body');
if (!list.length) {
container.innerHTML = '<div class="empty">Заданий нет</div>';
return;
}
const now = new Date();
container.innerHTML = list.map(a => {
const pct = a.total_members ? Math.round(a.completed_count / a.total_members * 100) : null;
const cls = aClassify(a);
const rowCls = cls === 'overdue' ? 'a-overdue' : cls === 'done' ? 'a-done' : '';
const sColor = SUBJ_COLORS_A[a.subject_slug] || '#9B5DE5';
const dlMs = a.deadline ? new Date(a.deadline) - now : Infinity;
const isUrgent = cls === 'active' && dlMs > 0 && dlMs < 24 * 3600 * 1000;
const dl = a.deadline
? new Date(a.deadline).toLocaleDateString('ru', {day:'numeric', month:'short'})
: null;
const targetStr = a.target_user_id
? esc(a.target_user_name || 'Ученик')
: esc(a.class_name || '—');
const metaParts = [
targetStr,
SUBJ_NAMES[a.subject_slug] || a.subject_slug,
`<span class="mode-badge mode-${a.mode}">${MODES[a.mode]||a.mode}</span>`,
a.count + ' вопр.',
dl ? `до ${dl}` : null,
isUrgent ? `<span class="a-tag-urgent"><i data-lucide="zap" style="width:10px;height:10px;vertical-align:-1px"></i> срочно</span>` : null,
cls === 'overdue' ? `<span class="a-tag-over">просрочено</span>` : null,
].filter(Boolean);
const barColor = pct >= 75 ? '#06D6A0' : pct >= 40 ? '#F59E0B' : '#F15BB5';
const pctLabel = pct !== null ? `${pct}%` : '—';
return `<div class="a-row ${rowCls}${isUrgent ? ' a-urgent' : ''}" style="--ac:${sColor}">
<div class="a-icon" style="background:${sColor}18;color:${sColor}"><i data-lucide="${SUBJ_ICONS_A[a.subject_slug]||'file-text'}" style="width:18px;height:18px"></i></div>
<div class="a-main">
<div class="a-title">${esc(a.title)}</div>
<div class="a-meta">${metaParts.join(' · ')}</div>
</div>
<div class="a-prog">
<div class="a-prog-nums">
<span>${a.completed_count} / ${a.total_members} сдали</span>
<span class="a-prog-pct ${pctClass(pct)}">${pctLabel}</span>
</div>
<div class="a-prog-bar">
<div class="a-prog-fill" style="width:${pct||0}%;background:${barColor}"></div>
</div>
</div>
<div class="a-actions">
<button class="btn-edit-q" onclick="openAModal(${a.id})">Изменить</button>
<button class="btn-del-q" onclick="deleteAsgn(${a.id})"><i data-lucide="x" style="width:14px;height:14px"></i></button>
</div>
</div>`;
}).join('');
if (window.lucide) lucide.createIcons();
}
function setAfSrc(src) {
_afSrc = src;
document.querySelectorAll('[data-afsrc]').forEach(b => b.classList.toggle('active', b.dataset.afsrc === src));
document.getElementById('af-random-fields').style.display = src === 'random' ? '' : 'none';
document.getElementById('af-test-fields').style.display = src === 'test' ? '' : 'none';
}
async function openAModal(id) {
const a = allAssignments.find(x => x.id === id);
if (!a) return;
editingAId = id;
document.getElementById('a-modal-title').textContent = `Редактировать: ${a.title}`;
document.getElementById('af-title').value = a.title;
document.getElementById('af-deadline').value = a.deadline ? a.deadline.split('T')[0] : '';
document.getElementById('af-error').textContent = '';
const testSel = document.getElementById('af-test');
testSel.innerHTML = '<option value="">Загрузка…</option>';
try {
_afLoadedTests = await LS.getTests();
testSel.innerHTML = _afLoadedTests.length
? '<option value="">— выберите тест —</option>' + _afLoadedTests.map(t => `<option value="${t.id}">${esc(t.title)} (${t.question_count} вопр.)</option>`).join('')
: '<option value="">Нет тестов</option>';
} catch {
testSel.innerHTML = '<option value="">Ошибка загрузки</option>';
_afLoadedTests = [];
}
if (a.test_id) {
setAfSrc('test');
testSel.value = a.test_id;
document.getElementById('af-mode-test').value = a.mode;
} else {
setAfSrc('random');
document.getElementById('af-subject').value = a.subject_slug;
document.getElementById('af-mode').value = a.mode;
document.getElementById('af-count').value = a.count;
}
document.getElementById('a-modal').classList.add('open');
setTimeout(() => document.getElementById('af-title').focus(), 80);
}
function closeAModal() {
document.getElementById('a-modal').classList.remove('open');
editingAId = null;
}
async function saveAssignment() {
const title = document.getElementById('af-title').value.trim();
const deadline = document.getElementById('af-deadline').value || null;
const errEl = document.getElementById('af-error');
errEl.textContent = '';
if (!title) { errEl.textContent = 'Введите название'; return; }
let payload = { title, deadline };
if (_afSrc === 'test') {
const test_id = document.getElementById('af-test').value;
const mode = document.getElementById('af-mode-test').value;
if (!test_id) { errEl.textContent = 'Выберите тест'; return; }
const testObj = _afLoadedTests.find(t => t.id === Number(test_id));
if (testObj && testObj.question_count === 0) { errEl.textContent = 'В выбранном тесте нет вопросов'; return; }
payload = { ...payload, test_id: Number(test_id), mode };
} else {
const subject_slug = document.getElementById('af-subject').value;
const mode = document.getElementById('af-mode').value;
const count = Number(document.getElementById('af-count').value);
if (!subject_slug) { errEl.textContent = 'Выберите предмет'; return; }
if (!count || count < 1) { errEl.textContent = 'Введите количество вопросов'; return; }
payload = { ...payload, subject_slug, mode, count, test_id: null };
}
const btn = document.getElementById('af-save');
btn.disabled = true; btn.textContent = 'Сохранение…';
try {
await LS.updateAssignment(editingAId, payload);
const idx = allAssignments.findIndex(x => x.id === editingAId);
if (idx !== -1) Object.assign(allAssignments[idx], payload);
closeAModal();
renderAssignments();
} catch (e) {
errEl.textContent = 'Ошибка: ' + e.message;
} finally {
btn.disabled = false; btn.textContent = 'Сохранить';
}
}
/* ─── Create assignment modal ─── */
function setAcTarget(t) {
_acTarget = t;
document.querySelectorAll('[data-actgt]').forEach(b => b.classList.toggle('active', b.dataset.actgt === t));
document.getElementById('acf-class-field').style.display = t === 'class' ? '' : 'none';
document.getElementById('acf-user-field').style.display = t === 'user' ? '' : 'none';
if (t === 'user' && !_acAllStudents) loadAcStudents();
}
async function loadAcStudents() {
const drop = document.getElementById('acf-student-drop');
drop.innerHTML = '<div style="padding:8px 12px;font-size:13px;color:#9ca3af">Загрузка…</div>';
drop.style.display = '';
try {
_acAllStudents = await LS.getStudentsList();
openAcStudentDrop();
} catch(e) {
_acAllStudents = [];
drop.innerHTML = `<div style="padding:8px 12px;font-size:13px;color:#ef4444">Ошибка загрузки: ${e.message}</div>`;
}
}
function filterAcStudents(q) {
openAcStudentDrop(q);
}
function openAcStudentDrop(q) {
const drop = document.getElementById('acf-student-drop');
if (_acAllStudents === null) { loadAcStudents(); return; }
const list = _acAllStudents;
const term = (q !== undefined ? q : document.getElementById('acf-student-search').value).toLowerCase().trim();
const filtered = term ? list.filter(s => s.name.toLowerCase().includes(term) || s.email.toLowerCase().includes(term)) : list;
if (!filtered.length) {
drop.innerHTML = '<div style="padding:8px 12px;font-size:13px;color:#9ca3af">Нет учеников</div>';
drop.style.display = '';
return;
}
drop.innerHTML = filtered.slice(0, 50).map(s =>
`<div style="padding:8px 12px;cursor:pointer;border-bottom:1px solid #f3f4f6;font-size:13px" data-id="${s.id}" data-name="${esc(s.name)}" data-email="${esc(s.email)}" onmousedown="selectAcStudent(+this.dataset.id,this.dataset.name,this.dataset.email)" onmouseover="this.style.background='#f9fafb'" onmouseout="this.style.background=''">${esc(s.name)} <span style="color:#9ca3af">${esc(s.email)}</span></div>`
).join('');
drop.style.display = '';
}
function closeAcStudentDrop() {
document.getElementById('acf-student-drop').style.display = 'none';
}
function selectAcStudent(id, name, email) {
_acStudentId = id;
document.getElementById('acf-student-search').value = name;
document.getElementById('acf-student-selected').textContent = `${name} (${email})`;
document.getElementById('acf-student-selected').style.display = '';
closeAcStudentDrop();
}
function setAcSrc(src) {
_acSrc = src;
document.querySelectorAll('[data-src]').forEach(b => b.classList.toggle('active', b.dataset.src === src));
document.getElementById('acf-random-fields').style.display = src === 'random' ? '' : 'none';
document.getElementById('acf-test-fields').style.display = src === 'test' ? '' : 'none';
document.getElementById('acf-file-fields').style.display = src === 'file' ? '' : 'none';
if (src === 'file' && !_acAllFiles) loadAcFiles();
}
async function loadAcFiles() {
try {
_acAllFiles = await LS.getFiles();
renderAcFiles('');
} catch { _acAllFiles = []; }
}
function renderAcFiles(q) {
const el = document.getElementById('acf-file-list');
if (!_acAllFiles) { el.innerHTML = '<div style="padding:10px;color:var(--text-3);font-size:.82rem;text-align:center">Загрузка…</div>'; return; }
const lq = q.toLowerCase();
const items = q ? _acAllFiles.filter(f => (f.title||'').toLowerCase().includes(lq)) : _acAllFiles;
const SUBJ = { bio:'Биология', chem:'Химия', math:'Математика', phys:'Физика' };
if (!items.length) { el.innerHTML = '<div style="padding:10px;color:var(--text-3);font-size:.82rem;text-align:center">Нет файлов</div>'; return; }
el.innerHTML = items.map(f => `
<div onclick="selectAcFile(${f.id},'${esc(f.title||'Файл')}','${f.subject_slug||''}')"
style="padding:9px 12px;cursor:pointer;border-bottom:1px solid rgba(15,23,42,0.07);display:flex;align-items:center;gap:8px;${_acFileId===f.id?'background:rgba(155,93,229,0.08);':''} transition:background .15s">
<div style="flex:1">
<div style="font-size:.84rem;font-weight:600">${esc(f.title||'Файл')}</div>
<div style="font-size:.74rem;color:var(--text-3)">${SUBJ[f.subject_slug]||f.subject_slug||''}</div>
</div>
${_acFileId===f.id ? '<span style="color:var(--violet)"><i data-lucide="check" style="width:15px;height:15px"></i></span>' : ''}
</div>`).join('');
if (window.lucide) lucide.createIcons();
}
function filterAcFiles(q) { renderAcFiles(q); }
function selectAcFile(id, title, subject_slug) {
_acFileId = id;
renderAcFiles(document.getElementById('acf-file-search').value);
const sel = document.getElementById('acf-file-selected');
sel.textContent = 'Выбран: ' + title;
sel.style.display = '';
}
async function openCreateAModal() {
_acSrc = 'random'; _acTarget = 'class'; _acFileId = null; _acStudentId = null; _acAllStudents = null;
setAcSrc('random');
setAcTarget('class');
loadAcStudents();
document.getElementById('acf-title').value = '';
document.getElementById('acf-subject').value = '';
document.getElementById('acf-mode').value = 'exam';
document.getElementById('acf-mode-test').value = 'exam';
document.getElementById('acf-count').value = '25';
document.getElementById('acf-deadline').value = '';
document.getElementById('acf-student-search').value = '';
document.getElementById('acf-student-selected').style.display = 'none';
_acStudentId = null;
document.getElementById('acf-error').textContent = '';
document.getElementById('acf-file-search').value = '';
document.getElementById('acf-file-selected').style.display = 'none';
const [clsSel, testSel] = [document.getElementById('acf-class'), document.getElementById('acf-test')];
clsSel.innerHTML = '<option value="">Загрузка…</option>';
testSel.innerHTML = '<option value="">Загрузка…</option>';
const [classesP, testsP] = await Promise.allSettled([LS.getClasses(), LS.getTests()]);
if (classesP.status === 'fulfilled') {
const classes = classesP.value;
clsSel.innerHTML = classes.length
? '<option value="">— выберите класс —</option>' + classes.map(c => `<option value="${c.id}">${esc(c.name)} (${c.member_count} уч.)</option>`).join('')
: '<option value="">Нет классов — создайте класс</option>';
} else {
clsSel.innerHTML = `<option value="">Ошибка загрузки классов</option>`;
}
if (testsP.status === 'fulfilled') {
const tests = testsP.value;
testSel.innerHTML = tests.length
? '<option value="">— выберите тест —</option>' + tests.map(t => `<option value="${t.id}">${esc(t.title)} (${t.question_count} вопр.)</option>`).join('')
: '<option value="">Нет тестов — создайте тест</option>';
} else {
testSel.innerHTML = `<option value="">Ошибка загрузки тестов</option>`;
}
document.getElementById('ac-modal').classList.add('open');
setTimeout(() => document.getElementById('acf-title').focus(), 80);
}
function closeCreateAModal() {
document.getElementById('ac-modal').classList.remove('open');
}
async function saveNewAssignment() {
const title = document.getElementById('acf-title').value.trim();
const deadline = document.getElementById('acf-deadline').value || null;
const errEl = document.getElementById('acf-error');
errEl.textContent = '';
if (!title) { errEl.textContent = 'Введите название'; return; }
let payload = { title, deadline };
if (_acSrc === 'file') {
if (!_acFileId) { errEl.textContent = 'Выберите файл из библиотеки'; return; }
const f = _acAllFiles.find(x => x.id === _acFileId);
payload = { ...payload, file_id: _acFileId, subject_slug: f?.subject_slug || 'bio', mode: 'exam', count: 1 };
} else if (_acSrc === 'test') {
const test_id = document.getElementById('acf-test').value;
const mode = document.getElementById('acf-mode-test').value;
if (!test_id) { errEl.textContent = 'Выберите тест'; return; }
const selOpt = document.querySelector(`#acf-test option[value="${test_id}"]`);
if (selOpt && selOpt.textContent.includes('(0 вопр.)')) { errEl.textContent = 'В выбранном тесте нет вопросов. Добавьте вопросы во вкладке «Тесты».'; return; }
payload = { ...payload, test_id: Number(test_id), mode };
} else {
const subject_slug = document.getElementById('acf-subject').value;
const mode = document.getElementById('acf-mode').value;
const count = Number(document.getElementById('acf-count').value);
if (!subject_slug) { errEl.textContent = 'Выберите предмет'; return; }
if (!count || count < 1) { errEl.textContent = 'Укажите количество вопросов'; return; }
payload = { ...payload, subject_slug, mode, count };
}
const btn = document.getElementById('acf-save');
btn.disabled = true; btn.textContent = 'Создание…';
try {
if (_acTarget === 'user') {
if (!_acStudentId) { errEl.textContent = 'Выберите ученика из списка'; btn.disabled=false; btn.textContent='Создать'; return; }
await LS.createDirectAssignment({ ...payload, student_id: _acStudentId });
} else {
const class_id = document.getElementById('acf-class').value;
if (!class_id) { errEl.textContent = 'Выберите класс'; btn.disabled=false; btn.textContent='Создать'; return; }
await LS.createAssignment(class_id, payload);
}
closeCreateAModal();
await load();
} catch (e) {
errEl.textContent = 'Ошибка: ' + e.message;
} finally {
btn.disabled = false; btn.textContent = 'Создать';
}
}
async function deleteAsgn(id) {
const a = allAssignments.find(x => x.id === id);
if (!await LS.confirm(`Удалить задание «${a?.title}»?\nВсе связанные сессии будут удалены.`, { title: 'Удалить задание', confirmText: 'Удалить' })) return;
try {
await LS.deleteAssignment(id);
allAssignments = allAssignments.filter(x => x.id !== id);
renderAssignments();
} catch (e) { LS.toast('Ошибка: ' + e.message, 'error'); }
}
// Expose handlers used by HTML/inline onclicks
window.loadAssignments = load;
window.renderAssignments = renderAssignments;
window.setAFilter = setAFilter;
window.setAfSrc = setAfSrc;
window.openAModal = openAModal;
window.closeAModal = closeAModal;
window.saveAssignment = saveAssignment;
window.setAcTarget = setAcTarget;
window.filterAcStudents = filterAcStudents;
window.openAcStudentDrop = openAcStudentDrop;
window.closeAcStudentDrop = closeAcStudentDrop;
window.selectAcStudent = selectAcStudent;
window.setAcSrc = setAcSrc;
window.filterAcFiles = filterAcFiles;
window.selectAcFile = selectAcFile;
window.openCreateAModal = openCreateAModal;
window.closeCreateAModal = closeCreateAModal;
window.saveNewAssignment = saveNewAssignment;
window.deleteAsgn = deleteAsgn;
window.AdminSections = window.AdminSections || {};
window.AdminSections.assignments = {
init: async () => { if (inited) return; inited = true; await load(); },
reload: load,
};
})();