feat(lessons): тема занятия в отчёте о занятии

В модалку #lessonModal добавлено текстовое поле «Тема занятия» (#lessonTopic)
между датой/временем и текстом отчёта. Поле необязательное, лимит 300 символов.

Протянуто по всему срезу:
- колонка lesson_reports.topic в db/init.sql, db/migration.sql и
  ensureLessonReportsTable() — миграция идемпотентная, ALTER IF NOT EXISTS
- POST /api/lesson-reports принимает topic, PUT обновляет; в GET-списке
  колонка добавлена в явный SELECT (там не SELECT *, без правки не пришла бы)
- тема в normalizeRestoreData, чтобы бэкап/restore её не теряли
- тема уходит в ИИ-контекст воркера строкой «Тема занятия: ...»
- вывод в списке отчётов (lessons.js + .lesson-topic в admin.css)

В PUT пустая строка очищает тему, а отсутствие поля в теле запроса её не
трогает: CASE WHEN $6::boolean THEN $3::text ELSE topic END, а не COALESCE —
при COALESCE пустая тема затиралась бы старым значением и поле нельзя было бы
очистить.

Версии не трогаем: lesson_report_versions хранит только text.

Тесты: +5 проверок (создание, список, редактирование, очистка, >300 символов),
api.smoketest.js — 76 PASS / 0 FAIL.
This commit is contained in:
dev
2026-10-04 11:56:55 +03:00
parent 001ff8a7b7
commit c994edbed7
9 changed files with 79 additions and 18 deletions
+11 -1
View File
@@ -534,6 +534,11 @@ function ensureLessonModal() {
<input type="time" id="lessonTime" class="settings-input">
</div>
</div>
<div class="settings-field">
<label>Тема занятия</label>
<input type="text" id="lessonTopic" class="settings-input" maxlength="300"
placeholder="Например: циклы for и while, задачи на списки">
</div>
<div class="settings-field">
<label>Что прошли на занятии <span style="color:#ef4444">*</span></label>
<textarea id="lessonText" class="settings-input" rows="6" maxlength="5000" required
@@ -591,6 +596,7 @@ async function submitLessonModal(e) {
const dateEl = modal.querySelector('#lessonDate');
const timeEl = modal.querySelector('#lessonTime');
const textEl = modal.querySelector('#lessonText');
const topicEl = modal.querySelector('#lessonTopic');
const submitBtn = modal.querySelector('#lessonSubmit');
const aiCheckEl = modal.querySelector('#lessonAiCheck');
const groupId = groupSel.value;
@@ -606,7 +612,7 @@ async function submitLessonModal(e) {
const res = await fetch(`${API}/api/lesson-reports${id ? '/' + id : ''}`, {
method: id ? 'PUT' : 'POST',
headers: hdrJson(),
body: JSON.stringify({ group_id: Number(groupId), lesson_date: date, lesson_time: timeEl.value || null, text, ai_check: aiCheck }),
body: JSON.stringify({ group_id: Number(groupId), lesson_date: date, lesson_time: timeEl.value || null, topic: topicEl.value.trim(), text, ai_check: aiCheck }),
});
const data = await res.json().catch(() => ({}));
if (!res.ok) {
@@ -675,6 +681,9 @@ function fillLessonModalFromReport(modal, r) {
textEl.value = r.text || '';
textEl.dispatchEvent(new Event('input'));
}
if (!modal.querySelector('#lessonTopic').value) {
modal.querySelector('#lessonTopic').value = r.topic || '';
}
const aiCheck = modal.querySelector('#lessonAiCheck');
if (aiCheck) aiCheck.checked = false;
updateLessonAiRow(modal, r);
@@ -713,6 +722,7 @@ function resetLessonModalFields(modal) {
showLessonDuplicate(modal, null);
modal.querySelector('#lessonDate').value = lessonTodayIso();
modal.querySelector('#lessonTime').value = lessonNowTime();
modal.querySelector('#lessonTopic').value = '';
const textEl = modal.querySelector('#lessonText');
textEl.value = '';
textEl.dispatchEvent(new Event('input'));