383 lines
15 KiB
HTML
383 lines
15 KiB
HTML
<!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="journal">
|
||
<div class="layout">
|
||
<div class="sidebar" id="sidebar"></div>
|
||
<div class="main">
|
||
<h2>Журнал</h2>
|
||
<div class="filters">
|
||
<div><label>Поиск</label><input type="text" id="searchInput" class="search-input" placeholder="Имя или описание"></div>
|
||
<div><label>Студент</label><select id="studentFilter"><option value="">Все</option></select></div>
|
||
<div><label>Группа</label><select id="groupFilter"><option value="">Все группы</option></select></div>
|
||
<div><label>С</label><input type="date" id="dateFrom"></div>
|
||
<div><label>По</label><input type="date" id="dateTo"></div>
|
||
<div><label> </label><button onclick="exportCSV()" style="background:var(--card);border:1px solid var(--border);color:var(--text);border-radius:8px;padding:8px 16px;font-size:.85rem;cursor:pointer;white-space:nowrap">Экспорт CSV</button></div>
|
||
<div><label> </label><button onclick="openLinkCreate()" style="background:var(--card);border:1px solid var(--border);color:var(--text);border-radius:8px;padding:8px 16px;font-size:.85rem;cursor:pointer;white-space:nowrap">Ссылка</button></div>
|
||
<div><label> </label>
|
||
<div class="view-toggle" id="viewToggle">
|
||
<button data-view="list" onclick="setView('list')">Список</button>
|
||
<button data-view="cards" onclick="setView('cards')">Карточки</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<div class="grid" id="grid"><div class="empty">Загрузка...</div></div>
|
||
<div class="pager" id="pager"></div>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="modal-overlay" id="imgModal" onclick="this.classList.remove('open')">
|
||
<img id="imgModalSrc">
|
||
</div>
|
||
|
||
<div class="modal-overlay" id="editModal" onclick="if(event.target===this)closeEdit()">
|
||
<div class="edit-modal">
|
||
<h3>Редактирование записи</h3>
|
||
<div>
|
||
<label>Фамилия и имя</label>
|
||
<input type="text" id="editName" list="studentEditList">
|
||
<datalist id="studentEditList"></datalist>
|
||
</div>
|
||
<div>
|
||
<label>Группа</label>
|
||
<select id="editGroup"></select>
|
||
</div>
|
||
<div>
|
||
<label>Что делала(а)</label>
|
||
<textarea id="editDesc"></textarea>
|
||
</div>
|
||
<div class="actions">
|
||
<button class="cancel" onclick="closeEdit()">Отмена</button>
|
||
<button class="save" onclick="saveEdit()">Сохранить</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="modal-overlay" id="linkModal" onclick="if(event.target===this)closeLinkCreate()">
|
||
<div class="edit-modal">
|
||
<div id="linkCreateView">
|
||
<h3>Создать ссылку</h3>
|
||
<div style="font-size:.85rem;color:var(--muted);background:var(--bg);border:1px solid var(--border);border-radius:8px;padding:10px 12px;line-height:1.5" id="linkPreview"></div>
|
||
<div>
|
||
<label>Название ссылки</label>
|
||
<input type="text" id="linkNameInput">
|
||
</div>
|
||
<div class="actions">
|
||
<button class="cancel" onclick="closeLinkCreate()">Отмена</button>
|
||
<button class="save" onclick="createLink()">Создать</button>
|
||
</div>
|
||
</div>
|
||
<div id="linkResultView" style="display:none">
|
||
<h3>Ссылка готова</h3>
|
||
<div style="font-size:.85rem;color:var(--muted);margin-bottom:8px">Открывается публичная страница с карточками — без входа и выбора параметров. Ссылку можно изменить или удалить в разделе «Ссылки».</div>
|
||
<input type="text" id="linkUrlResult" readonly style="font-size:.8rem;color:var(--accent)">
|
||
<div class="actions">
|
||
<a class="cancel" href="links.html" style="text-decoration:none">К списку ссылок</a>
|
||
<button class="save" onclick="copyLink()">Копировать</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="toast" id="toast"></div>
|
||
<script src="admin.js"></script>
|
||
<script>
|
||
// --- Journal ---
|
||
let currentEntries = [];
|
||
let editId = null;
|
||
let editGroups = [];
|
||
let groupsLoaded = false;
|
||
let studentsLoaded = false;
|
||
const PAGE_SIZE = 20;
|
||
let page = 1;
|
||
let totalEntries = 0;
|
||
let searchTimer = null;
|
||
let view = localStorage.getItem('journalView') === 'cards' ? 'cards' : 'list';
|
||
|
||
function gridEntryHTML(e) {
|
||
return `
|
||
<div class="card">
|
||
${e.photo_path ? `<img src="${API}${e.photo_path}" onclick="showImg('${API}${e.photo_path}')" alt="">` : ''}
|
||
<div class="info">
|
||
<div class="name">${esc(e.student_name)}</div>
|
||
<div class="group">${esc(e.group_name)}</div>
|
||
<div class="desc">${esc(e.description)}</div>
|
||
<div class="time">${new Date(e.created_at).toLocaleString('ru')}</div>
|
||
</div>
|
||
<button class="edit" onclick="openEdit(${e.id})" title="Редактировать">✎</button>
|
||
<button class="del" onclick="delEntry(${e.id})" title="Удалить">×</button>
|
||
</div>
|
||
`;
|
||
}
|
||
|
||
function cardEntryHTML(e) {
|
||
return `
|
||
<div class="journal-card">
|
||
${e.photo_path
|
||
? `<img src="${API}${e.photo_path}" onclick="showImg('${API}${e.photo_path}')" alt="">`
|
||
: `<div class="no-photo"><svg viewBox="0 0 240 160" preserveAspectRatio="xMidYMid meet" aria-hidden="true"><circle cx="105" cy="48" r="15"/><path d="M74 140 C74 106 88 92 105 92 C122 92 136 106 136 140 Z"/><circle cx="145" cy="58" r="18"/><path d="M108 144 C108 104 124 88 145 88 C166 88 182 104 182 144 Z"/></svg></div>`}
|
||
<div class="body">
|
||
<div class="name">${esc(e.student_name)}</div>
|
||
<div class="group">${esc(e.group_name)}</div>
|
||
<div class="desc">${esc(e.description)}</div>
|
||
</div>
|
||
<div class="foot">
|
||
<span>${new Date(e.created_at).toLocaleString('ru')}</span>
|
||
<span>
|
||
<button class="edit" onclick="openEdit(${e.id})" title="Редактировать">✎</button>
|
||
<button class="del" onclick="delEntry(${e.id})" title="Удалить">×</button>
|
||
</span>
|
||
</div>
|
||
</div>
|
||
`;
|
||
}
|
||
|
||
function renderGrid() {
|
||
const grid = document.getElementById('grid');
|
||
if (!currentEntries.length) {
|
||
grid.className = 'grid';
|
||
grid.innerHTML = '<div class="empty">Ничего не найдено</div>';
|
||
return;
|
||
}
|
||
if (view === 'cards') {
|
||
grid.className = 'journal-cards';
|
||
grid.innerHTML = currentEntries.map(cardEntryHTML).join('');
|
||
} else {
|
||
grid.className = 'grid';
|
||
grid.innerHTML = currentEntries.map(gridEntryHTML).join('');
|
||
}
|
||
}
|
||
|
||
function setView(v) {
|
||
view = v === 'cards' ? 'cards' : 'list';
|
||
localStorage.setItem('journalView', view);
|
||
document.querySelectorAll('#viewToggle button').forEach(b => {
|
||
b.classList.toggle('active', b.dataset.view === view);
|
||
});
|
||
renderGrid();
|
||
}
|
||
|
||
function setDefaultDates() {
|
||
const today = new Date().toISOString().slice(0, 10);
|
||
document.getElementById('dateFrom').value = today;
|
||
document.getElementById('dateTo').value = today;
|
||
}
|
||
|
||
async function loadGroupOptions() {
|
||
if (groupsLoaded) return;
|
||
const sel = document.getElementById('groupFilter');
|
||
while (sel.options.length > 1) sel.remove(1);
|
||
const res = await fetch(`${API}/api/groups`, { headers: hdr() });
|
||
const groups = await res.json();
|
||
groups.forEach(g => {
|
||
const o = document.createElement('option');
|
||
o.value = g.id; o.textContent = g.name;
|
||
sel.appendChild(o);
|
||
});
|
||
groupsLoaded = true;
|
||
}
|
||
|
||
async function loadStudentOptions() {
|
||
if (studentsLoaded) return;
|
||
const sel = document.getElementById('studentFilter');
|
||
while (sel.options.length > 1) sel.remove(1);
|
||
const res = await fetch(`${API}/api/students/names`, { headers: hdr() });
|
||
const names = await res.json();
|
||
names.forEach(n => {
|
||
const o = document.createElement('option');
|
||
o.value = n; o.textContent = n;
|
||
sel.appendChild(o);
|
||
});
|
||
studentsLoaded = true;
|
||
}
|
||
|
||
async function loadEntries() {
|
||
const p = new URLSearchParams();
|
||
const q = document.getElementById('searchInput').value;
|
||
const sn = document.getElementById('studentFilter').value;
|
||
const gid = document.getElementById('groupFilter').value;
|
||
const df = document.getElementById('dateFrom').value;
|
||
const dt = document.getElementById('dateTo').value;
|
||
if (q) p.set('search', q);
|
||
if (sn) p.set('student_name', sn);
|
||
if (gid) p.set('group_id', gid);
|
||
if (df) p.set('date_from', df);
|
||
if (dt) p.set('date_to', dt);
|
||
p.set('limit', PAGE_SIZE);
|
||
p.set('offset', (page - 1) * PAGE_SIZE);
|
||
const res = await fetch(`${API}/api/entries?${p}`, { headers: hdr() });
|
||
const data = await res.json();
|
||
currentEntries = data.entries;
|
||
totalEntries = data.total;
|
||
renderGrid();
|
||
renderPager(document.getElementById('pager'), page, Math.max(1, Math.ceil(totalEntries / PAGE_SIZE)), goPage);
|
||
}
|
||
|
||
function goPage(p) {
|
||
page = p;
|
||
loadEntries();
|
||
}
|
||
|
||
async function openEdit(id) {
|
||
const e = currentEntries.find(x => x.id === id);
|
||
if (!e) return;
|
||
editId = id;
|
||
fillAutocomplete();
|
||
document.getElementById('editName').value = e.student_name;
|
||
document.getElementById('editDesc').value = e.description;
|
||
const sel = document.getElementById('editGroup');
|
||
if (!editGroups.length) {
|
||
const res = await fetch(`${API}/api/groups`, { headers: hdr() });
|
||
editGroups = await res.json();
|
||
sel.innerHTML = editGroups.map(g => `<option value="${g.id}">${esc(g.name)}</option>`).join('');
|
||
}
|
||
sel.value = e.group_id;
|
||
document.getElementById('editModal').classList.add('open');
|
||
}
|
||
|
||
function closeEdit() {
|
||
document.getElementById('editModal').classList.remove('open');
|
||
editId = null;
|
||
}
|
||
|
||
async function saveEdit() {
|
||
if (!editId) return;
|
||
const name = document.getElementById('editName').value.trim();
|
||
const group_id = document.getElementById('editGroup').value;
|
||
const description = document.getElementById('editDesc').value.trim();
|
||
if (!name || !group_id || !description) { alert('Заполните все поля'); return; }
|
||
const res = await fetch(`${API}/api/entries/${editId}`, {
|
||
method: 'PUT',
|
||
headers: hdrJson(),
|
||
body: JSON.stringify({ student_name: name, group_id, description })
|
||
});
|
||
if (res.ok) {
|
||
closeEdit();
|
||
loadEntries();
|
||
showToast('Запись обновлена');
|
||
} else { const err = await res.json(); alert(err.error); }
|
||
}
|
||
|
||
async function delEntry(id) {
|
||
if (!confirm('Удалить запись?')) return;
|
||
await fetch(`${API}/api/entries/${id}`, { method: 'DELETE', headers: hdr() });
|
||
loadEntries();
|
||
}
|
||
|
||
document.getElementById('studentFilter').addEventListener('change', () => { page = 1; loadEntries(); });
|
||
document.getElementById('groupFilter').addEventListener('change', () => { page = 1; loadEntries(); });
|
||
document.getElementById('dateFrom').addEventListener('change', () => { page = 1; loadEntries(); });
|
||
document.getElementById('dateTo').addEventListener('change', () => { page = 1; loadEntries(); });
|
||
document.getElementById('searchInput').addEventListener('input', () => {
|
||
clearTimeout(searchTimer);
|
||
searchTimer = setTimeout(() => { page = 1; loadEntries(); }, 300);
|
||
});
|
||
|
||
async function exportCSV() {
|
||
const p = new URLSearchParams();
|
||
const q = document.getElementById('searchInput').value;
|
||
const sn = document.getElementById('studentFilter').value;
|
||
const gid = document.getElementById('groupFilter').value;
|
||
const df = document.getElementById('dateFrom').value;
|
||
const dt = document.getElementById('dateTo').value;
|
||
if (q) p.set('search', q);
|
||
if (sn) p.set('student_name', sn);
|
||
if (gid) p.set('group_id', gid);
|
||
if (df) p.set('date_from', df);
|
||
if (dt) p.set('date_to', dt);
|
||
const res = await fetch(`${API}/api/entries?${p}`, { headers: hdr() });
|
||
const entries = (await res.json()).entries;
|
||
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'),
|
||
e.photo_path ? `${API}${e.photo_path}` : ''
|
||
]);
|
||
});
|
||
const csv = rows.map(r => r.map(c => `"${String(c).replace(/"/g, '""')}"`).join(';')).join('\n');
|
||
const bom = '\uFEFF';
|
||
const blob = new Blob([bom + csv], { type: 'text/csv;charset=utf-8' });
|
||
const url = URL.createObjectURL(blob);
|
||
const a = document.createElement('a');
|
||
a.href = url;
|
||
const dateLabel = df && dt ? `${df}_${dt}` : 'all';
|
||
a.download = `journal_${dateLabel}.csv`;
|
||
a.click();
|
||
URL.revokeObjectURL(url);
|
||
}
|
||
|
||
(async () => {
|
||
if (await checkAuth()) {
|
||
buildSidebar(document.body.dataset.page);
|
||
document.querySelectorAll('#viewToggle button').forEach(b => {
|
||
b.classList.toggle('active', b.dataset.view === view);
|
||
});
|
||
setDefaultDates();
|
||
loadGroupOptions();
|
||
loadStudentOptions();
|
||
loadEntries();
|
||
}
|
||
})();
|
||
|
||
// --- Ссылки ---
|
||
let linkGroups = [];
|
||
let createdLinkUrl = '';
|
||
|
||
function fmtD(d) { return d ? d.split('-').reverse().join('.') : 'весь период'; }
|
||
|
||
async function openLinkCreate() {
|
||
const gid = document.getElementById('groupFilter').value;
|
||
if (!gid) { alert('Сначала выберите группу в фильтре'); return; }
|
||
const df = document.getElementById('dateFrom').value;
|
||
const dt = document.getElementById('dateTo').value;
|
||
if (!linkGroups.length) {
|
||
const res = await fetch(`${API}/api/groups`, { headers: hdr() });
|
||
linkGroups = await res.json();
|
||
}
|
||
const g = linkGroups.find(x => String(x.id) === String(gid));
|
||
document.getElementById('linkPreview').innerHTML =
|
||
`Будут показаны записи: <b>${esc(g ? g.name : '')}</b><br>Период: <b>${fmtD(df)}</b> — <b>${fmtD(dt)}</b>`;
|
||
const dfl = df.split('-').reverse().join('.');
|
||
document.getElementById('linkNameInput').value = `${g ? g.name : ''} ${dfl}`;
|
||
document.getElementById('linkCreateView').style.display = '';
|
||
document.getElementById('linkResultView').style.display = 'none';
|
||
document.getElementById('linkModal').classList.add('open');
|
||
}
|
||
|
||
function closeLinkCreate() {
|
||
document.getElementById('linkModal').classList.remove('open');
|
||
}
|
||
|
||
async function createLink() {
|
||
const name = document.getElementById('linkNameInput').value.trim();
|
||
const group_id = document.getElementById('groupFilter').value;
|
||
const date_from = document.getElementById('dateFrom').value;
|
||
const date_to = document.getElementById('dateTo').value;
|
||
if (!name) { alert('Введите название'); return; }
|
||
const res = await fetch(`${API}/api/links`, {
|
||
method: 'POST',
|
||
headers: hdrJson(),
|
||
body: JSON.stringify({ name, group_id, date_from: date_from || null, date_to: date_to || null })
|
||
});
|
||
if (!res.ok) { const err = await res.json(); alert(err.error || 'Ошибка'); return; }
|
||
const link = await res.json();
|
||
createdLinkUrl = `${location.origin}/s/${link.token}`;
|
||
document.getElementById('linkUrlResult').value = createdLinkUrl;
|
||
document.getElementById('linkCreateView').style.display = 'none';
|
||
document.getElementById('linkResultView').style.display = '';
|
||
showToast('Ссылка создана');
|
||
}
|
||
|
||
function copyLink() { copyText(createdLinkUrl); }
|
||
</script>
|
||
</body>
|
||
</html> |