feat: textbooks Phase 4 — A1+A2+A3+B4+C7 + назначение ученику
A1 — карточка ДЗ-чтения у ученика на /dashboard: - Новая ветка в buildAssignCard для assignments с textbook_id - Прогресс-бар «X из Y §», цвет берётся из textbook.color - Кнопка «Открыть / Продолжить» с deep-link на первый требуемый параграф - В classify(): textbook_all_read → done, deadline → overdue A2 — авто-проверка выполнения: - При POST /:slug/progress с mark_read: проверяются активные textbook-assignments - Если все требуемые § прочитаны → INSERT в assignment_completion - SSE-уведомление учителю «Ученик завершил чтение: <title>» - myAssignments возвращает completed_at и textbook_all_read A3 — учительский UI прогресса класса: - Новая страница /textbook-progress (учитель/админ) - Селекторы «учебник × класс» → таблица учеников с прогрессом - Сортировка по количеству прочитанного, дата last_at - Кнопка «Прогресс класса» добавлена в /textbooks (видна учителям) B4 — admin-UI управления учебниками: - /admin-textbooks (только admin) — таблица всех учебников - Inline-редактирование title/author, тоггл is_active - Колонка «Читателей» (count из textbook_progress) - Endpoints: GET /api/textbooks/admin/all, PATCH /admin/:id C7 — закладки/заметки внутри учебника: - Таблица textbook_bookmarks (user, textbook, para, text, note, color) - API: GET/POST/PATCH/DELETE для CRUD закладок - В tracker: при выделении текста (8-400 симв) появляется плавающая «+ Закладка» - Кнопка-иконка в overlay top-left открывает панель «Мои закладки» - Хранится paragraph-якорь, цвет, заметка, кнопка удалить Назначение ученику (в дополнение к классу): - В модалке /textbooks — переключатель «Классу / Ученику» - Поиск ученика по имени/email через /api/classes/students - Submit использует POST /api/assignments (createDirectAssignment) - createDirectAssignment расширен textbook_slug + textbook_paragraphs - Учитель может назначать только ученикам своих классов myAssignments расширен: возвращает textbook fields + post-process считает textbook_required_count, textbook_read_count, textbook_all_read. Deep-link поддержка: /textbook/<slug>#pN в tracker.js — на load и hashchange вызывает setParaTab(pN) (нативная функция учебника). Миграция 005: assignment_completion + textbook_bookmarks + индексы.
This commit is contained in:
+150
-28
@@ -209,6 +209,31 @@
|
||||
}
|
||||
.ax-error.visible { display:block; background:rgba(241,91,68,.1); border:1px solid rgba(241,91,68,.3); color:#F94144; }
|
||||
.ax-success.visible { display:block; background:rgba(6,214,160,.1); border:1px solid rgba(6,214,160,.3); color:#06D6A0; }
|
||||
|
||||
.ax-tabs { display:flex; gap:6px; background:var(--border); padding:4px; border-radius:10px; }
|
||||
.ax-tab {
|
||||
flex:1; padding:7px 12px; border-radius:7px;
|
||||
border:none; background:transparent; color:var(--text-2);
|
||||
font-family:'Manrope',sans-serif; font-size:.85rem; font-weight:700;
|
||||
cursor:pointer; transition:all .12s;
|
||||
}
|
||||
.ax-tab:hover { color:var(--text); }
|
||||
.ax-tab.active { background:var(--surface); color:var(--violet); box-shadow:0 1px 4px rgba(0,0,0,.08); }
|
||||
|
||||
.ax-student-results {
|
||||
margin-top:6px; max-height:160px; overflow-y:auto;
|
||||
border:1.5px solid var(--border); border-radius:10px;
|
||||
display:none;
|
||||
}
|
||||
.ax-student-results.visible { display:block; }
|
||||
.ax-student-row {
|
||||
padding:8px 12px; cursor:pointer; transition:background .12s;
|
||||
display:flex; align-items:center; gap:10px;
|
||||
font-size:.85rem;
|
||||
}
|
||||
.ax-student-row:hover { background:var(--border); }
|
||||
.ax-student-row.selected { background:rgba(155,93,229,.12); color:var(--violet); }
|
||||
.ax-student-row .ax-student-email { font-size:.75rem; color:var(--text-3); margin-left:auto; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
@@ -227,10 +252,11 @@
|
||||
<line x1="9" y1="11" x2="15" y2="11"/>
|
||||
</svg>
|
||||
</div>
|
||||
<div>
|
||||
<div style="flex:1">
|
||||
<div class="tb-title">Учебники</div>
|
||||
<div class="tb-sub">Полные курсы по предметам с разделами и интерактивными примерами</div>
|
||||
</div>
|
||||
<div id="tb-header-actions"></div>
|
||||
</header>
|
||||
|
||||
<div class="tb-grid" id="tb-grid">
|
||||
@@ -254,13 +280,26 @@
|
||||
</div>
|
||||
<form class="ax-form" id="assign-form" onsubmit="event.preventDefault(); submitAssign()">
|
||||
<div class="ax-field">
|
||||
<label>Кому</label>
|
||||
<div class="ax-tabs">
|
||||
<button type="button" class="ax-tab active" data-tab="class" onclick="setAssignTab('class')">Классу</button>
|
||||
<button type="button" class="ax-tab" data-tab="student" onclick="setAssignTab('student')">Ученику</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="ax-field" id="ax-class-field">
|
||||
<label>Классы</label>
|
||||
<div class="ax-classes" id="ax-classes-list">Загрузка…</div>
|
||||
</div>
|
||||
<div class="ax-field" id="ax-student-field" style="display:none">
|
||||
<label>Ученик</label>
|
||||
<input type="text" class="ax-input" id="ax-student-search" placeholder="Поиск по имени или email…" autocomplete="off" />
|
||||
<div class="ax-student-results" id="ax-student-results"></div>
|
||||
<input type="hidden" id="ax-student-id" />
|
||||
</div>
|
||||
<div class="ax-field">
|
||||
<label>Параграфы</label>
|
||||
<input type="text" class="ax-input" id="ax-paragraphs" placeholder="например: 1-5 или 1,3,7" />
|
||||
<div class="ax-hint">Диапазон («15-18») или список через запятую («1,3,5»)</div>
|
||||
<div class="ax-hint">Диапазон («15-18») или список через запятую («1,3,5»). Пустое = весь учебник.</div>
|
||||
</div>
|
||||
<div class="ax-field">
|
||||
<label>Срок сдачи</label>
|
||||
@@ -292,6 +331,15 @@
|
||||
let textbooks = [];
|
||||
let teacherClasses = null;
|
||||
|
||||
// Teacher-only: "Class progress" button in header
|
||||
if (isTeacher) {
|
||||
document.getElementById('tb-header-actions').innerHTML = `
|
||||
<a href="/textbook-progress" class="tb-btn" style="display:inline-flex;width:auto;text-decoration:none">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round"><line x1="18" y1="20" x2="18" y2="10"/><line x1="12" y1="20" x2="12" y2="4"/><line x1="6" y1="20" x2="6" y2="14"/></svg>
|
||||
Прогресс класса
|
||||
</a>`;
|
||||
}
|
||||
|
||||
function esc(s) {
|
||||
return String(s || '').replace(/[&<>"']/g, c => ({ '&':'&','<':'<','>':'>','"':'"',"'":''' }[c]));
|
||||
}
|
||||
@@ -359,8 +407,10 @@
|
||||
}
|
||||
|
||||
/* ── Assign modal ── */
|
||||
let assignSlug = null;
|
||||
let assignTitle = null;
|
||||
let assignSlug = null;
|
||||
let assignTitle = null;
|
||||
let assignTab = 'class'; // 'class' or 'student'
|
||||
let teacherStudents = null; // cached list of students-in-teacher's-classes
|
||||
|
||||
async function loadTeacherClasses() {
|
||||
if (teacherClasses) return teacherClasses;
|
||||
@@ -371,6 +421,25 @@
|
||||
return teacherClasses;
|
||||
}
|
||||
|
||||
async function loadTeacherStudents() {
|
||||
if (teacherStudents) return teacherStudents;
|
||||
try {
|
||||
const r = await LS.api('/api/classes/students');
|
||||
teacherStudents = Array.isArray(r) ? r : (r.students || []);
|
||||
} catch { teacherStudents = []; }
|
||||
return teacherStudents;
|
||||
}
|
||||
|
||||
window.setAssignTab = function (tab) {
|
||||
assignTab = tab;
|
||||
document.querySelectorAll('.ax-tab').forEach(t => t.classList.toggle('active', t.dataset.tab === tab));
|
||||
document.getElementById('ax-class-field').style.display = tab === 'class' ? '' : 'none';
|
||||
document.getElementById('ax-student-field').style.display = tab === 'student' ? '' : 'none';
|
||||
document.getElementById('ax-student-id').value = '';
|
||||
document.getElementById('ax-student-search').value = '';
|
||||
document.getElementById('ax-student-results').classList.remove('visible');
|
||||
};
|
||||
|
||||
window.openAssignModal = async function (slug, title) {
|
||||
assignSlug = slug;
|
||||
assignTitle = title;
|
||||
@@ -380,6 +449,7 @@
|
||||
document.getElementById('ax-deadline').value = '';
|
||||
document.getElementById('ax-submit').disabled = false;
|
||||
document.getElementById('ax-submit').textContent = 'Назначить';
|
||||
setAssignTab('class');
|
||||
|
||||
const listEl = document.getElementById('ax-classes-list');
|
||||
listEl.textContent = 'Загрузка…';
|
||||
@@ -409,6 +479,45 @@
|
||||
};
|
||||
function onAssignEsc(e) { if (e.key === 'Escape') closeAssignModal(); }
|
||||
|
||||
/* Student search (debounced) */
|
||||
let stSearchTimer = null;
|
||||
document.addEventListener('input', e => {
|
||||
if (e.target?.id !== 'ax-student-search') return;
|
||||
clearTimeout(stSearchTimer);
|
||||
stSearchTimer = setTimeout(() => filterStudents(e.target.value), 200);
|
||||
});
|
||||
|
||||
async function filterStudents(q) {
|
||||
const resultsEl = document.getElementById('ax-student-results');
|
||||
q = q.trim().toLowerCase();
|
||||
if (q.length < 2) { resultsEl.classList.remove('visible'); return; }
|
||||
const students = await loadTeacherStudents();
|
||||
const matches = students.filter(s =>
|
||||
(s.name && s.name.toLowerCase().includes(q)) ||
|
||||
(s.email && s.email.toLowerCase().includes(q))
|
||||
).slice(0, 12);
|
||||
if (!matches.length) {
|
||||
resultsEl.innerHTML = '<div class="ax-student-row" style="color:var(--text-3);cursor:default">Не найдено</div>';
|
||||
} else {
|
||||
resultsEl.innerHTML = matches.map(s => `
|
||||
<div class="ax-student-row" data-id="${s.id}" data-name="${esc(s.name)}">
|
||||
<span>${esc(s.name)}</span>
|
||||
<span class="ax-student-email">${esc(s.email || '')}</span>
|
||||
</div>`).join('');
|
||||
}
|
||||
resultsEl.classList.add('visible');
|
||||
}
|
||||
|
||||
document.addEventListener('click', e => {
|
||||
const row = e.target.closest('.ax-student-row');
|
||||
if (!row || !row.dataset.id) return;
|
||||
document.querySelectorAll('.ax-student-row').forEach(r => r.classList.remove('selected'));
|
||||
row.classList.add('selected');
|
||||
document.getElementById('ax-student-id').value = row.dataset.id;
|
||||
document.getElementById('ax-student-search').value = row.dataset.name;
|
||||
document.getElementById('ax-student-results').classList.remove('visible');
|
||||
});
|
||||
|
||||
window.submitAssign = async function () {
|
||||
const errorEl = document.getElementById('ax-error');
|
||||
const successEl = document.getElementById('ax-success');
|
||||
@@ -416,37 +525,50 @@
|
||||
errorEl.classList.remove('visible');
|
||||
successEl.classList.remove('visible');
|
||||
|
||||
const checked = [...document.querySelectorAll('#ax-classes-list input[name="cls"]:checked')]
|
||||
.map(el => Number(el.value));
|
||||
if (!checked.length) {
|
||||
errorEl.textContent = 'Выберите хотя бы один класс';
|
||||
errorEl.classList.add('visible');
|
||||
return;
|
||||
}
|
||||
|
||||
const paragraphs = document.getElementById('ax-paragraphs').value.trim();
|
||||
const deadline = document.getElementById('ax-deadline').value || null;
|
||||
const titleSuffix = paragraphs ? ` (§${paragraphs})` : '';
|
||||
|
||||
submitBtn.disabled = true;
|
||||
submitBtn.textContent = 'Назначаю…';
|
||||
|
||||
try {
|
||||
const titleSuffix = paragraphs ? ` (§${paragraphs})` : '';
|
||||
const r = await LS.api('/api/assignments/bulk', {
|
||||
method: 'POST',
|
||||
body: {
|
||||
title: `Учебник: ${assignTitle}${titleSuffix}`,
|
||||
class_ids: checked,
|
||||
mode: 'exam', // mode is required, but for textbook assignment is informational
|
||||
count: 1,
|
||||
subject_slug: 'other',
|
||||
is_homework: 1,
|
||||
deadline: deadline,
|
||||
textbook_slug: assignSlug,
|
||||
textbook_paragraphs: paragraphs || null,
|
||||
},
|
||||
});
|
||||
successEl.textContent = `Назначено в ${r.count || checked.length} класс(е/ах)`;
|
||||
let resultMsg;
|
||||
if (assignTab === 'class') {
|
||||
const checked = [...document.querySelectorAll('#ax-classes-list input[name="cls"]:checked')]
|
||||
.map(el => Number(el.value));
|
||||
if (!checked.length) throw new Error('Выберите хотя бы один класс');
|
||||
|
||||
const r = await LS.api('/api/assignments/bulk', {
|
||||
method: 'POST',
|
||||
body: {
|
||||
title: `Учебник: ${assignTitle}${titleSuffix}`,
|
||||
class_ids: checked,
|
||||
mode: 'exam', count: 1, subject_slug: 'other', is_homework: 1,
|
||||
deadline,
|
||||
textbook_slug: assignSlug,
|
||||
textbook_paragraphs: paragraphs || null,
|
||||
},
|
||||
});
|
||||
resultMsg = `Назначено в ${r.count || checked.length} класс(е/ах)`;
|
||||
} else {
|
||||
const studentId = Number(document.getElementById('ax-student-id').value);
|
||||
if (!studentId) throw new Error('Выберите ученика');
|
||||
|
||||
await LS.api('/api/assignments', {
|
||||
method: 'POST',
|
||||
body: {
|
||||
title: `Учебник: ${assignTitle}${titleSuffix}`,
|
||||
student_id: studentId,
|
||||
mode: 'exam', count: 1, subject_slug: 'other', is_homework: 1,
|
||||
deadline,
|
||||
textbook_slug: assignSlug,
|
||||
textbook_paragraphs: paragraphs || null,
|
||||
},
|
||||
});
|
||||
resultMsg = 'Личное задание создано';
|
||||
}
|
||||
successEl.textContent = resultMsg;
|
||||
successEl.classList.add('visible');
|
||||
submitBtn.textContent = 'Готово';
|
||||
setTimeout(closeAssignModal, 1500);
|
||||
|
||||
Reference in New Issue
Block a user