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 = `` + allGroups.map(g => ``).join(''); } } if (sel) { sel.innerHTML = `` + allGroups.map(g => ``).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 = ``; 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 = `${esc(studentInitials(name))}`; } } 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 = '
Ничего не найдено
'; } else { list.innerHTML = slice.map(s => `
`).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)}_${new Date().toISOString().slice(0, 10)}.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 = 'Пока нет загруженных фото'; return; } wrap.innerHTML = profilePhotos.map(p => `
${p.photo_path === main ? 'Главная' : ''}
`).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 = `` + groups.map(g => ``).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 = '
Никого не найдено
'; return; } list.innerHTML = filtered.map(s => ` `).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(); } })();