Часовой пояс и формат времени (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.
647 lines
27 KiB
JavaScript
647 lines
27 KiB
JavaScript
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="Удалить">×</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();
|
||
}
|
||
})(); |