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
+40 -4
View File
@@ -4158,13 +4158,15 @@ app.get('/api/export/student', requireAuth, async (req, res) => {
try {
name = reqStr(req.query.name, 150);
} catch {
return res.status(400).json({ error: 'Укажите имя ученика' });
return res.status(400).json({ error: 'Укажите имя резидента' });
}
const LESSON_REPORTS_LIMIT = 60;
const opts = {
includeEntries: req.query.include_entries !== '0',
includePhotos: req.query.include_photos !== '0',
includeFiles: req.query.include_files !== '0',
includeCaptions: req.query.include_captions !== '0',
includeReports: req.query.include_reports !== '0',
showDates: req.query.show_dates !== '0',
};
if (!opts.includeEntries && !opts.includePhotos && !opts.includeFiles) {
@@ -4211,7 +4213,7 @@ app.get('/api/export/student', requireAuth, async (req, res) => {
WHERE s.name = $1`,
[name]
),
pool.query(`SELECT e.id, e.description, e.created_at, e.module_id, e.ai_status, g.name AS group_name, m.name AS module_name
pool.query(`SELECT e.id, e.description, e.created_at, e.module_id, e.ai_status, e.group_id, g.name AS group_name, m.name AS module_name
FROM entries e JOIN groups g ON g.id = e.group_id
LEFT JOIN modules m ON m.id = e.module_id${whereStr}
ORDER BY e.created_at DESC`, params),
@@ -4247,12 +4249,41 @@ app.get('/api/export/student', requireAuth, async (req, res) => {
]);
const entryRows = entriesRes.rows;
if (!entryRows.length && !photosRes.rows.length && !filesRes.rows.length) {
return res.status(404).json({ error: hasPeriod ? 'Нет данных за выбранный период' : 'У ученика нет данных для отчёта' });
return res.status(404).json({ error: hasPeriod ? 'Нет данных за выбранный период' : 'У резидента нет данных для отчёта' });
}
const student = studRes.rows[0] || { name };
const studentPhotosRes = student.id
? await pool.query('SELECT photo_path, created_at FROM student_photos WHERE student_id = $1 ORDER BY id DESC', [student.id])
: { rows: [] };
const lessonReportsRes = opts.includeReports
? await (async () => {
const groupIds = [...new Set([student.group_id, ...entryRows.map(e => e.group_id)].filter(id => Number.isInteger(id)))];
if (!groupIds.length) return { rows: [], truncated: false };
const conds = ['lr.group_id = ANY($1::int[])'];
const rp = [groupIds];
if (dateFrom) {
rp.push(dateFrom);
conds.push(`lr.lesson_date >= $${rp.length}`);
}
if (dateTo) {
rp.push(dateTo);
conds.push(`lr.lesson_date <= $${rp.length}`);
}
if (bw.params.length) {
const start = rp.length + 1;
conds.push(`g.branch_id IN (${bw.params.map((_, i) => '$' + (start + i)).join(',')})`);
rp.push(...bw.params);
}
const { rows } = await pool.query(
`SELECT lr.id, lr.group_id, lr.lesson_date, lr.lesson_time, lr.topic, lr.text, g.name AS group_name
FROM lesson_reports lr
JOIN groups g ON g.id = lr.group_id
WHERE ${conds.join(' AND ')}
ORDER BY lr.lesson_date DESC, lr.lesson_time DESC NULLS LAST
LIMIT $${rp.length + 1}`, [...rp, LESSON_REPORTS_LIMIT + 1]);
return { rows, truncated: rows.length > LESSON_REPORTS_LIMIT };
})()
: { rows: [], truncated: false };
const zip = createZipWriter();
if (opts.includePhotos) zip.addDir('photos');
if (opts.includeFiles) zip.addDir('files');
@@ -4410,6 +4441,9 @@ app.get('/api/export/student', requireAuth, async (req, res) => {
groupPhotos: groupPhotosBuilt,
studentPhotos: studentPhotosBuilt,
files: filesBuilt,
lessonReports: opts.includeReports ? lessonReportsRes.rows.slice(0, LESSON_REPORTS_LIMIT) : [],
lessonReportsTruncated: opts.includeReports && lessonReportsRes.truncated,
hour12: await appHour12(),
generatedAt: new Date(),
period,
logo: logoFile,
@@ -4421,12 +4455,13 @@ app.get('/api/export/student', requireAuth, async (req, res) => {
student: reportData.student,
profile: student.profile || null,
period: period || null,
totals: { entries: entryRows.length, modules: modulesRes.rows.length, photos: photosBuilt.length, files: filesBuilt.length, groupPhotos: groupPhotosBuilt.length, studentPhotos: studentPhotosBuilt.length },
totals: { entries: entryRows.length, modules: modulesRes.rows.length, photos: photosBuilt.length, files: filesBuilt.length, groupPhotos: groupPhotosBuilt.length, studentPhotos: studentPhotosBuilt.length, lessonReports: reportData.lessonReports.length },
options: opts,
entries: entryRows.map(e => ({ id: e.id, group: e.group_name, module: e.module_name || null, created_at: e.created_at, ai_status: e.ai_status, description: e.description })),
photos: photosBuilt.map(p => ({ file: 'photos/' + p.stored, caption: p.caption, created_at: p.createdAt, entry_id: p.entryId })),
student_photos: studentPhotosBuilt.map(p => ({ file: 'photos/' + p.stored, main: !!p.main, created_at: p.createdAt })),
files: filesBuilt.map(f => ({ file: 'files/' + f.saved, name: f.original, size: f.size, created_at: f.createdAt, entry_id: f.entryId })),
lesson_reports: reportData.lessonReports.map(r => ({ id: r.id, group_id: r.group_id, group_name: r.group_name, lesson_date: r.lesson_date, lesson_time: r.lesson_time, topic: r.topic || null, text: r.text })),
};
zip.addFile('data.json', Buffer.from(JSON.stringify(meta, null, 2), 'utf8'));
@@ -4436,6 +4471,7 @@ app.get('/api/export/student', requireAuth, async (req, res) => {
entries: entryRows.length,
photos: photosBuilt.length,
files: filesBuilt.length,
lesson_reports: reportData.lessonReports.length,
opts,
date_from: dateFrom,
date_to: dateTo,