feat(export): отчёты о занятиях в ZIP-профиле резидента

В архив экспорта студента (GET /api/export/student, кнопка «Профиль»
на /students.html) добавлена секция «Отчёты о занятиях»: тема, дата,
время, группа и текст конспекта преподавателя. Секция рендерится между
«Занятия» и «О себе», попадает в навигацию и футер, появляется только
если есть хотя бы один отчёт.

Отчёты привязаны к группе (в lesson_reports нет student_id), поэтому
выбираются по группам резидента: текущая группа плюс все группы, где
у него есть записи. Фильтры филиалов (branchWhere) и период из
date_from/date_to соблюдаются, как и в остальной выборке. Ограничение
60 последних отчётов, при усечении в подзаголовке секции есть
соответствующая пометка. Новый флаг include_reports (по умолчанию
включён) + чекбокс в модалке экспорта.

Даты lesson_date/lesson_time форматируются как чистые DATE/TIME
строки, без new Date(), чтобы не сдвинуть день; учёт настройки
time_format сделан через appHour12.

Заодно в отчёте и в сообщениях эндпоинта «ученик» заменён на
«резидент».

Co-Authored-By: Cline <cline@cline.bot>
This commit is contained in:
dev
2026-10-04 13:37:57 +03:00
co-authored by Cline
parent c994edbed7
commit 9fbd696c84
4 changed files with 156 additions and 19 deletions
+107 -15
View File
@@ -122,6 +122,23 @@ function longDate(iso) {
return d.getDate() + ' ' + MONTHS_GEN[d.getMonth()] + ' ' + d.getFullYear();
}
function isoDateLabel(value) {
const m = /^(\d{4})-(\d{2})-(\d{2})/.exec(String(value || '').trim());
if (!m) return '';
const mi = Number(m[2]) - 1;
if (mi < 0 || mi > 11) return '';
return Number(m[3]) + ' ' + MONTHS_GEN[mi] + ' ' + m[1];
}
function hmLabel(value, hour12) {
const m = /^(\d{1,2}):(\d{2})/.exec(String(value || '').trim());
if (!m) return '';
const h = Number(m[1]);
if (h > 23) return '';
if (!hour12) return (h < 10 ? '0' + h : String(h)) + ':' + m[2];
return (h % 12 === 0 ? 12 : h % 12) + ':' + m[2] + (h < 12 ? ' AM' : ' PM');
}
function monthYear(iso) {
const d = asDate(iso);
if (!d) return '';
@@ -236,6 +253,25 @@ function iconForGroup(title) {
return 'sparkles';
}
function iconForReport(topic) {
const t = String(topic || '').toLowerCase();
if (/защит|практи|лаборатор|проект|работа/.test(t)) return 'hammer';
if (/разбор|рефактор|дебаг|код|ревью/.test(t)) return 'code-2';
if (/введ|теори|лекц|знакомств/.test(t)) return 'book-open-check';
return 'clipboard-list';
}
function paragraphs(value) {
return String(value === null || value === undefined ? '' : value)
.replace(/\r\n?/g, '\n')
.split(/\n{2,}/)
.map(p => p.split('\n').map(x => x.trim()).filter(Boolean).join(' '))
.map(p => p.trim())
.filter(Boolean)
.slice(0, 40)
.map(p => p.slice(0, 2000));
}
function buildProfileView(data, opts) {
const o = opts || {};
const showEntries = o.includeEntries !== false;
@@ -243,6 +279,8 @@ function buildProfileView(data, opts) {
const showFiles = o.includeFiles !== false;
const showCaptions = o.includeCaptions !== false;
const showDates = o.showDates !== false;
const showReports = o.includeReports !== false;
const hour12 = data.hour12 === true;
const student = data.student || {};
const profile = (data.profile && typeof data.profile === 'object' && !Array.isArray(data.profile)) ? data.profile : {};
const entries = arr(data.entries);
@@ -297,7 +335,7 @@ function buildProfileView(data, opts) {
const studentPhotoCards = showPhotos ? arr(data.studentPhotos).map(sp => {
const src = 'photos/' + sp.stored;
const g = galleryAdd('image', src, sp.main ? 'Главное фото' : 'Фото ученика');
const g = galleryAdd('image', src, sp.main ? 'Главное фото' : 'Фото резидента');
return {
src,
g,
@@ -361,6 +399,17 @@ function buildProfileView(data, opts) {
};
}) : [];
const lessonReports = showReports ? arr(data.lessonReports).map(r => {
const date = showDates ? isoDateLabel(r.lesson_date) : '';
const time = showDates ? hmLabel(r.lesson_time, hour12) : '';
return {
icon: iconForReport(r.topic),
title: str(r.topic, 300) || 'Отчёт о занятии',
sub: [date, time, str(r.group_name, 90)].filter(Boolean).join(' · '),
paragraphs: paragraphs(r.text),
};
}).filter(r => r.paragraphs.length || r.sub) : [];
const groupSet = new Set();
if (student.group_name) groupSet.add(student.group_name);
for (const e of entries) if (e.group_name) groupSet.add(e.group_name);
@@ -457,6 +506,7 @@ function buildProfileView(data, opts) {
if (showPhotos && photos.length) autoAchievements.push(photos.length + ' ' + plural(photos.length, 'фотография', 'фотографии', 'фотографий') + ' в архиве занятий');
if (showFiles && files.length) autoAchievements.push(files.length + ' ' + plural(files.length, 'работа', 'работы', 'работ') + ' в портфолио');
if (education.length) autoAchievements.push('Пройдено тем программы: ' + education.length + ' — ' + education.slice(0, 3).map(m => m.title).join(', '));
if (lessonReports.length) autoAchievements.push('Отчётов о занятиях: ' + lessonReports.length);
const achievements = profAchievements.length
? profAchievements.map(a => str(a, 200)).filter(Boolean)
: autoAchievements;
@@ -488,7 +538,7 @@ function buildProfileView(data, opts) {
const logo = /^[a-zA-Z0-9._-]+\.[a-zA-Z0-9]{1,8}$/.test(String(data.logo || '')) ? String(data.logo) : null;
return {
name, student, profile, entries, education, projects, photoCards, groupPhotoCards, studentPhotoCards, tasks, gallery, groups, stats,
name, student, profile, entries, education, projects, photoCards, groupPhotoCards, studentPhotoCards, tasks, lessonReports, gallery, groups, stats,
skillGroups, experience, contacts, achievements, facts, heroTags,
avatar, cover, idLabel, mainPhotoCard, profilePhotoCard, logo,
role: str(profile.role, 200),
@@ -739,6 +789,20 @@ svg{display:block;flex:none}
.about__list{display:flex;flex-direction:column;gap:10px}
.about__list li{display:flex;gap:10px;align-items:flex-start;font-size:.86rem;font-weight:600;color:var(--muted)}
.about__list svg{width:17px;height:17px;color:var(--ok);margin-top:2px;flex:none}
.lesson-reports{display:flex;flex-direction:column;gap:12px}
.lesson-report{padding:clamp(14px,1.8vw,20px);display:flex;flex-direction:column;gap:12px;min-width:0;transition:border-color .2s ease}
.lesson-report:hover{border-color:var(--stroke-strong)}
.lesson-report__head{display:flex;align-items:flex-start;gap:12px;min-width:0}
.lesson-report__icon{width:40px;height:40px;flex:none;display:grid;place-items:center;border-radius:12px;background:rgba(109,140,255,.16);border:1px solid rgba(109,140,255,.34);color:var(--brand)}
.lesson-report__icon svg{width:19px;height:19px;stroke-width:2.2}
.lesson-report__headmain{min-width:0;display:flex;flex-direction:column;gap:4px}
.lesson-report__title{font-size:1.02rem;font-weight:800;overflow-wrap:anywhere}
.lesson-report__sub{display:flex;align-items:center;gap:7px;font-size:.79rem;font-weight:600;color:var(--muted);flex-wrap:wrap;overflow-wrap:anywhere}
.lesson-report__sub svg{width:14px;height:14px;flex:none}
.lesson-report__text{display:flex;flex-direction:column;gap:9px;padding-top:12px;border-top:1px solid var(--stroke)}
.lesson-report__text p{font-size:.92rem;line-height:1.6;color:var(--muted);overflow-wrap:anywhere}
@media (max-width:520px){.lesson-report__icon{width:34px;height:34px;border-radius:10px}.lesson-report__icon svg{width:17px;height:17px}.lesson-report__title{font-size:.95rem}}
@media print{.lesson-report{page-break-inside:avoid}.lesson-reports{gap:10px}}
.empty{display:flex;flex-direction:column;align-items:center;gap:10px;padding:clamp(22px,3vw,38px);text-align:center;color:var(--muted);font-size:.9rem;font-weight:600}
.empty svg{width:30px;height:30px;stroke-width:1.8;opacity:.8}
.empty b{color:var(--text);font-weight:800;font-size:.98rem}
@@ -845,7 +909,7 @@ function heroHtml(v) {
actions.push('<button class="btn btn--primary" type="button" data-action="print">' + iconTag('printer') + 'Печать / PDF</button>');
const mail = v.contacts.find(c => (c.href || '').toLowerCase().startsWith('mailto:')) || v.contacts.find(c => c.href);
if (mail) actions.push('<a class="btn" href="' + escapeHtml(mail.href) + '">' + iconTag('send') + 'Написать</a>');
if (v.studentPhotoCards.length) actions.push('<a class="btn btn--ghost" href="#student-photos">' + iconTag('user-round') + 'Фото ученика</a>');
if (v.studentPhotoCards.length) actions.push('<a class="btn btn--ghost" href="#student-photos">' + iconTag('user-round') + 'Фото резидента</a>');
if (v.photoCards.length) actions.push('<a class="btn btn--ghost" href="#photos">' + iconTag('images') + 'Фотографии</a>');
const facts = v.facts.length
? v.facts.map(f => '<span class="fact">' + iconTag(f.icon) + '<span>' + escapeHtml(f.text) + '</span></span>').join('')
@@ -957,7 +1021,7 @@ function groupPhotosHtml(v) {
function studentPhotosHtml(v) {
if (!v.studentPhotoCards.length) {
return emptyBlock('image', 'Фотографий ученика пока нет',
return emptyBlock('image', 'Фотографий резидента пока нет',
'Личные фотографии резидента появятся здесь, как только их добавят в данных профиля.');
}
const total = v.studentPhotoCards.length;
@@ -966,7 +1030,7 @@ function studentPhotosHtml(v) {
mediaBlock(p.src, '4x3', v.name + ' — фото', p.g) +
'<div class="prj-card__body">' +
'<span class="badge ' + (p.main ? 'badge--ok' : 'badge--info') + '">' +
iconTag(p.main ? 'star' : 'image') + (p.main ? 'Главное фото' : 'Фото ученика') + '</span>' +
iconTag(p.main ? 'star' : 'image') + (p.main ? 'Главное фото' : 'Фото резидента') + '</span>' +
'<h3 class="prj-card__title">' + escapeHtml(total > 1 ? 'Фото ' + (i + 1) + ' из ' + total : 'Фотография резидента') + '</h3>' +
'<p class="prj-card__text">' + escapeHtml(
(p.main ? 'Личная фотография резидента' : 'Дополнительная фотография резидента') +
@@ -1000,6 +1064,25 @@ function tasksHtml(v) {
}).join('');
}
function lessonReportsHtml(v) {
if (!v.lessonReports.length) {
return emptyBlock('notebook-pen', 'Отчётов о занятиях пока нет',
'Конспекты занятий группы появятся здесь, как только их заполнит преподаватель.');
}
return v.lessonReports.map(r =>
'<article class="card lesson-report reveal">' +
'<div class="lesson-report__head">' +
'<span class="lesson-report__icon">' + iconTag(r.icon) + '</span>' +
'<div class="lesson-report__headmain">' +
'<h3 class="lesson-report__title">' + escapeHtml(r.title) + '</h3>' +
(r.sub ? '<p class="lesson-report__sub">' + iconTag('calendar-days') + escapeHtml(r.sub) + '</p>' : '') +
'</div></div>' +
(r.paragraphs.length
? '<div class="lesson-report__text">' + r.paragraphs.map(p => '<p>' + escapeHtml(p) + '</p>').join('') + '</div>'
: '') +
'</article>').join('');
}
function aboutHtml(v) {
const bio = '<article class="card about__bio reveal">' +
'<h3 class="section__title" style="font-size:1.15rem">' + iconTag('user-round') + 'Коротко о себе</h3>' +
@@ -1039,9 +1122,10 @@ function aboutHtml(v) {
function footerHtml(v, generatedAt) {
const links = [];
if (v.entries.length) links.push('<a href="#lessons">Занятия</a>');
if (v.studentPhotoCards.length) links.push('<a href="#student-photos">Фото ученика</a>');
if (v.studentPhotoCards.length) links.push('<a href="#student-photos">Фото резидента</a>');
if (v.photoCards.length) links.push('<a href="#photos">Фотографии</a>');
if (v.groupPhotoCards.length) links.push('<a href="#group-photos">Фото группы</a>');
if (v.lessonReports.length) links.push('<a href="#lesson-reports">Отчёты о занятиях</a>');
if (v.projects.length) links.push('<a href="#projects">Работы</a>');
links.push('<a href="#top">Наверх</a>');
return '<footer class="footer"><p>© <span id="year"></span> KIBERone · профиль резидента ' + escapeHtml(v.name) +
@@ -1052,15 +1136,16 @@ function footerHtml(v, generatedAt) {
function navItems(v) {
const all = [
{ id: 'profile', icon: 'user-round', label: 'Профиль', rank: 0 },
{ id: 'student-photos', icon: 'images', label: 'Фото ученика', rank: 1 },
{ id: 'student-photos', icon: 'images', label: 'Фото резидента', rank: 1 },
{ id: 'lessons', icon: 'list-checks', label: 'Занятия', rank: 2 },
{ id: 'projects', icon: 'folder-git-2', label: 'Работы', rank: 3 },
{ id: 'education', icon: 'book-open', label: 'Образование', rank: 4 },
{ id: 'about', icon: 'info', label: 'О себе', rank: 5 },
{ id: 'photos', icon: 'images', label: 'Фотографии', rank: 6 },
{ id: 'group-photos', icon: 'users-round', label: 'Фото группы', rank: 7 },
{ id: 'skills', icon: 'sparkles', label: 'Навыки', rank: 8 },
{ id: 'experience', icon: 'briefcase', label: 'Опыт', rank: 9 },
{ id: 'lesson-reports', icon: 'notebook-pen', label: 'Отчёты о занятиях', rank: 3 },
{ id: 'projects', icon: 'folder-git-2', label: 'Работы', rank: 4 },
{ id: 'education', icon: 'book-open', label: 'Образование', rank: 5 },
{ id: 'about', icon: 'info', label: 'О себе', rank: 6 },
{ id: 'photos', icon: 'images', label: 'Фотографии', rank: 7 },
{ id: 'group-photos', icon: 'users-round', label: 'Фото группы', rank: 8 },
{ id: 'skills', icon: 'sparkles', label: 'Навыки', rank: 9 },
{ id: 'experience', icon: 'briefcase', label: 'Опыт', rank: 10 },
];
const sections = {
'profile': true,
@@ -1072,6 +1157,7 @@ function navItems(v) {
'skills': v.skillGroups.length > 0,
'projects': v.projects.length > 0,
'lessons': v.tasks.length > 0,
'lesson-reports': v.lessonReports.length > 0,
'about': true,
};
return all
@@ -1218,7 +1304,7 @@ function renderStudentReport(data, opts) {
(periodChip ? '<div class="hero__tags">' + periodChip + '</div>' : '') +
'<div class="stats">' + statsHtml(v) + '</div></section>');
sections.push('<section class="section" id="student-photos">' +
sectionHead('user-round', 'Фотографии ученика', v.studentPhotoCards.length > 1
sectionHead('user-round', 'Фотографии резидента', v.studentPhotoCards.length > 1
? 'Личные фотографии резидента. Нажмите «Просмотр», чтобы открыть снимок крупнее.'
: 'Личная фотография резидента. Нажмите «Просмотр», чтобы открыть её крупнее.') +
studentPhotosHtml(v) + '</section>');
@@ -1247,6 +1333,12 @@ function renderStudentReport(data, opts) {
sections.push('<section class="section" id="lessons">' +
sectionHead('list-checks', 'Занятия', 'Записи журнала: что резидент делал, фотографии и файлы занятий.') +
'<div class="tasks">' + tasksHtml(v) + '</div></section>');
if (v.lessonReports.length) {
sections.push('<section class="section" id="lesson-reports">' +
sectionHead('notebook-pen', 'Отчёты о занятиях', 'Что проходили на занятиях группы: тема, конспект и разбор, зафиксированные преподавателем.' +
(data.lessonReportsTruncated ? ' Показаны последние ' + v.lessonReports.length + ' отчётов.' : '')) +
'<div class="lesson-reports">' + lessonReportsHtml(v) + '</div></section>');
}
sections.push('<section class="section" id="about">' +
sectionHead('info', 'Немного о себе', 'Рассказ резидента, контакты и достижения.') +
'<div class="about">' + aboutHtml(v) + '</div></section>');