- renderStudentReport: детский учебный дизайн (крупные скругления, sticky-навигация, секции-карточки) - журнал занятий в две колонки (одна на мобильных) - единая галерея фото и видео: листание кнопками/стрелками, счётчик - воспроизведение видео прямо в лайтбоксе - кнопка закрытия ✕, закрытие по фону и Esc - модалка экспорта отчёта ученика (период и выбор содержимого)
332 lines
14 KiB
JavaScript
332 lines
14 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 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">
|
|
<input class="name-input student-name-input" id="sname-${s.id}" value="${esc(s.name)}">
|
|
<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-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 => 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 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('studentList').addEventListener('click', e => {
|
|
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 dl = e.target.closest('[data-del-student]');
|
|
if (dl) { delStudent(parseInt(dl.dataset.delStudent, 10)); }
|
|
});
|
|
|
|
(async () => {
|
|
if (await checkAuth()) {
|
|
buildSidebar(document.body.dataset.page);
|
|
fillAutocomplete();
|
|
loadGroupOptions();
|
|
loadStudents();
|
|
}
|
|
})(); |