Files
WhatIDo/public/groups.html
T

419 lines
18 KiB
HTML
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.
<!DOCTYPE html>
<html lang="ru">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>Группы — Админ-панель</title>
<link rel="stylesheet" href="admin.css">
</head>
<body data-page="groups">
<div class="layout">
<div class="sidebar" id="sidebar"></div>
<div class="main">
<h2>Группы</h2>
<div class="add-row" style="align-items:center;gap:8px">
<input type="text" id="newGroup" placeholder="Название группы" style="flex:1;max-width:300px">
<select id="newGroupBranch" style="background:var(--bg);border:1px solid var(--border);color:var(--text);border-radius:8px;padding:8px 12px;font-size:.85rem;min-width:180px"><option value="">— без филиала —</option></select>
<button onclick="addGroup()">Добавить</button>
</div>
<div class="list" id="groupList"></div>
</div>
</div>
<div class="modal-overlay" id="photosModal" onclick="if(event.target===this)closePhotos()">
<div class="gallery-modal">
<div class="gallery-head">
<h3 id="photosTitle">Фото</h3>
<button class="upload-btn" onclick="openAddPhoto()">Загрузить фото</button>
</div>
<div class="photo-grid" id="photoGrid"><div class="empty">Загрузка...</div></div>
<div class="pager" id="photosPager"></div>
<div class="gallery-foot">
<button class="cancel" onclick="closePhotos()">Закрыть</button>
</div>
</div>
</div>
<div class="modal-overlay" id="photoFormModal" onclick="if(event.target===this)closePhotoForm()">
<div class="edit-modal" style="max-width:480px">
<h3 id="photoFormTitle">Добавить фото</h3>
<form id="photoForm" class="settings-stack" style="gap:12px">
<div class="settings-row">
<div class="settings-field" style="flex:1">
<label>Выбрать файл (jpg/png и т.п.) <span style="color:#ef4444">*</span></label>
<input type="file" id="photoFile" accept="image/*">
</div>
</div>
<div class="settings-row">
<div class="settings-field" style="flex:1">
<div id="photoFormPreview" class="photo-preview" style="display:none"><img id="photoPreviewImg" alt=""></div>
</div>
</div>
<div class="settings-row">
<div class="settings-field" style="flex:1">
<label>Подпись</label>
<textarea id="photoCaption" class="settings-input" placeholder="Например: выступление, тренировка..."></textarea>
</div>
</div>
<div class="settings-row">
<div class="settings-field" style="flex:1">
<label>Дата съёмки</label>
<input type="date" id="photoTakenAt" class="settings-input">
</div>
</div>
<div class="card-foot" style="justify-content:flex-end;gap:8px">
<button type="button" class="btn-primary ghost" onclick="closePhotoForm()">Отмена</button>
<button type="submit" class="btn-primary">Сохранить</button>
</div>
</form>
</div>
</div>
<div class="toast" id="toast"></div>
<script src="admin.js"></script>
<script src="vendor/jszip.min.js"></script>
<script>
// --- Groups ---
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 => `<option value="${b.id}" ${b.id === selectedId ? 'selected' : ''}>${esc(b.name)}</option>`).join('');
return `<div><label>Филиал</label><select class="branch-select" style="background:var(--bg);border:1px solid var(--border);color:var(--text);border-radius:6px;padding:6px 8px;font-size:.8rem"><option value="">— без филиала —</option>${opts}</select></div>`;
}
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 = '<div style="color:var(--muted);text-align:center;padding:32px">Нет групп</div>'; return; }
list.innerHTML = `<div class="group-grid">` + groups.map(g => {
const schedSet = g.day_of_week != null && g.time_start && g.time_end;
return `
<div class="group-card">
${g.cover_path
? `<img class="group-cover" src="${API}${g.cover_path}" data-group-id="${g.id}" data-group-name="${esc(g.name).replace(/"/g, '"')}" title="Фото группы (хронология)" alt="">`
: `<div class="group-cover empty-cover" data-group-id="${g.id}" data-group-name="${esc(g.name).replace(/"/g, '"')}" title="Фото группы (хронология)">📷</div>`}
<div class="group-head">
<input class="name-input" id="name-${g.id}" value="${esc(g.name)}">
<button class="del" onclick="delGroup(${g.id})" title="Удалить">&times;</button>
</div>
<div class="group-schedule">
${branchSelectHTML(g.branch_id)}
<div><label>День</label>
<select id="dow-${g.id}">
<option value="">—</option>
${DAYS.map((d, i) => `<option value="${i}" ${g.day_of_week === i ? 'selected' : ''}>${d}</option>`).join('')}
</select>
</div>
<div><label>С</label>
<input type="time" id="ts-${g.id}" value="${g.time_start ? g.time_start.slice(0,5) : ''}">
</div>
<div><label>По</label>
<input type="time" id="te-${g.id}" value="${g.time_end ? g.time_end.slice(0,5) : ''}">
</div>
</div>
<div class="group-foot">
<div style="display:flex;gap:8px">
<button class="save-sched" onclick="saveGroup(${g.id})">Сохранить</button>
<button class="photo-btn" data-group-id="${g.id}" data-group-name="${esc(g.name).replace(/"/g, '"')}" title="Фото группы (хронология)">Фото</button>
<button class="btn-link" onclick="exportGroup(${g.id}, \`${esc(g.name).replace(/'/g, '')}\`)" title="Экспортировать записи группы">CSV</button>
<button class="btn-link" onclick="exportGroupUrls(${g.id}, \`${esc(g.name).replace(/'/g, '')}\`)" title="Скачать .url файлы учеников группы">URL</button>
</div>
<div class="sched-status">${schedSet ? `${DAYS[g.day_of_week]} ${g.time_start.slice(0,5)}–${g.time_end.slice(0,5)}` : 'Не настроено'}</div>
</div>
</div>`;
}).join('') + `</div>`;
}
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('')
: '<div class="empty" style="grid-column:1/-1">Фото пока нет. Загрузите первое фото.</div>';
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 `
<div class="photo-item" data-photo-id="${p.id}" data-photo-path="${esc(p.photo_path)}">
<img src="${API}${p.photo_path}" onclick="showImg('${API}${p.photo_path}')" alt="">
<div class="p-cap">${esc(p.caption || '')}</div>
<div class="p-date">${taken ? taken + ' · ' : ''}${uploaded}</div>
<div class="p-actions">
<button class="p-cover${isCover ? ' active' : ''}" data-photo-id="${p.id}" data-photo-path="${esc(p.photo_path)}" title="${isCover ? 'Текущая обложка' : 'Сделать обложкой'}" ${isCover ? 'disabled' : ''}>🖼️</button>
<button class="p-edit" onclick="openEditPhoto(${p.id})" title="Редактировать">✎</button>
<button class="p-del" onclick="deletePhoto(${p.id})" title="Удалить">&times;</button>
</div>
</div>
`;
}
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; }
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('photoFile').addEventListener('change', previewPhotoFile);
document.getElementById('photoForm').addEventListener('submit', e => {
e.preventDefault();
savePhotoForm();
});
// Event delegation for group cover clicks
document.getElementById('groupList').addEventListener('click', e => {
const target = e.target.closest('[data-group-id]');
if (target) {
const id = parseInt(target.dataset.groupId, 10);
const name = target.dataset.groupName;
if (!isNaN(id)) openPhotos(id, name);
}
});
// Event delegation for photo actions in modal
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;
showToast('Обложка обновлена');
loadGroupPhotos();
loadGroups();
} else {
const err = await res.json();
alert(err.error || 'Ошибка');
}
} catch (err) {
alert('Ошибка сети: ' + err.message);
}
}
}
});
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 = '<option value="">— без филиала —</option>' + branches.map(b => `<option value="${b.id}">${esc(b.name)}</option>`).join('');
}
}
(async () => {
if (await checkAuth()) {
buildSidebar(document.body.dataset.page);
await populateNewGroupBranch();
loadGroups();
}
})();
</script>
</body>
</html>