Files
WhatIDo/public/js/students.js
T
dev 3c127b895e feat(datetime): timezone/time_format в настройках + единый хелпер дат
Часовой пояс и формат времени (24h/12h) перенесены из браузера в настройки
приложения: валидация IANA-зоны, сид в db/init.sql + db/migration.sql, отдача
в GET /api/public-settings, раздел #sec-datetime в настройках с живым превью.

Границы суток в SQL переведены на tzDayStart/tzDayEnd + bindTz ($TZ$) — раньше
$n::date по TIMESTAMPTZ считал дни в UTC (у контейнера TimeZone=UTC) и молча
сдвигал выборку на день; хардкод Europe/Moscow вычищен, now() заменён на
tzWall().

Новый public/js/datetime.js: три семейства хелперов (instant / чистая
DATE-строка без Date() / чистая TIME-строка), подключён на всех страницах
включая публичные share/report/index, initDateTime() встроен в checkAuth().
Прямые toLocale*/getFullYear/toISOString().slice(0,10) в public/ выпилены.

Попутно: bindTz добавляет параметр только при наличии $TZ$ в SQL (иначе запрос
виснет вечно без global error handler) — регрессия закрыта в api.smoketest.js;
починены off-by-one месяца в report.js и группировка по дате в share.js.
2026-10-04 10:49:48 +03:00

