const DAYS = ['Вс', 'Пн', 'Вт', 'Ср', 'Чт', 'Пт', 'Сб']; let branchesCache = []; let allGroups = []; let currentCover = null; async function loadBranchesCache() { const res = await fetch(`${API}/api/branches`, { headers: hdr() }); if (res.ok) branchesCache = await res.json(); } function branchSelectHTML(selectedId) { const opts = branchesCache.map(b => ``).join(''); return `
`; } async function loadGroups() { await loadBranchesCache(); const res = await fetch(`${API}/api/groups`, { headers: hdr() }); const groups = await res.json(); allGroups = groups; const list = document.getElementById('groupList'); if (!groups.length) { list.innerHTML = '
Нет групп
'; return; } list.innerHTML = `
` + groups.map(g => { const schedSet = g.day_of_week != null && g.time_start && g.time_end; return `
${g.cover_path ? `` : `
📷
`}
${branchSelectHTML(g.branch_id)}
${schedSet ? `${DAYS[g.day_of_week]} ${g.time_start.slice(0,5)}–${g.time_end.slice(0,5)}` : 'Не настроено'}
`; }).join('') + `
`; } async function saveGroup(id) { const name = document.getElementById(`name-${id}`).value.trim(); if (!name) return; const nameInput = document.getElementById(`name-${id}`); const groupCard = nameInput.closest('.group-card'); const branchSelect = groupCard ? groupCard.querySelector('.branch-select') : null; const branch_id = branchSelect ? branchSelect.value : ''; const dow = document.getElementById(`dow-${id}`).value; const ts = document.getElementById(`ts-${id}`).value; const te = document.getElementById(`te-${id}`).value; const res = await fetch(`${API}/api/groups/${id}`, { method: 'PUT', headers: hdrJson(), body: JSON.stringify({ name, branch_id: branch_id || null, day_of_week: dow !== '' ? parseInt(dow) : null, time_start: ts || null, time_end: te || null }) }); if (res.ok) { loadGroups(); const dayLabel = dow !== '' ? DAYS[dow] : 'без расписания'; const timeLabel = ts && te ? `, ${ts}–${te}` : ''; showToast(`Сохранено: «${name}» — ${dayLabel}${timeLabel}`); } else { const e = await res.json(); alert(e.error); } } async function addGroup() { const input = document.getElementById('newGroup'); const name = input.value.trim(); if (!name) return; await loadBranchesCache(); const branchSelect = document.getElementById('newGroupBranch'); const branch_id = branchSelect ? branchSelect.value : ''; const res = await fetch(`${API}/api/groups`, { method: 'POST', headers: hdrJson(), body: JSON.stringify({ name, branch_id: branch_id || null }) }); if (res.ok) { input.value = ''; loadGroups(); } else { const e = await res.json(); alert(e.error); } } async function delGroup(id) { if (!confirm('Удалить группу? Фото группы также будут удалены.')) return; await fetch(`${API}/api/groups/${id}`, { method: 'DELETE', headers: hdr() }); loadGroups(); } const PHOTO_PAGE_SIZE = 24; let currentGroupId = null; let currentGroupName = ''; let photos = []; let photoPage = 1; let totalPhotos = 0; let editingPhotoId = null; async function openPhotos(groupId, groupName) { currentGroupId = groupId; currentGroupName = groupName; const g = allGroups.find(x => String(x.id) === String(groupId)); currentCover = g ? g.cover_path : null; photoPage = 1; editingPhotoId = null; document.getElementById('photosTitle').textContent = `Фото — ${groupName}`; document.getElementById('photosModal').classList.add('open'); await loadGroupPhotos(); } function closePhotos() { document.getElementById('photosModal').classList.remove('open'); currentGroupId = null; } async function loadGroupPhotos() { const p = new URLSearchParams(); p.set('limit', PHOTO_PAGE_SIZE); p.set('offset', (photoPage - 1) * PHOTO_PAGE_SIZE); const res = await fetch(`${API}/api/groups/${currentGroupId}/photos?${p}`, { headers: hdr() }); const data = await res.json(); photos = data.photos; totalPhotos = data.total; document.getElementById('photoGrid').className = 'photo-grid'; document.getElementById('photoGrid').innerHTML = photos.length ? photos.map(photoHTML).join('') : '
Фото пока нет. Загрузите первое фото.
'; renderPager(document.getElementById('photosPager'), photoPage, Math.max(1, Math.ceil(totalPhotos / PHOTO_PAGE_SIZE)), goPhotoPage); } function goPhotoPage(p) { photoPage = p; loadGroupPhotos(); } function fmtPhotoDate(d) { return d ? d.split('-').reverse().join('.') : ''; } function photoHTML(p) { const taken = p.taken_at ? `Дата: ${fmtPhotoDate(p.taken_at)}` : ''; const uploaded = `Загружено: ${new Date(p.created_at).toLocaleDateString('ru')}`; const isCover = currentCover && p.photo_path === currentCover; return `
⠿
${esc(p.caption || '')}
${taken ? taken + ' · ' : ''}${uploaded}
`; } function openAddPhoto() { editingPhotoId = null; document.getElementById('photoFormTitle').textContent = `Добавить фото — ${currentGroupName}`; document.getElementById('photoFile').value = ''; document.getElementById('photoCaption').value = ''; document.getElementById('photoTakenAt').value = ''; document.getElementById('photoFormPreview').style.display = 'none'; document.getElementById('photoFormModal').classList.add('open'); } async function openEditPhoto(id) { const p = photos.find(x => x.id === id); if (!p) return; editingPhotoId = id; document.getElementById('photoFormTitle').textContent = 'Редактировать фото'; document.getElementById('photoFile').value = ''; document.getElementById('photoCaption').value = p.caption || ''; document.getElementById('photoTakenAt').value = p.taken_at || ''; const prev = document.getElementById('photoFormPreview'); const img = document.getElementById('photoPreviewImg'); img.src = `${API}${p.photo_path}`; prev.style.display = 'block'; document.getElementById('photoFormModal').classList.add('open'); } function closePhotoForm() { document.getElementById('photoFormModal').classList.remove('open'); editingPhotoId = null; } function previewPhotoFile() { const file = document.getElementById('photoFile').files[0]; const prev = document.getElementById('photoFormPreview'); if (!file) { prev.style.display = 'none'; return; } const img = document.getElementById('photoPreviewImg'); img.src = URL.createObjectURL(file); prev.style.display = 'block'; } async function savePhotoForm() { const caption = document.getElementById('photoCaption').value.trim(); const taken_at = document.getElementById('photoTakenAt').value; if (editingPhotoId) { const res = await fetch(`${API}/api/groups/${currentGroupId}/photos/${editingPhotoId}`, { method: 'PUT', headers: hdrJson(), body: JSON.stringify({ caption: caption || null, taken_at: taken_at || null }) }); if (!res.ok) { const e = await res.json(); alert(e.error || 'Ошибка'); return; } showToast('Фото обновлено'); } else { const file = document.getElementById('photoFile').files[0]; if (!file) { alert('Выберите файл'); return; } const fd = new FormData(); fd.append('photo', file); fd.append('caption', caption); fd.append('taken_at', taken_at); const res = await fetch(`${API}/api/groups/${currentGroupId}/photos`, { method: 'POST', headers: hdr(), body: fd }); if (!res.ok) { const e = await res.json(); alert(e.error || 'Ошибка'); return; } showToast('Фото добавлено в хронологию'); } closePhotoForm(); loadGroupPhotos(); loadGroups(); } async function deletePhoto(id) { if (!confirm('Удалить это фото безвозвратно?')) return; const res = await fetch(`${API}/api/groups/${currentGroupId}/photos/${id}`, { method: 'DELETE', headers: hdr() }); if (!res.ok) { const e = await res.json(); alert(e.error || 'Ошибка'); return; } const p = photos.find(x => x.id === id); if (p && currentCover === p.photo_path) currentCover = null; showToast('Фото удалено'); loadGroupPhotos(); loadGroups(); } async function exportGroup(id, name) { const res = await fetch(`${API}/api/entries?group_id=${id}`, { headers: hdr() }); const { entries } = await res.json(); if (!entries.length) { alert('Нет данных для экспорта'); return; } const rows = [['Фамилия Имя', 'Группа', 'Описание', 'Дата', 'Ссылка на страницу']]; entries.forEach(e => { rows.push([ e.student_name, e.group_name, e.description.replace(/\n/g, ' '), new Date(e.created_at).toLocaleString('ru'), `${location.origin}/?nameInput=${encodeURIComponent(e.student_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; a.download = `group_${name.replace(/[\\/:*?"<>|]/g, '_')}.csv`; a.click(); URL.revokeObjectURL(url); showToast(`Экспортировано записей: ${entries.length}`); } async function exportGroupUrls(groupId, groupName) { const res = await fetch(`${API}/api/students`, { headers: hdr() }); const students = await res.json(); const list = students.filter(s => s.group_id === groupId); if (!list.length) { alert('В группе нет учеников'); return; } await exportStudentUrlZip(list, `group_${groupName.replace(/[\\/:*?"<>|]/g, '_')}.zip`); showToast(`Экспортировано: ${list.length}`); } document.getElementById('newGroup').addEventListener('keydown', e => { if (e.key === 'Enter') addGroup(); }); document.getElementById('addGroupBtn').addEventListener('click', addGroup); document.getElementById('photoFile').addEventListener('change', previewPhotoFile); document.getElementById('photoForm').addEventListener('submit', e => { e.preventDefault(); savePhotoForm(); }); document.getElementById('photosModal').addEventListener('click', e => { if (e.target === e.currentTarget) closePhotos(); }); document.getElementById('photoFormModal').addEventListener('click', e => { if (e.target === e.currentTarget) closePhotoForm(); }); document.getElementById('openPhotoBtn').addEventListener('click', openAddPhoto); document.getElementById('closePhotosBtn').addEventListener('click', closePhotos); document.getElementById('closePhotoFormBtn').addEventListener('click', closePhotoForm); document.getElementById('groupList').addEventListener('click', e => { const sg = e.target.closest('[data-save-group]'); if (sg) { saveGroup(parseInt(sg.dataset.saveGroup, 10)); return; } const dg = e.target.closest('[data-del-group]'); if (dg) { delGroup(parseInt(dg.dataset.delGroup, 10)); return; } const eg = e.target.closest('[data-export-group]'); if (eg) { exportGroup(parseInt(eg.dataset.exportGroup, 10), eg.dataset.groupName); return; } const eu = e.target.closest('[data-export-urls]'); if (eu) { exportGroupUrls(parseInt(eu.dataset.exportUrls, 10), eu.dataset.groupName); return; } const cover = e.target.closest('[data-group-id]'); if (cover) { const id = parseInt(cover.dataset.groupId, 10); const name = cover.dataset.groupName; if (!isNaN(id)) openPhotos(id, name); } }); function updateGroupCoverUI(coverPath) { const card = document.querySelector(`.group-card[data-group-id="${currentGroupId}"]`); if (!card) return; const g = allGroups.find(x => String(x.id) === String(currentGroupId)); const name = g ? g.name : currentGroupName; const existing = card.querySelector('.group-cover'); if (existing && existing.tagName === 'IMG') { existing.src = API + coverPath; return; } const el = document.createElement('img'); el.className = 'group-cover'; el.src = API + coverPath; el.dataset.groupId = String(currentGroupId); el.dataset.groupName = name; el.title = 'Фото группы (хронология)'; el.alt = ''; if (existing) existing.replaceWith(el); else card.insertBefore(el, card.firstChild); } let dragPhotoId = null; async function persistPhotoOrder() { const items = [...document.querySelectorAll('#photoGrid .photo-item')]; const order = items.map(el => parseInt(el.dataset.photoId, 10)); if (JSON.stringify(order) === JSON.stringify(photos.map(p => p.id))) return; const res = await fetch(`${API}/api/groups/${currentGroupId}/photos/reorder`, { method: 'PUT', headers: hdrJson(), body: JSON.stringify({ order }) }); if (res.ok) showToast('Порядок фото сохранён'); else { try { const err = await res.json(); alert(err.error || 'Ошибка'); } catch { alert('Ошибка'); } loadGroupPhotos(); } } document.getElementById('photoGrid').addEventListener('dragstart', e => { const item = e.target.closest('.photo-item'); if (!item) return; e.stopPropagation(); dragPhotoId = parseInt(item.dataset.photoId, 10); item.classList.add('dragging'); e.dataTransfer.effectAllowed = 'move'; e.dataTransfer.setData('text/plain', String(dragPhotoId)); }); document.getElementById('photoGrid').addEventListener('dragover', e => { e.preventDefault(); e.dataTransfer.dropEffect = 'move'; const target = e.target.closest('.photo-item'); const dragged = dragPhotoId != null ? document.querySelector(`.photo-item[data-photo-id="${dragPhotoId}"]`) : null; if (!target || !dragged || target === dragged) return; if (target.contains(dragged)) return; const rect = target.getBoundingClientRect(); const grid = document.getElementById('photoGrid'); grid.insertBefore(dragged, e.clientX > rect.left + rect.width / 2 ? target.nextSibling : target); }); document.getElementById('photoGrid').addEventListener('dragend', e => { const item = e.target.closest('.photo-item'); if (item) item.classList.remove('dragging'); if (dragPhotoId != null) persistPhotoOrder(); dragPhotoId = null; }); document.getElementById('photoGrid').addEventListener('drop', e => e.preventDefault()); document.getElementById('photoGrid').addEventListener('click', async e => { const coverBtn = e.target.closest('.p-cover'); if (coverBtn) { const photoId = parseInt(coverBtn.dataset.photoId, 10); const photoPath = coverBtn.dataset.photoPath; if (!isNaN(photoId)) { try { const res = await fetch(`${API}/api/groups/${currentGroupId}/photos/${photoId}/cover`, { method: 'PUT', headers: hdrJson(), }); if (res.ok) { const g = await res.json(); currentCover = g.cover_path || null; const grp = allGroups.find(x => String(x.id) === String(currentGroupId)); if (grp) grp.cover_path = currentCover; showToast('Обложка обновлена'); updateGroupCoverUI(currentCover); loadGroupPhotos(); } else { const err = await res.json(); alert(err.error || 'Ошибка'); } } catch (err) { alert('Ошибка сети: ' + err.message); } } return; } const ed = e.target.closest('[data-edit-photo]'); if (ed) { openEditPhoto(parseInt(ed.dataset.editPhoto, 10)); return; } const dl = e.target.closest('[data-del-photo]'); if (dl) { deletePhoto(parseInt(dl.dataset.delPhoto, 10)); } }); async function populateNewGroupBranch() { const res = await fetch(`${API}/api/branches`, { headers: hdr() }); if (res.ok) { const branches = await res.json(); const sel = document.getElementById('newGroupBranch'); sel.innerHTML = '' + branches.map(b => ``).join(''); } } (async () => { if (await checkAuth()) { buildSidebar(document.body.dataset.page); await populateNewGroupBranch(); loadGroups(); } })();