Files
WhatIDo/public/js/groups.js
T
dev ad81adfe71 Migrate UI to Lucide icons, reorder sidebar, and polish journal/groups UX
- Replace emoji icons with Lucide across admin pages; add vendor/lucide.min.js and renderIcons() helper\n- Reorder sidebar logically (Dashboard, Journal, Students, Groups, Files, Links, Trash + admin sections)\n- Groups: open photo chronology only via the Фото button; covers no longer clickable\n- Journal: show group badge over card photo, compact AI-status icon beside description, and date range in empty-state message\n- Update README (AI worker, Lucide, worker.js)
2026-09-11 14:33:41 +03:00

425 lines
19 KiB
JavaScript
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.
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" data-group-id="${g.id}">
${g.cover_path
? `<img class="group-cover" src="${API}${g.cover_path}" alt="">`
: `<div class="group-cover empty-cover">📷</div>`}
<div class="group-head">
<input class="name-input" id="name-${g.id}" value="${esc(g.name)}">
<button class="del" data-del-group="${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" data-save-group="${g.id}">Сохранить</button>
<button class="photo-btn" data-group-id="${g.id}" data-group-name="${esc(g.name)}" title="Фото группы (хронология)">Фото</button>
<button class="btn-link" data-export-group="${g.id}" data-group-name="${esc(g.name)}" title="Экспортировать записи группы">CSV</button>
<button class="btn-link" data-export-urls="${g.id}" data-group-name="${esc(g.name)}" 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)}" draggable="true">
<div class="drag-hint" title="Перетащите, чтобы изменить порядок">⠿</div>
<img src="${API}${p.photo_path}" data-img="${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" data-edit-photo="${p.id}" title="Редактировать">✎</button>
<button class="p-del" data-del-photo="${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; }
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 photoBtn = e.target.closest('.photo-btn');
if (photoBtn) {
const id = parseInt(photoBtn.dataset.groupId, 10);
const name = photoBtn.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 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.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 = '<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();
}
})();