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
+3
View File
@@ -144,6 +144,7 @@ function openExportModal(name) {
document.getElementById('exportForm').reset(); document.getElementById('exportForm').reset();
document.getElementById('exportDateFrom').value = ''; document.getElementById('exportDateFrom').value = '';
document.getElementById('expEntries').checked = true; document.getElementById('expEntries').checked = true;
document.getElementById('expReports').checked = true;
document.getElementById('expPhotos').checked = true; document.getElementById('expPhotos').checked = true;
document.getElementById('expCaptions').checked = true; document.getElementById('expCaptions').checked = true;
document.getElementById('expFiles').checked = true; document.getElementById('expFiles').checked = true;
@@ -168,6 +169,7 @@ async function exportStudent(name, opts) {
if (opts.dateFrom) q.set('date_from', opts.dateFrom); if (opts.dateFrom) q.set('date_from', opts.dateFrom);
if (opts.dateTo) q.set('date_to', opts.dateTo); if (opts.dateTo) q.set('date_to', opts.dateTo);
q.set('include_entries', opts.entries ? '1' : '0'); q.set('include_entries', opts.entries ? '1' : '0');
q.set('include_reports', opts.reports === false ? '0' : '1');
q.set('include_photos', opts.photos ? '1' : '0'); q.set('include_photos', opts.photos ? '1' : '0');
q.set('include_captions', opts.captions ? '1' : '0'); q.set('include_captions', opts.captions ? '1' : '0');
q.set('include_files', opts.files ? '1' : '0'); q.set('include_files', opts.files ? '1' : '0');
@@ -192,6 +194,7 @@ async function submitExport() {
const captionsEl = document.getElementById('expCaptions'); const captionsEl = document.getElementById('expCaptions');
const opts = { const opts = {
entries: document.getElementById('expEntries').checked, entries: document.getElementById('expEntries').checked,
reports: document.getElementById('expReports').checked,
photos: document.getElementById('expPhotos').checked, photos: document.getElementById('expPhotos').checked,
files: document.getElementById('expFiles').checked, files: document.getElementById('expFiles').checked,
captions: captionsEl.checked && !captionsEl.disabled, captions: captionsEl.checked && !captionsEl.disabled,
+6
View File
@@ -73,6 +73,12 @@
Записи занятий (текст) Записи занятий (текст)
</label> </label>
</div> </div>
<div class="settings-field full">
<label style="display:flex;align-items:center;gap:8px;cursor:pointer">
<input type="checkbox" id="expReports" checked>
Отчёты о занятиях (конспекты преподавателя)
</label>
</div>
<div class="settings-field full"> <div class="settings-field full">
<label style="display:flex;align-items:center;gap:8px;cursor:pointer"> <label style="display:flex;align-items:center;gap:8px;cursor:pointer">
<input type="checkbox" id="expPhotos" checked> <input type="checkbox" id="expPhotos" checked>
+40 -4
View File
@@ -4158,13 +4158,15 @@ app.get('/api/export/student', requireAuth, async (req, res) => {
try { try {
name = reqStr(req.query.name, 150); name = reqStr(req.query.name, 150);
} catch { } catch {
return res.status(400).json({ error: 'Укажите имя ученика' }); return res.status(400).json({ error: 'Укажите имя резидента' });
} }
const LESSON_REPORTS_LIMIT = 60;
const opts = { const opts = {
includeEntries: req.query.include_entries !== '0', includeEntries: req.query.include_entries !== '0',
includePhotos: req.query.include_photos !== '0', includePhotos: req.query.include_photos !== '0',
includeFiles: req.query.include_files !== '0', includeFiles: req.query.include_files !== '0',
includeCaptions: req.query.include_captions !== '0', includeCaptions: req.query.include_captions !== '0',
includeReports: req.query.include_reports !== '0',
showDates: req.query.show_dates !== '0', showDates: req.query.show_dates !== '0',
}; };
if (!opts.includeEntries && !opts.includePhotos && !opts.includeFiles) { if (!opts.includeEntries && !opts.includePhotos && !opts.includeFiles) {
@@ -4211,7 +4213,7 @@ app.get('/api/export/student', requireAuth, async (req, res) => {
WHERE s.name = $1`, WHERE s.name = $1`,
[name] [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 FROM entries e JOIN groups g ON g.id = e.group_id
LEFT JOIN modules m ON m.id = e.module_id${whereStr} LEFT JOIN modules m ON m.id = e.module_id${whereStr}
ORDER BY e.created_at DESC`, params), ORDER BY e.created_at DESC`, params),
@@ -4247,12 +4249,41 @@ app.get('/api/export/student', requireAuth, async (req, res) => {
]); ]);
const entryRows = entriesRes.rows; const entryRows = entriesRes.rows;
if (!entryRows.length && !photosRes.rows.length && !filesRes.rows.length) { 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 student = studRes.rows[0] || { name };
const studentPhotosRes = student.id 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]) ? await pool.query('SELECT photo_path, created_at FROM student_photos WHERE student_id = $1 ORDER BY id DESC', [student.id])
: { rows: [] }; : { 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(); const zip = createZipWriter();
if (opts.includePhotos) zip.addDir('photos'); if (opts.includePhotos) zip.addDir('photos');
if (opts.includeFiles) zip.addDir('files'); if (opts.includeFiles) zip.addDir('files');
@@ -4410,6 +4441,9 @@ app.get('/api/export/student', requireAuth, async (req, res) => {
groupPhotos: groupPhotosBuilt, groupPhotos: groupPhotosBuilt,
studentPhotos: studentPhotosBuilt, studentPhotos: studentPhotosBuilt,
files: filesBuilt, files: filesBuilt,
lessonReports: opts.includeReports ? lessonReportsRes.rows.slice(0, LESSON_REPORTS_LIMIT) : [],
lessonReportsTruncated: opts.includeReports && lessonReportsRes.truncated,
hour12: await appHour12(),
generatedAt: new Date(), generatedAt: new Date(),
period, period,
logo: logoFile, logo: logoFile,
@@ -4421,12 +4455,13 @@ app.get('/api/export/student', requireAuth, async (req, res) => {
student: reportData.student, student: reportData.student,
profile: student.profile || null, profile: student.profile || null,
period: period || 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, 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 })), 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 })), 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 })), 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 })), 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')); 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, entries: entryRows.length,
photos: photosBuilt.length, photos: photosBuilt.length,
files: filesBuilt.length, files: filesBuilt.length,
lesson_reports: reportData.lessonReports.length,
opts, opts,
date_from: dateFrom, date_from: dateFrom,
date_to: dateTo, date_to: dateTo,
+107 -15
View File
@@ -122,6 +122,23 @@ function longDate(iso) {
return d.getDate() + ' ' + MONTHS_GEN[d.getMonth()] + ' ' + d.getFullYear(); 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) { function monthYear(iso) {
const d = asDate(iso); const d = asDate(iso);
if (!d) return ''; if (!d) return '';
@@ -236,6 +253,25 @@ function iconForGroup(title) {
return 'sparkles'; 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) { function buildProfileView(data, opts) {
const o = opts || {}; const o = opts || {};
const showEntries = o.includeEntries !== false; const showEntries = o.includeEntries !== false;
@@ -243,6 +279,8 @@ function buildProfileView(data, opts) {
const showFiles = o.includeFiles !== false; const showFiles = o.includeFiles !== false;
const showCaptions = o.includeCaptions !== false; const showCaptions = o.includeCaptions !== false;
const showDates = o.showDates !== false; const showDates = o.showDates !== false;
const showReports = o.includeReports !== false;
const hour12 = data.hour12 === true;
const student = data.student || {}; const student = data.student || {};
const profile = (data.profile && typeof data.profile === 'object' && !Array.isArray(data.profile)) ? data.profile : {}; const profile = (data.profile && typeof data.profile === 'object' && !Array.isArray(data.profile)) ? data.profile : {};
const entries = arr(data.entries); const entries = arr(data.entries);
@@ -297,7 +335,7 @@ function buildProfileView(data, opts) {
const studentPhotoCards = showPhotos ? arr(data.studentPhotos).map(sp => { const studentPhotoCards = showPhotos ? arr(data.studentPhotos).map(sp => {
const src = 'photos/' + sp.stored; const src = 'photos/' + sp.stored;
const g = galleryAdd('image', src, sp.main ? 'Главное фото' : 'Фото ученика'); const g = galleryAdd('image', src, sp.main ? 'Главное фото' : 'Фото резидента');
return { return {
src, src,
g, 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(); const groupSet = new Set();
if (student.group_name) groupSet.add(student.group_name); if (student.group_name) groupSet.add(student.group_name);
for (const e of entries) if (e.group_name) groupSet.add(e.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 (showPhotos && photos.length) autoAchievements.push(photos.length + ' ' + plural(photos.length, 'фотография', 'фотографии', 'фотографий') + ' в архиве занятий');
if (showFiles && files.length) autoAchievements.push(files.length + ' ' + plural(files.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 (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 const achievements = profAchievements.length
? profAchievements.map(a => str(a, 200)).filter(Boolean) ? profAchievements.map(a => str(a, 200)).filter(Boolean)
: autoAchievements; : 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; const logo = /^[a-zA-Z0-9._-]+\.[a-zA-Z0-9]{1,8}$/.test(String(data.logo || '')) ? String(data.logo) : null;
return { 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, skillGroups, experience, contacts, achievements, facts, heroTags,
avatar, cover, idLabel, mainPhotoCard, profilePhotoCard, logo, avatar, cover, idLabel, mainPhotoCard, profilePhotoCard, logo,
role: str(profile.role, 200), 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{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 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} .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{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 svg{width:30px;height:30px;stroke-width:1.8;opacity:.8}
.empty b{color:var(--text);font-weight:800;font-size:.98rem} .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>'); 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); 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 (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>'); if (v.photoCards.length) actions.push('<a class="btn btn--ghost" href="#photos">' + iconTag('images') + 'Фотографии</a>');
const facts = v.facts.length const facts = v.facts.length
? v.facts.map(f => '<span class="fact">' + iconTag(f.icon) + '<span>' + escapeHtml(f.text) + '</span></span>').join('') ? 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) { function studentPhotosHtml(v) {
if (!v.studentPhotoCards.length) { if (!v.studentPhotoCards.length) {
return emptyBlock('image', 'Фотографий ученика пока нет', return emptyBlock('image', 'Фотографий резидента пока нет',
'Личные фотографии резидента появятся здесь, как только их добавят в данных профиля.'); 'Личные фотографии резидента появятся здесь, как только их добавят в данных профиля.');
} }
const total = v.studentPhotoCards.length; const total = v.studentPhotoCards.length;
@@ -966,7 +1030,7 @@ function studentPhotosHtml(v) {
mediaBlock(p.src, '4x3', v.name + ' — фото', p.g) + mediaBlock(p.src, '4x3', v.name + ' — фото', p.g) +
'<div class="prj-card__body">' + '<div class="prj-card__body">' +
'<span class="badge ' + (p.main ? 'badge--ok' : 'badge--info') + '">' + '<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>' + '<h3 class="prj-card__title">' + escapeHtml(total > 1 ? 'Фото ' + (i + 1) + ' из ' + total : 'Фотография резидента') + '</h3>' +
'<p class="prj-card__text">' + escapeHtml( '<p class="prj-card__text">' + escapeHtml(
(p.main ? 'Личная фотография резидента' : 'Дополнительная фотография резидента') + (p.main ? 'Личная фотография резидента' : 'Дополнительная фотография резидента') +
@@ -1000,6 +1064,25 @@ function tasksHtml(v) {
}).join(''); }).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) { function aboutHtml(v) {
const bio = '<article class="card about__bio reveal">' + const bio = '<article class="card about__bio reveal">' +
'<h3 class="section__title" style="font-size:1.15rem">' + iconTag('user-round') + 'Коротко о себе</h3>' + '<h3 class="section__title" style="font-size:1.15rem">' + iconTag('user-round') + 'Коротко о себе</h3>' +
@@ -1039,9 +1122,10 @@ function aboutHtml(v) {
function footerHtml(v, generatedAt) { function footerHtml(v, generatedAt) {
const links = []; const links = [];
if (v.entries.length) links.push('<a href="#lessons">Занятия</a>'); 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.photoCards.length) links.push('<a href="#photos">Фотографии</a>');
if (v.groupPhotoCards.length) links.push('<a href="#group-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>'); if (v.projects.length) links.push('<a href="#projects">Работы</a>');
links.push('<a href="#top">Наверх</a>'); links.push('<a href="#top">Наверх</a>');
return '<footer class="footer"><p>© <span id="year"></span> KIBERone · профиль резидента ' + escapeHtml(v.name) + return '<footer class="footer"><p>© <span id="year"></span> KIBERone · профиль резидента ' + escapeHtml(v.name) +
@@ -1052,15 +1136,16 @@ function footerHtml(v, generatedAt) {
function navItems(v) { function navItems(v) {
const all = [ const all = [
{ id: 'profile', icon: 'user-round', label: 'Профиль', rank: 0 }, { 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: 'lessons', icon: 'list-checks', label: 'Занятия', rank: 2 },
{ id: 'projects', icon: 'folder-git-2', label: 'Работы', rank: 3 }, { id: 'lesson-reports', icon: 'notebook-pen', label: 'Отчёты о занятиях', rank: 3 },
{ id: 'education', icon: 'book-open', label: 'Образование', rank: 4 }, { id: 'projects', icon: 'folder-git-2', label: 'Работы', rank: 4 },
{ id: 'about', icon: 'info', label: 'О себе', rank: 5 }, { id: 'education', icon: 'book-open', label: 'Образование', rank: 5 },
{ id: 'photos', icon: 'images', label: 'Фотографии', rank: 6 }, { id: 'about', icon: 'info', label: 'О себе', rank: 6 },
{ id: 'group-photos', icon: 'users-round', label: 'Фото группы', rank: 7 }, { id: 'photos', icon: 'images', label: 'Фотографии', rank: 7 },
{ id: 'skills', icon: 'sparkles', label: 'Навыки', rank: 8 }, { id: 'group-photos', icon: 'users-round', label: 'Фото группы', rank: 8 },
{ id: 'experience', icon: 'briefcase', label: 'Опыт', rank: 9 }, { id: 'skills', icon: 'sparkles', label: 'Навыки', rank: 9 },
{ id: 'experience', icon: 'briefcase', label: 'Опыт', rank: 10 },
]; ];
const sections = { const sections = {
'profile': true, 'profile': true,
@@ -1072,6 +1157,7 @@ function navItems(v) {
'skills': v.skillGroups.length > 0, 'skills': v.skillGroups.length > 0,
'projects': v.projects.length > 0, 'projects': v.projects.length > 0,
'lessons': v.tasks.length > 0, 'lessons': v.tasks.length > 0,
'lesson-reports': v.lessonReports.length > 0,
'about': true, 'about': true,
}; };
return all return all
@@ -1218,7 +1304,7 @@ function renderStudentReport(data, opts) {
(periodChip ? '<div class="hero__tags">' + periodChip + '</div>' : '') + (periodChip ? '<div class="hero__tags">' + periodChip + '</div>' : '') +
'<div class="stats">' + statsHtml(v) + '</div></section>'); '<div class="stats">' + statsHtml(v) + '</div></section>');
sections.push('<section class="section" id="student-photos">' + 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>'); studentPhotosHtml(v) + '</section>');
@@ -1247,6 +1333,12 @@ function renderStudentReport(data, opts) {
sections.push('<section class="section" id="lessons">' + sections.push('<section class="section" id="lessons">' +
sectionHead('list-checks', 'Занятия', 'Записи журнала: что резидент делал, фотографии и файлы занятий.') + sectionHead('list-checks', 'Занятия', 'Записи журнала: что резидент делал, фотографии и файлы занятий.') +
'<div class="tasks">' + tasksHtml(v) + '</div></section>'); '<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">' + sections.push('<section class="section" id="about">' +
sectionHead('info', 'Немного о себе', 'Рассказ резидента, контакты и достижения.') + sectionHead('info', 'Немного о себе', 'Рассказ резидента, контакты и достижения.') +
'<div class="about">' + aboutHtml(v) + '</div></section>'); '<div class="about">' + aboutHtml(v) + '</div></section>');