Часовой пояс и формат времени (24h/12h) перенесены из браузера в настройки приложения: валидация IANA-зоны, сид в db/init.sql + db/migration.sql, отдача в GET /api/public-settings, раздел #sec-datetime в настройках с живым превью. Границы суток в SQL переведены на tzDayStart/tzDayEnd + bindTz ($TZ$) — раньше $n::date по TIMESTAMPTZ считал дни в UTC (у контейнера TimeZone=UTC) и молча сдвигал выборку на день; хардкод Europe/Moscow вычищен, now() заменён на tzWall(). Новый public/js/datetime.js: три семейства хелперов (instant / чистая DATE-строка без Date() / чистая TIME-строка), подключён на всех страницах включая публичные share/report/index, initDateTime() встроен в checkAuth(). Прямые toLocale*/getFullYear/toISOString().slice(0,10) в public/ выпилены. Попутно: bindTz добавляет параметр только при наличии $TZ$ в SQL (иначе запрос виснет вечно без global error handler) — регрессия закрыта в api.smoketest.js; починены off-by-one месяца в report.js и группировка по дате в share.js.
263 lines
12 KiB
JavaScript
263 lines
12 KiB
JavaScript
const PAGE_SIZE = 20;
|
||
let currentEntries = [];
|
||
let currentGroups = [];
|
||
let pendingEntries = [];
|
||
let pendingGroups = [];
|
||
let page = 1;
|
||
let totalEntries = 0;
|
||
let totalGroups = 0;
|
||
let pendingTotalEntries = 0;
|
||
let pendingTotalGroups = 0;
|
||
let purgeDays = 30;
|
||
|
||
function isImageFile(name) {
|
||
return /\.(jpe?g|jfif|png|gif|webp|bmp|avif|svg|ico)$/i.test(name || '');
|
||
}
|
||
|
||
function filesHTML(files) {
|
||
if (!files || !files.length) return '';
|
||
return `<div class="entry-files">${files.map(f =>
|
||
isImageFile(f.name)
|
||
? `<a class="f-link" href="#" data-img="${API}/api/files/${f.token}" title="Открыть">${esc(f.name)}</a>`
|
||
: `<a class="f-link" href="${API}/api/files/${f.token}" download title="Скачать">${esc(f.name)}</a>`
|
||
).join('')}</div>`;
|
||
}
|
||
|
||
function entryHTML(e) {
|
||
return `
|
||
<div class="card">
|
||
${e.photo_path ? `<img src="${thumbSrc(e.photo_path)}" data-img="${API}${e.photo_path}" loading="lazy" decoding="async" 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>
|
||
${filesHTML(e.files)}
|
||
<div class="trash-time">Удалена: ${fmtFull(e.deleted_at)}</div>
|
||
</div>
|
||
<button class="restore-btn" data-restore="${e.id}" title="Восстановить">Восстановить</button>
|
||
<button class="del-btn" data-hard="${e.id}" title="Пометить на удаление (через ${purgeDays} дней будет удалена безвозвратно)">×</button>
|
||
</div>
|
||
`;
|
||
}
|
||
|
||
function groupHTML(g) {
|
||
return `
|
||
<div class="card">
|
||
${g.cover_path ? `<img src="${thumbSrc(g.cover_path)}" data-img="${API}${g.cover_path}" loading="lazy" decoding="async" alt="">` : ''}
|
||
<div class="info">
|
||
<div class="name">${esc(g.name)}</div>
|
||
${g.branch_name ? `<div class="group">${esc(g.branch_name)}</div>` : ''}
|
||
<div class="trash-time">Удалена: ${fmtFull(g.deleted_at)}</div>
|
||
</div>
|
||
<button class="restore-btn" data-restore-group="${g.id}" title="Восстановить">Восстановить</button>
|
||
<button class="del-btn" data-hard-group="${g.id}" title="Пометить на удаление (через ${purgeDays} дней будет удалена безвозвратно вместе с записями и фото)">×</button>
|
||
</div>
|
||
`;
|
||
}
|
||
|
||
function pendingEntryHTML(e) {
|
||
return `
|
||
<div class="card pending">
|
||
${e.photo_path ? `<img src="${thumbSrc(e.photo_path)}" data-img="${API}${e.photo_path}" loading="lazy" decoding="async" 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>
|
||
${filesHTML(e.files)}
|
||
<div class="trash-time warn">Удаление через ${purgeDays} дн.: ${fmtFull(e.purge_at)}</div>
|
||
</div>
|
||
<button class="restore-btn" data-unschedule="${e.id}" title="Вернуть в корзину">В корзину</button>
|
||
</div>
|
||
`;
|
||
}
|
||
|
||
function pendingGroupHTML(g) {
|
||
return `
|
||
<div class="card pending">
|
||
${g.cover_path ? `<img src="${thumbSrc(g.cover_path)}" data-img="${API}${g.cover_path}" loading="lazy" decoding="async" alt="">` : ''}
|
||
<div class="info">
|
||
<div class="name">${esc(g.name)}</div>
|
||
${g.branch_name ? `<div class="group">${esc(g.branch_name)}</div>` : ''}
|
||
<div class="trash-time warn">Удаление через ${purgeDays} дн.: ${fmtFull(g.purge_at)}</div>
|
||
</div>
|
||
<button class="restore-btn" data-unschedule-group="${g.id}" title="Вернуть в корзину">В корзину</button>
|
||
</div>
|
||
`;
|
||
}
|
||
|
||
function renderGroups() {
|
||
const grid = document.getElementById('groupsGrid');
|
||
grid.className = 'grid';
|
||
grid.innerHTML = currentGroups.length
|
||
? currentGroups.map(groupHTML).join('')
|
||
: '<div class="empty">Групп в корзине нет</div>';
|
||
document.getElementById('groupsInfo').textContent =
|
||
totalGroups > 0 ? `— групп в корзине: ${totalGroups}` : '';
|
||
}
|
||
|
||
function renderGrid() {
|
||
const grid = document.getElementById('grid');
|
||
grid.className = 'grid';
|
||
grid.innerHTML = currentEntries.length
|
||
? currentEntries.map(entryHTML).join('')
|
||
: '<div class="empty">Записей в корзине нет</div>';
|
||
document.getElementById('entriesInfo').textContent =
|
||
totalEntries > 0 ? `— записей в корзине: ${totalEntries}` : '';
|
||
document.getElementById('trashInfo').textContent =
|
||
totalEntries + totalGroups > 0
|
||
? `В корзине: записей ${totalEntries}, групп ${totalGroups}`
|
||
: 'Корзина пуста';
|
||
document.getElementById('emptyBtn').disabled = totalEntries + totalGroups === 0;
|
||
renderPager(document.getElementById('pager'), page, Math.max(1, Math.ceil(totalEntries / PAGE_SIZE)), goPage);
|
||
}
|
||
|
||
function renderPending() {
|
||
document.getElementById('pendingGroupsGrid').className = 'grid';
|
||
document.getElementById('pendingGroupsGrid').innerHTML = pendingGroups.length
|
||
? pendingGroups.map(pendingGroupHTML).join('')
|
||
: '<div class="empty">Нет групп, помеченных на удаление</div>';
|
||
document.getElementById('pendingGrid').className = 'grid';
|
||
document.getElementById('pendingGrid').innerHTML = pendingEntries.length
|
||
? pendingEntries.map(pendingEntryHTML).join('')
|
||
: '<div class="empty">Нет записей, помеченных на удаление</div>';
|
||
document.getElementById('pendingGroupsInfo').textContent =
|
||
pendingTotalGroups > 0 ? `— ${pendingTotalGroups}` : '';
|
||
document.getElementById('pendingEntriesInfo').textContent =
|
||
pendingTotalEntries > 0 ? `— ${pendingTotalEntries}` : '';
|
||
const n = pendingTotalEntries + pendingTotalGroups;
|
||
document.getElementById('pendingInfo').textContent =
|
||
n > 0 ? `— будет удалено: ${n}` : '';
|
||
const hint = document.getElementById('pendingToggleHint');
|
||
if (hint) {
|
||
hint.textContent = n > 0
|
||
? `Помечено: записей ${pendingTotalEntries}, групп ${pendingTotalGroups} — безвозвратно удалятся через ${purgeDays} дней`
|
||
: 'Данных, помеченных на удаление, нет';
|
||
}
|
||
document.getElementById('pendingSection').hidden = !document.getElementById('pendingToggle').checked;
|
||
}
|
||
|
||
async function loadTrash() {
|
||
const p = new URLSearchParams();
|
||
p.set('limit', PAGE_SIZE);
|
||
p.set('offset', (page - 1) * PAGE_SIZE);
|
||
const [trashRes, pendRes] = await Promise.all([
|
||
fetch(`${API}/api/trash?${p}`, { headers: hdr() }),
|
||
fetch(`${API}/api/trash/pending`, { headers: hdr() }),
|
||
]);
|
||
const data = await trashRes.json();
|
||
const pend = await pendRes.json();
|
||
currentEntries = data.entries;
|
||
totalEntries = data.total;
|
||
currentGroups = data.groups;
|
||
totalGroups = data.total_groups;
|
||
pendingEntries = pend.entries;
|
||
pendingTotalEntries = pend.total;
|
||
pendingGroups = pend.groups;
|
||
pendingTotalGroups = pend.total_groups;
|
||
if (pend.purge_days > 0) purgeDays = pend.purge_days;
|
||
renderGroups();
|
||
renderGrid();
|
||
renderPending();
|
||
}
|
||
|
||
function goPage(p) { page = p; loadTrash(); }
|
||
|
||
async function restoreEntry(id) {
|
||
const res = await fetch(`${API}/api/entries/${id}/restore`, { method: 'PUT', headers: hdr() });
|
||
if (res.ok) { showToast('Запись восстановлена'); loadTrash(); }
|
||
else { const err = await res.json(); showToast(err.error || 'Ошибка восстановления'); }
|
||
}
|
||
|
||
async function hardDelete(id) {
|
||
if (!confirm(`Пометить запись на удаление? Она будет безвозвратно удалена через ${purgeDays} дней вместе с файлами и фото.`)) return;
|
||
const res = await fetch(`${API}/api/entries/${id}/permanent`, { method: 'DELETE', headers: hdr() });
|
||
if (res.ok) { showToast(`Запись помечена на удаление через ${purgeDays} дн.`); loadTrash(); }
|
||
else { const err = await res.json(); showToast(err.error || 'Ошибка'); }
|
||
}
|
||
|
||
async function restoreGroup(id) {
|
||
const res = await fetch(`${API}/api/groups/${id}/restore`, { method: 'PUT', headers: hdr() });
|
||
if (res.ok) { showToast('Группа восстановлена'); loadTrash(); }
|
||
else { const err = await res.json(); showToast(err.error || 'Ошибка восстановления'); }
|
||
}
|
||
|
||
async function hardDeleteGroup(id, name) {
|
||
if (!confirm(`Пометить группу «${name}» на удаление? Записи журнала, фото и файлы группы будут безвозвратно удалены через ${purgeDays} дней, ученики открепятся.`)) return;
|
||
const res = await fetch(`${API}/api/groups/${id}/permanent`, { method: 'DELETE', headers: hdr() });
|
||
if (res.ok) { showToast(`Группа помечена на удаление через ${purgeDays} дн.`); loadTrash(); }
|
||
else { const err = await res.json(); showToast(err.error || 'Ошибка'); }
|
||
}
|
||
|
||
async function unscheduleEntry(id) {
|
||
const res = await fetch(`${API}/api/entries/${id}/unschedule`, { method: 'PUT', headers: hdr() });
|
||
if (res.ok) { showToast('Запись возвращена в корзину'); loadTrash(); }
|
||
else { const err = await res.json(); showToast(err.error || 'Ошибка'); }
|
||
}
|
||
|
||
async function unscheduleGroup(id) {
|
||
const res = await fetch(`${API}/api/groups/${id}/unschedule`, { method: 'PUT', headers: hdr() });
|
||
if (res.ok) { showToast('Группа возвращена в корзину'); loadTrash(); }
|
||
else { const err = await res.json(); showToast(err.error || 'Ошибка'); }
|
||
}
|
||
|
||
async function emptyTrash() {
|
||
const parts = [];
|
||
if (totalEntries) parts.push(`записей: ${totalEntries}`);
|
||
if (totalGroups) parts.push(`групп: ${totalGroups}`);
|
||
if (!parts.length) return;
|
||
if (!confirm(`Пометить всё из корзины (${parts.join(', ')}) на удаление? Данные будут безвозвратно удалены через ${purgeDays} дней.`)) return;
|
||
const res = await fetch(`${API}/api/trash`, { method: 'DELETE', headers: hdr() });
|
||
if (res.ok) { showToast(`Всё помечено на удаление через ${purgeDays} дн.`); page = 1; loadTrash(); }
|
||
else { const err = await res.json(); alert(err.error || 'Ошибка'); }
|
||
}
|
||
|
||
document.getElementById('emptyBtn').addEventListener('click', emptyTrash);
|
||
document.getElementById('imgModal').addEventListener('click', e => e.currentTarget.classList.remove('open'));
|
||
document.getElementById('pendingToggle').addEventListener('change', () => {
|
||
const open = document.getElementById('pendingToggle').checked;
|
||
document.getElementById('pendingSection').hidden = !open;
|
||
if (open) loadTrash();
|
||
});
|
||
|
||
document.getElementById('grid').addEventListener('click', e => {
|
||
const card = e.target.closest('.card');
|
||
if (!card) return;
|
||
const rs = card.querySelector('[data-restore]');
|
||
if (rs && (e.target === rs || rs.contains(e.target))) { restoreEntry(parseInt(rs.dataset.restore, 10)); return; }
|
||
const hd = card.querySelector('[data-hard]');
|
||
if (hd && (e.target === hd || hd.contains(e.target))) { hardDelete(parseInt(hd.dataset.hard, 10)); }
|
||
});
|
||
|
||
document.getElementById('groupsGrid').addEventListener('click', e => {
|
||
const card = e.target.closest('.card');
|
||
if (!card) return;
|
||
const rs = card.querySelector('[data-restore-group]');
|
||
if (rs && (e.target === rs || rs.contains(e.target))) { restoreGroup(parseInt(rs.dataset.restoreGroup, 10)); return; }
|
||
const hd = card.querySelector('[data-hard-group]');
|
||
if (hd && (e.target === hd || hd.contains(e.target))) {
|
||
const id = parseInt(hd.dataset.hardGroup, 10);
|
||
const g = currentGroups.find(x => String(x.id) === String(id));
|
||
hardDeleteGroup(id, g ? g.name : id);
|
||
}
|
||
});
|
||
|
||
document.getElementById('pendingGrid').addEventListener('click', e => {
|
||
const card = e.target.closest('.card');
|
||
if (!card) return;
|
||
const us = card.querySelector('[data-unschedule]');
|
||
if (us && (e.target === us || us.contains(e.target))) { unscheduleEntry(parseInt(us.dataset.unschedule, 10)); }
|
||
});
|
||
|
||
document.getElementById('pendingGroupsGrid').addEventListener('click', e => {
|
||
const card = e.target.closest('.card');
|
||
if (!card) return;
|
||
const us = card.querySelector('[data-unschedule-group]');
|
||
if (us && (e.target === us || us.contains(e.target))) { unscheduleGroup(parseInt(us.dataset.unscheduleGroup, 10)); }
|
||
});
|
||
|
||
(async () => {
|
||
if (await checkAuth()) {
|
||
buildSidebar(document.body.dataset.page);
|
||
loadTrash();
|
||
}
|
||
})(); |