647 lines
27 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
let allStudents = [];
let allGroups = [];
let groupOptionsLoaded = false;
let sPage = 1;
const S_PAGE_SIZE = 25;
let sSearchTimer = null;
let aSearchTimer = null;
let attachGroupLoaded = false;
async function loadGroupOptions(sel, selected) {
if (!groupOptionsLoaded) {
const res = await fetch(`${API}/api/groups`, { headers: hdr() });
allGroups = await res.json();
groupOptionsLoaded = true;
const filt = document.getElementById('studentGroupFilter');
if (filt && filt.options.length <= 1) {
filt.innerHTML = `<option value="">Все группы</option>` +
allGroups.map(g => `<option value="${g.id}">${esc(g.name)}</option>`).join('');
}
}
if (sel) {
sel.innerHTML = `<option value="">— не привязана —</option>` +
allGroups.map(g => `<option value="${g.id}" ${selected == g.id ? 'selected' : ''}>${esc(g.name)}</option>`).join('');
}
}
async function loadStudents() {
const res = await fetch(`${API}/api/students`, { headers: hdr() });
allStudents = await res.json();
renderStudents();
}
function studentInitials(name) {
const parts = String(name || '').trim().split(/\s+/).filter(Boolean);
if (!parts.length) return 'У';
const a = parts[0].charAt(0);
const b = parts.length > 1 ? parts[1].charAt(0) : (parts[0].length > 1 ? parts[0].charAt(1) : '');
return (a + b).toUpperCase();
}
function photoFullSrc(p) {
if (!p) return '';
return p.startsWith('/') ? `${API}${p}` : p;
}
function setStudentAvatar(el, path, name) {
if (!el) return;
if (path) {
el.classList.remove('empty');
el.dataset.studentPhoto = path;
el.title = 'Увеличить фото';
el.innerHTML = `<img src="${esc(thumbSrc(path))}" alt="" loading="lazy">`;
const img = el.querySelector('img');
if (img) img.addEventListener('error', () => setStudentAvatar(el, '', name), { once: true });
} else {
el.classList.add('empty');
delete el.dataset.studentPhoto;
el.title = 'Фото не загружено — нажмите, чтобы загрузить';
el.innerHTML = `<span>${esc(studentInitials(name))}</span>`;
}
}
async function syncStudentAvatar(id, photoPath) {
const el = document.getElementById(`savatar-${id}`);
if (!el) return;
const nameInput = document.getElementById(`sname-${id}`);
const name = nameInput ? nameInput.value : '';
if (photoPath !== undefined) {
setStudentAvatar(el, photoPath || '', name);
return;
}
try {
const res = await fetch(`${API}/api/students/${id}/photos`, { headers: hdr() });
if (!res.ok) return;
const data = await res.json();
setStudentAvatar(el, data.photo_path || '', name);
} catch (err) {}
}
function renderStudents() {
const q = document.getElementById('studentSearch').value.trim().toLowerCase();
const gid = document.getElementById('studentGroupFilter').value;
let filtered = q ? allStudents.filter(s => s.name.toLowerCase().includes(q)) : allStudents;
if (gid) filtered = filtered.filter(s => String(s.group_id) === gid);
const totalPages = Math.max(1, Math.ceil(filtered.length / S_PAGE_SIZE));
if (sPage > totalPages) sPage = 1;
const slice = filtered.slice((sPage - 1) * S_PAGE_SIZE, sPage * S_PAGE_SIZE);
const list = document.getElementById('studentList');
if (!slice.length) { list.innerHTML = '<div style="color:var(--muted);text-align:center;padding:32px">Ничего не найдено</div>'; }
else {
list.innerHTML = slice.map(s => `
<div class="item">
<div class="student-main">
<button type="button" class="student-avatar" id="savatar-${s.id}" data-avatar-student="${s.id}"></button>
<input class="name-input student-name-input" id="sname-${s.id}" value="${esc(s.name)}">
</div>
<select class="group-select" id="sgroup-${s.id}"></select>
<div class="student-actions">
<button class="btn-save" data-save-student="${s.id}">Сохранить</button>
<button class="btn-link" data-profile-student="${s.id}" title="Скачать профиль ученика (ZIP: фото и работы)">Профиль</button>
<button class="btn-link" data-profile-data="${s.id}" title="Данные профиля: заполняют разделы страницы профиля в архиве">Данные</button>
<button class="btn-link" data-export-student="${esc(s.name)}" title="Сформировать ZIP-отчёт: фотографии и файлы">Отчёт</button>
<button class="btn-link" data-copy-student="${esc(s.name)}" title="Скопировать ссылку с подстановкой имени">Ссылка</button>
<button class="btn-del" data-del-student="${s.id}" title="Удалить">&times;</button>
</div>
</div>
`).join('');
slice.forEach(s => {
setStudentAvatar(document.getElementById(`savatar-${s.id}`), s.photo_path, s.name);
loadGroupOptions(document.getElementById(`sgroup-${s.id}`), s.group_id);
});
}
renderPager(document.getElementById('pager'), sPage, totalPages, goStudentPage);
}
function goStudentPage(p) {
sPage = p;
renderStudents();
}
async function addStudent() {
const input = document.getElementById('newStudent');
const name = input.value.trim();
if (!name) return;
const res = await fetch(`${API}/api/students`, { method: 'POST', headers: hdrJson(), body: JSON.stringify({ name }) });
if (res.ok) { input.value = ''; loadStudents(); fillAutocomplete(); showToast(`Добавлен: ${name}`); }
else { const e = await res.json(); alert(e.error); }
}
async function saveStudent(id) {
const name = document.getElementById(`sname-${id}`).value.trim();
if (!name) return;
const group_id = document.getElementById(`sgroup-${id}`).value;
const res = await fetch(`${API}/api/students/${id}`, { method: 'PUT', headers: hdrJson(), body: JSON.stringify({ name, group_id }) });
if (res.ok) { loadStudents(); fillAutocomplete(); showToast(`Сохранено: ${name}`); }
else { const e = await res.json(); alert(e.error); }
}
let exportPendingName = null;
function openExportModal(name) {
exportPendingName = name;
document.getElementById('exportName').textContent = name;
document.getElementById('exportForm').reset();
document.getElementById('exportDateFrom').value = '';
document.getElementById('expEntries').checked = true;
document.getElementById('expPhotos').checked = true;
document.getElementById('expCaptions').checked = true;
document.getElementById('expFiles').checked = true;
document.getElementById('expDates').checked = true;
document.getElementById('exportModal').classList.add('open');
}
function closeExportModal() {
document.getElementById('exportModal').classList.remove('open');
exportPendingName = null;
}
function onExportPhotosToggle() {
const on = document.getElementById('expPhotos').checked;
document.getElementById('expCaptions').disabled = !on;
}
async function exportStudent(name, opts) {
showToast(`Формируем отчёт: ${name}...`);
try {
const q = new URLSearchParams({ name });
if (opts.dateFrom) q.set('date_from', opts.dateFrom);
if (opts.dateTo) q.set('date_to', opts.dateTo);
q.set('include_entries', opts.entries ? '1' : '0');
q.set('include_photos', opts.photos ? '1' : '0');
q.set('include_captions', opts.captions ? '1' : '0');
q.set('include_files', opts.files ? '1' : '0');
q.set('show_dates', opts.dates ? '1' : '0');
const res = await fetch(`${API}/api/export/student?${q}`, { headers: hdr() });
if (!res.ok) {
const e = await res.json();
alert(e.error || 'Не удалось сформировать отчёт');
return;
}
const blob = await res.blob();
downloadBlob(blob, `student_${safeWindowsName(name)}_report.zip`);
showToast(`Готово: ${name}`);
} catch (err) {
alert('Ошибка при формировании отчёта');
}
}
async function submitExport() {
const name = exportPendingName;
if (!name) return;
const captionsEl = document.getElementById('expCaptions');
const opts = {
entries: document.getElementById('expEntries').checked,
photos: document.getElementById('expPhotos').checked,
files: document.getElementById('expFiles').checked,
captions: captionsEl.checked && !captionsEl.disabled,
dates: document.getElementById('expDates').checked,
dateFrom: document.getElementById('exportDateFrom').value,
dateTo: document.getElementById('exportDateTo').value,
};
if (!opts.entries && !opts.photos && !opts.files) {
alert('Выберите хотя бы одну категорию: записи, фото или файлы');
return;
}
if (opts.dateFrom && opts.dateTo && opts.dateFrom > opts.dateTo) {
alert('Дата «С» позже даты «По»');
return;
}
closeExportModal();
await exportStudent(name, opts);
}
async function exportStudentProfile(id) {
const s = allStudents.find(x => x.id === id);
if (!s) return;
const btn = document.querySelector(`[data-profile-student="${id}"]`);
const label = btn ? btn.textContent : '';
if (btn) { btn.disabled = true; btn.textContent = 'Готовим...'; }
showToast(`Формируем профиль: ${s.name}...`);
try {
const res = await fetch(`${API}/api/export/student?name=${encodeURIComponent(s.name)}`, { headers: hdr() });
if (!res.ok) {
const e = await res.json().catch(() => ({}));
showToast(e.error || 'Не удалось сформировать профиль');
return;
}
const blob = await res.blob();
downloadBlob(blob, `profile_${safeWindowsName(s.name)}_${todayIso()}.zip`);
showToast(`Готово: ${s.name}`);
} catch (err) {
showToast('Ошибка при формировании профиля');
} finally {
if (btn) { btn.disabled = false; btn.textContent = label; }
}
}
let profileDataId = null;
let profilePhotos = [];
function openProfileData(id) {
profileDataId = id;
loadGroupOptions();
loadStudentPhotos(id);
fetch(`${API}/api/students/${id}/profile`, { headers: hdr() })
.then(r => (r.ok ? r.json() : Promise.reject(new Error('load'))))
.then(data => fillProfileForm(data.profile, data.name, data.photo_path))
.catch(() => fillProfileForm(null, '', ''));
document.getElementById('profileDataModal').classList.add('open');
}
function closeProfileData() {
document.getElementById('profileDataModal').classList.remove('open');
profileDataId = null;
}
async function loadStudentPhotos(id) {
try {
const res = await fetch(`${API}/api/students/${id}/photos`, { headers: hdr() });
if (!res.ok) return;
const data = await res.json();
profilePhotos = data.photos || [];
document.getElementById('pfPhoto').value = data.photo_path || '';
renderStudentPhotos();
} catch (err) {
profilePhotos = [];
}
}
function renderStudentPhotos() {
const wrap = document.getElementById('pfPhotos');
if (!wrap) return;
const main = document.getElementById('pfPhoto').value.trim();
if (!profilePhotos.length) {
wrap.innerHTML = '<span class="hint" style="margin:0">Пока нет загруженных фото</span>';
return;
}
wrap.innerHTML = profilePhotos.map(p => `
<div class="pf-photo${p.photo_path === main ? ' main' : ''}" data-pf-set="${p.id}" title="Сделать главным">
<img src="${esc(p.photo_path)}" alt="" loading="lazy">
${p.photo_path === main ? '<span class="pf-badge">Главная</span>' : ''}
<button type="button" class="pf-del" data-pf-del="${p.id}" title="Удалить фото">✕</button>
</div>
`).join('');
}
async function uploadStudentPhoto(file) {
if (!profileDataId || !file) return;
if (!file.type.startsWith('image/')) {
alert('Можно загружать только изображения');
return;
}
showToast('Загружаем фото...');
const fd = new FormData();
fd.append('photo', file);
try {
const res = await fetch(`${API}/api/students/${profileDataId}/photos`, { method: 'POST', headers: hdr(), body: fd });
if (res.ok) {
await loadStudentPhotos(profileDataId);
syncStudentAvatar(profileDataId);
showToast('Фото загружено');
} else {
const e = await res.json().catch(() => ({}));
alert(e.error || 'Не удалось загрузить фото');
}
} catch (err) {
alert('Ошибка при загрузке фото');
}
}
async function setMainStudentPhoto(pid) {
if (!profileDataId) return;
try {
const res = await fetch(`${API}/api/students/${profileDataId}/photos/${pid}/main`, { method: 'PUT', headers: hdr() });
if (res.ok) {
const r = await res.json();
document.getElementById('pfPhoto').value = r.photo_path || '';
renderStudentPhotos();
syncStudentAvatar(profileDataId, r.photo_path || '');
showToast('Главное фото обновлено');
} else {
const e = await res.json().catch(() => ({}));
alert(e.error || 'Не удалось выбрать главное фото');
}
} catch (err) {
alert('Ошибка при выборе главного фото');
}
}
async function delStudentPhoto(pid) {
if (!profileDataId) return;
if (!confirm('Удалить это фото?')) return;
try {
const res = await fetch(`${API}/api/students/${profileDataId}/photos/${pid}`, { method: 'DELETE', headers: hdr() });
if (res.ok) {
await loadStudentPhotos(profileDataId);
syncStudentAvatar(profileDataId);
showToast('Фото удалено');
} else {
const e = await res.json().catch(() => ({}));
alert(e.error || 'Не удалось удалить фото');
}
} catch (err) {
alert('Ошибка при удалении фото');
}
}
function fillProfileForm(profile, name, photoPath) {
const p = profile || {};
const set = (id, v) => { document.getElementById(id).value = v === null || v === undefined ? '' : String(v); };
document.getElementById('profileDataTitle').textContent = name || 'Профиль ученика';
set('pfRole', p.role); set('pfStatus', p.status); set('pfStatusNote', p.status_note);
set('pfCity', p.city); set('pfMentor', p.mentor); set('pfJoined', p.joined);
set('pfBio', p.bio); set('pfQuote', p.quote);
set('pfTags', (p.tags || []).join(', '));
set('pfContacts', (p.contacts || []).map(c => [c.icon || 'link', c.label, c.href || ''].join(' | ')).join('\n'));
set('pfAchievements', (p.achievements || []).join('\n'));
set('pfSkills', (p.skills || []).map(s => [s.group || 'Навыки', s.name, s.level || '', s.value === null || s.value === undefined ? '' : s.value].join(' | ')).join('\n'));
set('pfExperience', (p.experience || []).map(e => [e.title, e.company || '', e.period || '', e.date || '', e.badge || '', e.description || '', (e.tags || []).join(',')].join(' | ')).join('\n'));
set('pfEducation', (p.education || []).map(m => [m.module, m.progress === null || m.progress === undefined ? '' : m.progress, m.grade || '', m.teacher || ''].join(' | ')).join('\n'));
set('pfPhoto', photoPath || '');
}
function collectProfile() {
const val = id => document.getElementById(id).value.trim();
const lines = id => val(id).split('\n').map(s => s.trim()).filter(Boolean);
const cols = line => line.split('|').map(s => s.trim());
const intOrNull = s => (s === '' || s === null || s === undefined ? null : Number(s));
const out = {
role: val('pfRole') || null,
status: val('pfStatus') || null,
status_note: val('pfStatusNote') || null,
city: val('pfCity') || null,
mentor: val('pfMentor') || null,
joined: val('pfJoined') || null,
bio: val('pfBio') || null,
quote: val('pfQuote') || null,
tags: val('pfTags') ? val('pfTags').split(',').map(s => s.trim()).filter(Boolean) : [],
contacts: lines('pfContacts').map(l => {
const c = cols(l);
return { icon: c[0] || 'link', label: c[1] || '', href: c[2] || '' };
}).filter(c => c.label),
achievements: lines('pfAchievements'),
skills: lines('pfSkills').map(l => {
const c = cols(l);
return { group: c[0] || 'Навыки', name: c[1] || '', level: c[2] || null, value: intOrNull(c[3]) };
}).filter(s => s.name),
experience: lines('pfExperience').map(l => {
const c = cols(l);
return {
title: c[0] || '', company: c[1] || null, period: c[2] || null, date: c[3] || null,
badge: c[4] || null, description: c[5] || null,
tags: c[6] ? c[6].split(',').map(s => s.trim()).filter(Boolean) : [],
};
}).filter(e => e.title),
education: lines('pfEducation').map(l => {
const c = cols(l);
return { module: c[0] || '', progress: intOrNull(c[1]), grade: c[2] || null, teacher: c[3] || null };
}).filter(m => m.module),
};
const hasData = Object.keys(out).some(k => (Array.isArray(out[k]) ? out[k].length : out[k]));
return hasData ? out : null;
}
async function saveProfileData(e) {
e.preventDefault();
if (!profileDataId) return;
const body = { profile: collectProfile() };
const photoPath = document.getElementById('pfPhoto').value.trim();
body.photo_path = photoPath || null;
const res = await fetch(`${API}/api/students/${profileDataId}/profile`, {
method: 'PUT',
headers: hdrJson(),
body: JSON.stringify(body),
});
if (res.ok) {
closeProfileData();
loadStudents();
showToast('Данные профиля сохранены');
} else {
const err = await res.json().catch(() => ({}));
alert(err.error || 'Не удалось сохранить данные профиля');
}
}
async function delStudent(id) {
if (!confirm('Удалить ученика из списка? Старые записи останутся.')) return;
await fetch(`${API}/api/students/${id}`, { method: 'DELETE', headers: hdr() });
loadStudents();
fillAutocomplete();
}
function openBatchAdd() {
document.getElementById('batchText').value = '';
loadGroupOptions(document.getElementById('batchGroup'), '');
document.getElementById('batchModal').classList.add('open');
}
function closeBatchAdd() {
document.getElementById('batchModal').classList.remove('open');
}
async function batchAdd() {
const raw = document.getElementById('batchText').value;
const names = [...new Set(raw.split('\n').map(s => s.trim()).filter(Boolean))];
if (!names.length) return;
const group_id = document.getElementById('batchGroup').value;
let added = 0, dupes = 0;
for (const name of names) {
const res = await fetch(`${API}/api/students`, { method: 'POST', headers: hdrJson(), body: JSON.stringify({ name, group_id: group_id || null }) });
if (res.ok) added++;
else if (res.status === 409) dupes++;
}
closeBatchAdd();
loadStudents();
fillAutocomplete();
showToast(`Добавлено: ${added}${dupes ? `, дубликатов: ${dupes}` : ''}`);
}
document.getElementById('newStudent').addEventListener('keydown', e => { if (e.key === 'Enter') addStudent(); });
document.getElementById('batchForm').addEventListener('submit', e => {
e.preventDefault();
batchAdd();
});
document.getElementById('studentSearch').addEventListener('input', () => {
clearTimeout(sSearchTimer);
sSearchTimer = setTimeout(() => { sPage = 1; renderStudents(); }, 300);
});
document.getElementById('studentGroupFilter').addEventListener('change', () => { sPage = 1; renderStudents(); });
function exportStudentsCSV() {
const gid = document.getElementById('studentGroupFilter').value;
const list = gid ? allStudents.filter(s => String(s.group_id) === gid) : allStudents;
if (!list.length) { alert('Нет данных для экспорта'); return; }
const rows = [['Фамилия Имя', 'Группа', 'Ссылка на страницу']];
list.forEach(s => {
rows.push([
s.name,
s.group_name || '',
`${location.origin}/?nameInput=${encodeURIComponent(s.name)}`
]);
});
const csv = rows.map(r => r.map(c => `"${String(c).replace(/"/g, '""')}"`).join(';')).join('\n');
const blob = new Blob(['\uFEFF' + csv], { type: 'text/csv;charset=utf-8' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
const groupName = gid ? (allGroups.find(g => g.id == gid)?.name || 'group') : 'all';
a.download = `students_${groupName.replace(/[\\/:*?"<>|]/g, '_')}.csv`;
a.click();
URL.revokeObjectURL(url);
showToast(`Экспортировано: ${list.length}`);
}
async function exportStudentsUrls() {
const gid = document.getElementById('studentGroupFilter').value;
const list = gid ? allStudents.filter(s => String(s.group_id) === gid) : allStudents;
if (!list.length) { alert('Нет данных для экспорта'); return; }
const groupName = gid ? (allGroups.find(g => g.id == gid)?.name || 'group') : 'all';
await exportStudentUrlZip(list, `students_${groupName.replace(/[\\/:*?"<>|]/g, '_')}.zip`);
showToast(`Экспортировано: ${list.length}`);
}
async function openBatchAttach() {
const sel = document.getElementById('attachGroup');
if (!attachGroupLoaded) {
const res = await fetch(`${API}/api/groups`, { headers: hdr() });
const groups = await res.json();
sel.innerHTML = `<option value="">— выберите группу —</option>` +
groups.map(g => `<option value="${g.id}">${esc(g.name)}</option>`).join('');
attachGroupLoaded = true;
}
document.getElementById('attachSearch').value = '';
renderAttachList();
document.getElementById('attachModal').classList.add('open');
}
function closeBatchAttach() {
document.getElementById('attachModal').classList.remove('open');
}
function renderAttachList() {
const q = document.getElementById('attachSearch').value.trim().toLowerCase();
const filtered = q ? allStudents.filter(s => s.name.toLowerCase().includes(q)) : allStudents;
const list = document.getElementById('attachList');
if (!filtered.length) { list.innerHTML = '<div style="color:var(--muted);text-align:center;padding:16px">Никого не найдено</div>'; return; }
list.innerHTML = filtered.map(s => `
<label>
<input type="checkbox" value="${s.id}">
<span>${esc(s.name)}</span>
<span class="cur">${s.group_name ? esc(s.group_name) : ''}</span>
</label>
`).join('');
}
async function batchAttach() {
const group_id = document.getElementById('attachGroup').value;
const ids = [...document.querySelectorAll('#attachList input[type=checkbox]:checked')].map(c => c.value);
if (!group_id) { alert('Выберите группу'); return; }
if (!ids.length) { alert('Выберите учеников'); return; }
const res = await fetch(`${API}/api/students/batch-group`, {
method: 'POST',
headers: hdrJson(),
body: JSON.stringify({ group_id, student_ids: ids })
});
if (res.ok) {
const r = await res.json();
closeBatchAttach();
loadStudents();
showToast(`Прикреплено: ${r.updated}`);
} else { const e = await res.json(); alert(e.error); }
}
document.getElementById('attachForm').addEventListener('submit', e => {
e.preventDefault();
batchAttach();
});
document.getElementById('attachSearch').addEventListener('input', () => {
clearTimeout(aSearchTimer);
aSearchTimer = setTimeout(renderAttachList, 300);
});
document.getElementById('exportForm').addEventListener('submit', e => {
e.preventDefault();
submitExport();
});
document.getElementById('exportCancelBtn').addEventListener('click', closeExportModal);
document.getElementById('expPhotos').addEventListener('change', onExportPhotosToggle);
document.getElementById('exportModal').addEventListener('click', e => { if (e.target === e.currentTarget) closeExportModal(); });
document.getElementById('addStudentBtn').addEventListener('click', addStudent);
document.getElementById('batchAddBtn').addEventListener('click', openBatchAdd);
document.getElementById('batchAttachBtn').addEventListener('click', openBatchAttach);
document.getElementById('exportStudentsCsvBtn').addEventListener('click', exportStudentsCSV);
document.getElementById('exportStudentsUrlsBtn').addEventListener('click', exportStudentsUrls);
document.getElementById('batchModal').addEventListener('click', e => { if (e.target === e.currentTarget) closeBatchAdd(); });
document.getElementById('attachModal').addEventListener('click', e => { if (e.target === e.currentTarget) closeBatchAttach(); });
document.getElementById('batchCancelBtn').addEventListener('click', closeBatchAdd);
document.getElementById('attachCancelBtn').addEventListener('click', closeBatchAttach);
document.getElementById('imgModal').addEventListener('click', e => e.currentTarget.classList.remove('open'));
document.addEventListener('keydown', e => {
if (e.key === 'Escape') document.getElementById('imgModal').classList.remove('open');
});
document.getElementById('studentList').addEventListener('click', e => {
const av = e.target.closest('[data-avatar-student]');
if (av) {
const p = av.dataset.studentPhoto;
if (p) showImg(photoFullSrc(p));
else openProfileData(parseInt(av.dataset.avatarStudent, 10));
return;
}
const sv = e.target.closest('[data-save-student]');
if (sv) { saveStudent(parseInt(sv.dataset.saveStudent, 10)); return; }
const cp = e.target.closest('[data-copy-student]');
if (cp) { copyStudentLink(cp.dataset.copyStudent); return; }
const ex = e.target.closest('[data-export-student]');
if (ex) { openExportModal(ex.dataset.exportStudent.trim()); return; }
const pf = e.target.closest('[data-profile-student]');
if (pf) { exportStudentProfile(parseInt(pf.dataset.profileStudent, 10)); return; }
const pd = e.target.closest('[data-profile-data]');
if (pd) { openProfileData(parseInt(pd.dataset.profileData, 10)); return; }
const dl = e.target.closest('[data-del-student]');
if (dl) { delStudent(parseInt(dl.dataset.delStudent, 10)); }
});
document.getElementById('profileDataForm').addEventListener('submit', saveProfileData);
document.getElementById('profileDataCancelBtn').addEventListener('click', closeProfileData);
document.getElementById('pfPhotoUploadBtn').addEventListener('click', () => document.getElementById('pfPhotoFile').click());
document.getElementById('pfPhotoFile').addEventListener('change', e => {
const f = e.target.files && e.target.files[0];
e.target.value = '';
if (f) uploadStudentPhoto(f);
});
document.getElementById('pfPhotos').addEventListener('click', e => {
const del = e.target.closest('[data-pf-del]');
if (del) { delStudentPhoto(parseInt(del.dataset.pfDel, 10)); return; }
const set = e.target.closest('[data-pf-set]');
if (set) setMainStudentPhoto(parseInt(set.dataset.pfSet, 10));
});
document.addEventListener('paste', e => {
if (!profileDataId) return;
if (!document.getElementById('profileDataModal').classList.contains('open')) return;
const items = e.clipboardData && e.clipboardData.items;
if (!items) return;
for (const it of items) {
if (it.type && it.type.startsWith('image/')) {
const f = it.getAsFile();
if (f) {
e.preventDefault();
uploadStudentPhoto(f);
}
return;
}
}
});
document.getElementById('profileDataModal').addEventListener('click', e => { if (e.target === e.currentTarget) closeProfileData(); });
(async () => {
if (await checkAuth()) {
buildSidebar(document.body.dataset.page);
fillAutocomplete();
loadGroupOptions();
loadStudents();
}
})();