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:
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user