789 lines
34 KiB
JavaScript
789 lines
34 KiB
JavaScript
let currentEntries = [];
|
||
let seenEntryIds = new Set();
|
||
let highlightNewIds = new Set();
|
||
let highlightClearTimer = null;
|
||
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';
|
||
let aiCorrecting = false;
|
||
let editDeletePhoto = false;
|
||
let linkSettings = null;
|
||
|
||
async function loadLinkSettings() {
|
||
if (!linkSettings) {
|
||
const res = await fetch(`${API}/api/settings`, { headers: hdr() });
|
||
linkSettings = await res.json().catch(() => ({}));
|
||
}
|
||
return linkSettings;
|
||
}
|
||
|
||
function isImageFile(name) {
|
||
return /\.(jpe?g|jfif|png|gif|webp|bmp|avif|svg|ico)$/i.test(name || '');
|
||
}
|
||
|
||
function mainPhotoPath(e) {
|
||
if (e.photo_path) return e.photo_path;
|
||
if (e.photos && e.photos.length) return e.photos[0].photo_path;
|
||
return null;
|
||
}
|
||
|
||
function filesHTML(files) {
|
||
if (!files || !files.length) return '';
|
||
return `<div class="entry-files">${files.map(f => {
|
||
const link = 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>`;
|
||
return `<span class="f-item">${link}<button class="f-detach" data-detach="${f.id}" title="Открепить файл">✕</button></span>`;
|
||
}).join('')}</div>`;
|
||
}
|
||
|
||
const AI_STATUS_LABELS = {
|
||
pending: 'ИИ: в очереди',
|
||
processing: 'ИИ: проверяет…',
|
||
done: 'ИИ: проверено',
|
||
skipped: 'ИИ: пропущено',
|
||
error: 'ИИ: ошибка',
|
||
reverted: 'ИИ: возвращён оригинал',
|
||
};
|
||
|
||
const AI_STATUS_ICONS = {
|
||
pending: 'clock',
|
||
processing: 'loader',
|
||
done: 'circle-check',
|
||
skipped: 'skip-forward',
|
||
error: 'circle-alert',
|
||
reverted: 'undo',
|
||
};
|
||
|
||
function aiBadge(e) {
|
||
if (!e || !e.ai_status) return '';
|
||
const st = e.ai_status;
|
||
const label = AI_STATUS_LABELS[st] || `ИИ: ${st}`;
|
||
const icon = AI_STATUS_ICONS[st] || 'sparkles';
|
||
return `<span class="ai-badge ai-badge-ic ai-badge-${esc(st)}" data-ai-badge="${e.id}" title="${esc(label)}" role="img" aria-label="${esc(label)}"><i data-lucide="${icon}"></i></span>`;
|
||
}
|
||
|
||
function findFile(id) {
|
||
for (const e of currentEntries) {
|
||
const f = (e.files || []).find(x => x.id === id);
|
||
if (f) return f;
|
||
}
|
||
return null;
|
||
}
|
||
|
||
async function detachFile(id) {
|
||
const f = findFile(id);
|
||
if (!f) return;
|
||
if (!confirm(`Открепить файл «${f.name}» от записи? Он переместится в «Файлы → Откреплённые».`)) return;
|
||
const res = await fetch(`${API}/api/files/${id}/detach`, { method: 'POST', headers: hdr() });
|
||
if (res.ok) {
|
||
showToast('Файл откреплён');
|
||
if (f.entry_id) refreshEntryCard(f.entry_id);
|
||
else loadEntries();
|
||
} else {
|
||
const err = await res.json().catch(() => ({}));
|
||
alert(err.error || 'Ошибка');
|
||
}
|
||
}
|
||
|
||
function gridEntryHTML(e) {
|
||
const mp = mainPhotoPath(e);
|
||
return `
|
||
<div class="card${highlightNewIds.has(e.id) ? ' entry-appear' : ' entry-enter'}">
|
||
${mp ? `<img src="${thumbSrc(mp)}" data-img="${API}${mp}" 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)}${aiBadge(e)}</div>
|
||
${filesHTML(e.files)}
|
||
<div class="time">${new Date(e.created_at).toLocaleString('ru')}</div>
|
||
</div>
|
||
<button class="edit" data-edit="${e.id}" title="Редактировать">✎</button>
|
||
<button class="del" data-del="${e.id}" title="Удалить">×</button>
|
||
</div>
|
||
`;
|
||
}
|
||
|
||
function cardEntryHTML(e) {
|
||
const mp = mainPhotoPath(e);
|
||
return `
|
||
<div class="journal-card${highlightNewIds.has(e.id) ? ' entry-appear' : ' entry-enter'}">
|
||
${mp
|
||
? `<img src="${thumbSrc(mp)}" data-img="${API}${mp}" loading="lazy" decoding="async" 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>`}
|
||
${e.group_name ? `<span class="group-badge" title="${esc(e.group_name)}">${esc(e.group_name)}</span>` : ''}
|
||
<div class="body">
|
||
<div class="name">${esc(e.student_name)}</div>
|
||
<div class="desc">${esc(e.description)}${aiBadge(e)}</div>
|
||
${filesHTML(e.files)}
|
||
</div>
|
||
<div class="foot">
|
||
<span>${new Date(e.created_at).toLocaleString('ru')}</span>
|
||
<span>
|
||
<button class="edit" data-edit="${e.id}" title="Редактировать">✎</button>
|
||
<button class="del" data-del="${e.id}" title="Удалить">×</button>
|
||
</span>
|
||
</div>
|
||
</div>
|
||
`;
|
||
}
|
||
|
||
function renderGrid() {
|
||
const grid = document.getElementById('grid');
|
||
if (!currentEntries.length) {
|
||
const fmt = d => d ? d.split('-').reverse().join('.') : '';
|
||
const from = fmt(document.getElementById('dateFrom').value);
|
||
const to = fmt(document.getElementById('dateTo').value);
|
||
let range = '';
|
||
if (from && to) range = ` за ${from} – ${to}`;
|
||
else if (from) range = ` с ${from}`;
|
||
else if (to) range = ` по ${to}`;
|
||
grid.className = 'grid';
|
||
grid.innerHTML = `<div class="empty">Ничего не найдено${range}</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('');
|
||
}
|
||
const items = Array.from(grid.children);
|
||
items.forEach((el, i) => {
|
||
el.style.animationDelay = `${Math.min(i * 45, 500)}ms`;
|
||
});
|
||
renderIcons();
|
||
}
|
||
|
||
function refreshEntryCardDom(e) {
|
||
const btn = document.querySelector(`[data-edit="${e.id}"]`);
|
||
const card = btn ? btn.closest('.card, .journal-card') : null;
|
||
if (!card) return;
|
||
const tmp = document.createElement('div');
|
||
tmp.innerHTML = (view === 'cards' ? cardEntryHTML(e) : gridEntryHTML(e)).trim();
|
||
const fresh = tmp.firstElementChild;
|
||
fresh.classList.remove('entry-enter', 'entry-appear');
|
||
fresh.style.animationDelay = '';
|
||
card.replaceWith(fresh);
|
||
renderIcons();
|
||
}
|
||
|
||
async function refreshEntryCard(id) {
|
||
const res = await fetch(`${API}/api/entries/${id}`, { headers: hdr() });
|
||
if (!res.ok) return false;
|
||
const e = await res.json();
|
||
const idx = currentEntries.findIndex(x => x.id === e.id);
|
||
if (idx !== -1) currentEntries[idx] = e;
|
||
refreshEntryCardDom(e);
|
||
return true;
|
||
}
|
||
|
||
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 dl = document.getElementById('studentFilterList');
|
||
const res = await fetch(`${API}/api/students/names`, { headers: hdr() });
|
||
const names = await res.json();
|
||
dl.innerHTML = names.map(n => `<option value="${esc(n)}">`).join('');
|
||
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();
|
||
const firstLoad = seenEntryIds.size === 0;
|
||
const newIds = firstLoad ? [] : data.entries.filter(e => !seenEntryIds.has(e.id)).map(e => e.id);
|
||
if (newIds.length) {
|
||
highlightNewIds = new Set([...highlightNewIds, ...newIds]);
|
||
if (highlightClearTimer) clearTimeout(highlightClearTimer);
|
||
highlightClearTimer = setTimeout(() => { highlightNewIds = new Set(); }, 8000);
|
||
}
|
||
data.entries.forEach(e => seenEntryIds.add(e.id));
|
||
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();
|
||
}
|
||
|
||
let liveSource = null;
|
||
let liveReconnectTimer = null;
|
||
let entriesRefreshTimer = null;
|
||
function applyAiUpdate(update) {
|
||
const e = currentEntries.find(x => x.id === update.id);
|
||
if (!e) return;
|
||
e.ai_status = update.ai_status;
|
||
e.ai_error = update.ai_error || null;
|
||
if (update.description !== null && update.description !== undefined) e.description = update.description;
|
||
if (update.description_ai !== null && update.description_ai !== undefined) e.description_ai = update.description_ai;
|
||
if (update.description_original !== null && update.description_original !== undefined) e.description_original = update.description_original;
|
||
if (update.ai_status === 'done' || update.ai_status === 'reverted') e.ai_checked_at = new Date().toISOString();
|
||
document.querySelectorAll(`[data-ai-badge="${update.id}"]`).forEach(badgeEl => {
|
||
const desc = badgeEl.closest('.desc');
|
||
if (desc && update.description !== null && update.description !== undefined) {
|
||
desc.textContent = e.description;
|
||
desc.insertAdjacentHTML('beforeend', aiBadge(e));
|
||
} else {
|
||
badgeEl.outerHTML = aiBadge(e);
|
||
}
|
||
});
|
||
renderIcons();
|
||
const editBadge = document.getElementById('editAiStatus');
|
||
if (editId === update.id && editBadge && editBadge.style.display !== 'none') {
|
||
renderEditAiStatus(e);
|
||
}
|
||
if (editId === update.id) {
|
||
const ta = document.getElementById('editDesc');
|
||
if (ta && document.activeElement !== ta && update.description !== null && update.description !== undefined) {
|
||
ta.value = e.description;
|
||
}
|
||
}
|
||
}
|
||
function connectLiveUpdates() {
|
||
if (liveSource) { liveSource.close(); liveSource = null; }
|
||
const connect = () => {
|
||
const t = sessionStorage.getItem('authToken');
|
||
if (!t) return;
|
||
liveSource = new EventSource(`${API}/api/events?token=${encodeURIComponent(t)}`);
|
||
liveSource.addEventListener('entries_changed', () => {
|
||
if (entriesRefreshTimer) return;
|
||
entriesRefreshTimer = setTimeout(() => {
|
||
entriesRefreshTimer = null;
|
||
showToast('Поступила новая запись — список обновлён');
|
||
page = 1;
|
||
loadEntries();
|
||
}, 300);
|
||
});
|
||
liveSource.addEventListener('ai_status', ev => {
|
||
try { applyAiUpdate(JSON.parse(ev.data)); } catch (err) {}
|
||
});
|
||
liveSource.onerror = () => {
|
||
if (liveSource) { liveSource.close(); liveSource = null; }
|
||
if (liveReconnectTimer) clearTimeout(liveReconnectTimer);
|
||
liveReconnectTimer = setTimeout(connect, 5000);
|
||
};
|
||
};
|
||
connect();
|
||
window.addEventListener('beforeunload', () => { if (liveSource) liveSource.close(); });
|
||
}
|
||
|
||
async function openEdit(id) {
|
||
const e = currentEntries.find(x => x.id === id);
|
||
if (!e) return;
|
||
editId = id;
|
||
editDeletePhoto = false;
|
||
fillAutocomplete();
|
||
document.getElementById('editName').value = e.student_name;
|
||
document.getElementById('editDesc').value = e.description;
|
||
renderEditAiStatus(e);
|
||
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;
|
||
renderEditPhotos(e.photos || [], e.photo_path);
|
||
document.getElementById('editModal').classList.add('open');
|
||
}
|
||
|
||
function renderEditPhotos(photos, mainPath) {
|
||
const preview = document.getElementById('editPhotoPreview');
|
||
preview.innerHTML = '';
|
||
if (!photos.length) return;
|
||
photos.forEach((p) => {
|
||
const isMain = p.photo_path === mainPath;
|
||
const div = document.createElement('div');
|
||
div.className = 'file-preview-item';
|
||
div.style.cssText = 'display:flex;align-items:center;gap:8px;padding:6px 8px;background:var(--bg);border:1px solid var(--border);border-radius:6px;font-size:.8rem';
|
||
div.dataset.photoId = p.id;
|
||
const applyStyle = isMain
|
||
? 'background:#16a34a;border:none;color:#fff;border-radius:6px;padding:4px 10px;font-size:.75rem;cursor:default;white-space:nowrap'
|
||
: 'background:var(--card);border:1px solid var(--border);color:var(--text);border-radius:6px;padding:4px 10px;font-size:.75rem;cursor:pointer;white-space:nowrap';
|
||
div.innerHTML = `<img src="${thumbSrc(p.photo_path)}" style="width:48px;height:48px;object-fit:cover;border-radius:4px"> <span style="flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap">${esc(p.photo_path.split('/').pop())}</span> <button type="button" class="apply-photo-btn" style="${applyStyle}" title="${isMain ? 'Главное фото записи' : 'Сделать главным фото записи'}" ${isMain ? 'disabled' : ''}>${isMain ? 'Применено ✓' : 'Применить'}</button> <button type="button" class="delete-entry-photo" style="background:none;border:none;color:#ef4444;cursor:pointer;font-size:1.1rem;line-height:1" title="Удалить фото">✕</button>`;
|
||
preview.appendChild(div);
|
||
const thumb = div.querySelector('img');
|
||
const showMissing = () => {
|
||
const ph = document.createElement('span');
|
||
ph.textContent = 'нет файла';
|
||
ph.style.cssText = 'width:48px;height:48px;display:flex;align-items:center;justify-content:center;background:var(--bg);border:1px dashed var(--border);border-radius:4px;font-size:.6rem;color:var(--muted);text-align:center;line-height:1.1;flex:0 0 auto';
|
||
thumb.replaceWith(ph);
|
||
};
|
||
thumb.addEventListener('error', showMissing);
|
||
if (thumb.complete && thumb.naturalWidth === 0) showMissing();
|
||
div.querySelector('.delete-entry-photo').addEventListener('click', async () => {
|
||
if (!confirm('Удалить это фото?')) return;
|
||
const res = await fetch(`${API}/api/entries/${editId}/photos/${p.id}`, { method: 'DELETE', headers: hdr() });
|
||
if (res.ok) {
|
||
div.remove();
|
||
showToast('Фото удалено');
|
||
refreshEntryCard(editId);
|
||
} else {
|
||
alert('Ошибка удаления');
|
||
}
|
||
});
|
||
});
|
||
}
|
||
|
||
async function applyMainPhoto(btn) {
|
||
const div = btn.closest('.file-preview-item');
|
||
const photoId = div?.dataset.photoId;
|
||
if (!photoId) return;
|
||
btn.disabled = true;
|
||
const res = await fetch(`${API}/api/entries/${editId}/photos/${photoId}/main`, { method: 'PUT', headers: hdr() });
|
||
if (!res.ok) {
|
||
btn.disabled = false;
|
||
const err = await res.json().catch(() => ({}));
|
||
alert(err.error || 'Ошибка');
|
||
return;
|
||
}
|
||
document.querySelectorAll('#editPhotoPreview .apply-photo-btn').forEach(b => {
|
||
b.disabled = false;
|
||
b.textContent = 'Применить';
|
||
b.title = 'Сделать главным фото записи';
|
||
b.style.background = 'var(--card)';
|
||
b.style.color = 'var(--text)';
|
||
});
|
||
btn.disabled = true;
|
||
btn.textContent = 'Применено ✓';
|
||
btn.title = 'Главное фото записи';
|
||
btn.style.background = '#16a34a';
|
||
btn.style.color = '#fff';
|
||
showToast('Главное фото обновлено');
|
||
refreshEntryCard(editId);
|
||
}
|
||
|
||
function closeEdit() {
|
||
document.getElementById('editModal').classList.remove('open');
|
||
document.getElementById('editFiles').value = '';
|
||
document.getElementById('editFilesPreview').innerHTML = '';
|
||
document.getElementById('editPhoto').value = '';
|
||
document.getElementById('editPhotoPreview').innerHTML = '';
|
||
document.getElementById('editAiStatus').style.display = 'none';
|
||
editId = null;
|
||
editDeletePhoto = false;
|
||
}
|
||
|
||
async function aiCorrect() {
|
||
if (aiCorrecting) return;
|
||
const textarea = document.getElementById('editDesc');
|
||
const text = textarea.value.trim();
|
||
if (!text) { showToast('Нет текста для исправления'); return; }
|
||
const btn = document.getElementById('aiCorrectBtn');
|
||
const modal = document.getElementById('aiCorrectModal');
|
||
const suggestionEl = document.getElementById('aiSuggestion');
|
||
aiCorrecting = true;
|
||
btn.disabled = true;
|
||
btn.textContent = '⏳';
|
||
suggestionEl.textContent = 'Обработка...';
|
||
modal.classList.add('open');
|
||
try {
|
||
const res = await fetch(`${API}/api/ai/correct`, {
|
||
method: 'POST',
|
||
headers: hdrJson(),
|
||
body: JSON.stringify({ text })
|
||
});
|
||
if (!res.ok) {
|
||
const err = await res.json();
|
||
throw new Error(err.error || 'Ошибка ИИ');
|
||
}
|
||
const data = await res.json();
|
||
suggestionEl.textContent = data.suggestion || text;
|
||
} catch (e) {
|
||
suggestionEl.textContent = 'Ошибка: ' + e.message;
|
||
showToast('ИИ недоступен: ' + e.message);
|
||
} finally {
|
||
aiCorrecting = false;
|
||
btn.disabled = false;
|
||
btn.textContent = '✨';
|
||
}
|
||
}
|
||
|
||
function closeAiCorrect() {
|
||
document.getElementById('aiCorrectModal').classList.remove('open');
|
||
}
|
||
|
||
function acceptAiCorrect() {
|
||
const suggestion = document.getElementById('aiSuggestion').textContent;
|
||
if (suggestion && suggestion !== 'Обработка...' && !suggestion.startsWith('Ошибка:')) {
|
||
document.getElementById('editDesc').value = suggestion;
|
||
}
|
||
closeAiCorrect();
|
||
}
|
||
|
||
function renderEditAiStatus(e) {
|
||
const el = document.getElementById('editAiStatus');
|
||
if (!e || !e.ai_status) { el.style.display = 'none'; el.innerHTML = ''; return; }
|
||
el.style.display = 'block';
|
||
const changed = e.description_ai && e.description_original && e.description_ai !== e.description_original;
|
||
const parts = [AI_STATUS_LABELS[e.ai_status] || `ИИ: ${e.ai_status}`];
|
||
if (e.ai_checked_at) parts.push(new Date(e.ai_checked_at).toLocaleString('ru'));
|
||
let html = `<span>${esc(parts.join(' · '))}</span>`;
|
||
if (changed) html += ` <button type="button" id="aiRevertBtn" class="ai-btn" style="font-size:.75rem" title="Вернуть исходный текст (до ИИ)">↩ Оригинал</button>`;
|
||
html += ` <button type="button" id="aiRecheckBtn" class="ai-btn" style="font-size:.75rem" title="Проверить заново">⟳ Проверить</button>`;
|
||
el.innerHTML = html;
|
||
const rb = document.getElementById('aiRevertBtn');
|
||
if (rb) rb.addEventListener('click', () => revertAi(e.id));
|
||
const cb = document.getElementById('aiRecheckBtn');
|
||
if (cb) cb.addEventListener('click', () => recheckAi(e.id));
|
||
}
|
||
|
||
async function revertAi(id) {
|
||
if (!confirm('Вернуть исходный текст записи (до ИИ-проверки)?')) return;
|
||
const res = await fetch(`${API}/api/entries/${id}/ai/revert`, { method: 'POST', headers: hdr() });
|
||
if (!res.ok) { const err = await res.json().catch(() => ({})); alert(err.error || 'Ошибка'); return; }
|
||
const e = currentEntries.find(x => x.id === id);
|
||
if (e) {
|
||
if (e.description_original) e.description = e.description_original;
|
||
e.description_ai = null;
|
||
e.ai_status = 'reverted';
|
||
e.ai_error = null;
|
||
}
|
||
showToast('Оригинал восстановлен');
|
||
closeEdit();
|
||
refreshEntryCard(id);
|
||
}
|
||
|
||
async function recheckAi(id) {
|
||
const res = await fetch(`${API}/api/entries/${id}/ai/recheck`, { method: 'POST', headers: hdr() });
|
||
if (!res.ok) { const err = await res.json().catch(() => ({})); alert(err.error || 'Ошибка'); return; }
|
||
const e = currentEntries.find(x => x.id === id);
|
||
if (e) {
|
||
e.ai_status = 'pending';
|
||
e.ai_error = null;
|
||
}
|
||
showToast('Отправлено на ИИ-проверку');
|
||
closeEdit();
|
||
refreshEntryCard(id);
|
||
}
|
||
|
||
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 formData = new FormData();
|
||
formData.append('student_name', name);
|
||
formData.append('group_id', group_id);
|
||
formData.append('description', description);
|
||
|
||
const photoFiles = document.getElementById('editPhoto').files;
|
||
for (const f of photoFiles) formData.append('photo', f);
|
||
|
||
const res = await fetch(`${API}/api/entries/${editId}`, {
|
||
method: 'PUT',
|
||
headers: hdr(),
|
||
body: formData
|
||
});
|
||
if (!res.ok) { const err = await res.json(); alert(err.error); return; }
|
||
|
||
const files = document.getElementById('editFiles').files;
|
||
if (files.length) {
|
||
const filesFormData = new FormData();
|
||
for (const f of files) filesFormData.append('files', f);
|
||
const uploadRes = await fetch(`${API}/api/entries/${editId}/files`, {
|
||
method: 'POST',
|
||
headers: hdr(),
|
||
body: filesFormData
|
||
});
|
||
if (!uploadRes.ok) {
|
||
const err = await uploadRes.json().catch(() => ({}));
|
||
alert(err.error || 'Ошибка загрузки файлов');
|
||
return;
|
||
}
|
||
}
|
||
|
||
const id = editId;
|
||
closeEdit();
|
||
const refreshed = await refreshEntryCard(id);
|
||
if (!refreshed) loadEntries();
|
||
showToast('Запись обновлена');
|
||
}
|
||
|
||
async function delEntry(id) {
|
||
if (!confirm('Переместить запись в корзину? Файлы и фото сохранятся.')) return;
|
||
await fetch(`${API}/api/entries/${id}`, { method: 'DELETE', headers: hdr() });
|
||
showToast('Запись перемещена в корзину');
|
||
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);
|
||
});
|
||
document.getElementById('editForm').addEventListener('submit', (e) => {
|
||
e.preventDefault();
|
||
saveEdit();
|
||
});
|
||
document.getElementById('editFiles').addEventListener('change', (e) => {
|
||
const preview = document.getElementById('editFilesPreview');
|
||
preview.innerHTML = '';
|
||
Array.from(e.target.files).forEach((f, i) => {
|
||
const div = document.createElement('div');
|
||
div.className = 'file-preview-item';
|
||
div.style.cssText = 'display:flex;align-items:center;gap:8px;padding:6px 8px;background:var(--bg);border:1px solid var(--border);border-radius:6px;font-size:.8rem';
|
||
div.innerHTML = `<span>${esc(f.name)}</span> <span style="color:var(--muted)">${(f.size/1024).toFixed(1)} KB</span> <button type="button" data-remove-file="${i}" style="background:none;border:none;color:var(--muted);cursor:pointer;font-size:1.1rem;line-height:1">✕</button>`;
|
||
preview.appendChild(div);
|
||
});
|
||
});
|
||
|
||
document.getElementById('editPhoto').addEventListener('change', (e) => {
|
||
const preview = document.getElementById('editPhotoPreview');
|
||
Array.from(e.target.files).forEach(f => {
|
||
const div = document.createElement('div');
|
||
div.className = 'file-preview-item';
|
||
div.style.cssText = 'display:flex;align-items:center;gap:8px;padding:6px 8px;background:var(--bg);border:1px solid var(--border);border-radius:6px;font-size:.8rem';
|
||
const url = URL.createObjectURL(f);
|
||
div.innerHTML = `<img src="${url}" style="width:48px;height:48px;object-fit:cover;border-radius:4px"> <span>${esc(f.name)}</span> <span style="color:var(--muted)">${(f.size/1024).toFixed(1)} KB</span> <button type="button" data-remove-new-photo="${f.name}" style="background:none;border:none;color:var(--muted);cursor:pointer;font-size:1.1rem;line-height:1">✕</button>`;
|
||
preview.appendChild(div);
|
||
});
|
||
});
|
||
|
||
function removeEditFile(index) {
|
||
const input = document.getElementById('editFiles');
|
||
const dt = new DataTransfer();
|
||
Array.from(input.files).forEach((f, i) => { if (i !== index) dt.items.add(f); });
|
||
input.files = dt.files;
|
||
input.dispatchEvent(new Event('change'));
|
||
}
|
||
|
||
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);
|
||
}
|
||
|
||
let linkGroups = [];
|
||
let linkStudentsLoaded = false;
|
||
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();
|
||
}
|
||
if (!linkStudentsLoaded) {
|
||
const res = await fetch(`${API}/api/students/names`, { headers: hdr() });
|
||
const names = await res.json();
|
||
const dl = document.getElementById('linkStudentList');
|
||
dl.innerHTML = names.map(n => `<option value="${esc(n)}">`).join('');
|
||
linkStudentsLoaded = true;
|
||
}
|
||
const currentStudent = document.getElementById('studentFilter').value;
|
||
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>`;
|
||
document.getElementById('linkGroupPreview').value = g ? g.name : '';
|
||
const dfl = df.split('-').reverse().join('.');
|
||
document.getElementById('linkNameInput').value = `${g ? g.name : ''} ${dfl}`;
|
||
document.getElementById('linkStudent').value = currentStudent;
|
||
document.getElementById('linkExpiresDays').value = '7';
|
||
document.getElementById('linkShowNames').checked = false;
|
||
document.getElementById('linkPassword').value = '';
|
||
document.getElementById('linkMessage').value = '';
|
||
document.getElementById('linkUrl').value = '';
|
||
const s = await loadLinkSettings();
|
||
document.getElementById('linkShowNames').checked = s.share_show_student_names !== 'false';
|
||
document.getElementById('linkShowMessage').checked = s.share_show_student_message === 'true';
|
||
document.getElementById('linkShowDate').checked = s.share_show_entry_date === 'true';
|
||
document.getElementById('linkShowPhotos').checked = s.share_show_group_photos !== 'false';
|
||
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 student_name = document.getElementById('linkStudent').value;
|
||
const date_from = document.getElementById('dateFrom').value;
|
||
const date_to = document.getElementById('dateTo').value;
|
||
const expires_days = parseInt(document.getElementById('linkExpiresDays').value, 10);
|
||
const show_student_names = document.getElementById('linkShowNames').checked;
|
||
const access_password = document.getElementById('linkPassword').value;
|
||
const message = document.getElementById('linkMessage').value.trim();
|
||
const link_url = document.getElementById('linkUrl').value.trim();
|
||
if (!name) { alert('Введите название'); return; }
|
||
if (expires_days < 1 || expires_days > 365) { alert('Дней истечения: 1-365'); return; }
|
||
const expires_at = new Date(Date.now() + expires_days * 24 * 60 * 60 * 1000).toISOString();
|
||
const res = await fetch(`${API}/api/links`, {
|
||
method: 'POST',
|
||
headers: hdrJson(),
|
||
body: JSON.stringify({ name, group_id, student_name: student_name || null, date_from: date_from || null, date_to: date_to || null, show_student_names, expires_at, access_password: access_password || null, message: message || null, link_url: link_url || null, show_student_message: document.getElementById('linkShowMessage').checked, show_entry_date: document.getElementById('linkShowDate').checked, show_group_photos: document.getElementById('linkShowPhotos').checked })
|
||
});
|
||
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); }
|
||
|
||
document.getElementById('imgModal').addEventListener('click', e => e.currentTarget.classList.remove('open'));
|
||
document.getElementById('editModal').addEventListener('click', e => { if (e.target === e.currentTarget) closeEdit(); });
|
||
document.getElementById('linkModal').addEventListener('click', e => { if (e.target === e.currentTarget) closeLinkCreate(); });
|
||
|
||
document.getElementById('grid').addEventListener('click', e => {
|
||
const det = e.target.closest('[data-detach]');
|
||
if (det) { detachFile(parseInt(det.dataset.detach, 10)); return; }
|
||
const ed = e.target.closest('[data-edit]');
|
||
if (ed) { openEdit(parseInt(ed.dataset.edit, 10)); return; }
|
||
const dl = e.target.closest('[data-del]');
|
||
if (dl) { delEntry(parseInt(dl.dataset.del, 10)); }
|
||
});
|
||
|
||
document.getElementById('editFilesPreview').addEventListener('click', e => {
|
||
const btn = e.target.closest('[data-remove-file]');
|
||
if (btn) removeEditFile(parseInt(btn.dataset.removeFile, 10));
|
||
});
|
||
|
||
document.getElementById('editPhotoPreview').addEventListener('click', e => {
|
||
const removeBtn = e.target.closest('[data-remove-photo]');
|
||
if (removeBtn) {
|
||
document.getElementById('editPhoto').value = '';
|
||
document.getElementById('editPhotoPreview').innerHTML = '';
|
||
return;
|
||
}
|
||
const applyBtn = e.target.closest('.apply-photo-btn');
|
||
if (applyBtn) { applyMainPhoto(applyBtn); return; }
|
||
const removeNewBtn = e.target.closest('[data-remove-new-photo]');
|
||
if (removeNewBtn) {
|
||
const fileName = removeNewBtn.dataset.removeNewPhoto;
|
||
const input = document.getElementById('editPhoto');
|
||
const dt = new DataTransfer();
|
||
Array.from(input.files).forEach(f => { if (f.name !== fileName) dt.items.add(f); });
|
||
input.files = dt.files;
|
||
removeNewBtn.closest('.file-preview-item').remove();
|
||
}
|
||
});
|
||
|
||
document.getElementById('aiCorrectBtn').addEventListener('click', aiCorrect);
|
||
document.getElementById('aiAcceptBtn').addEventListener('click', acceptAiCorrect);
|
||
document.getElementById('aiRejectBtn').addEventListener('click', closeAiCorrect);
|
||
document.getElementById('aiCorrectModal').addEventListener('click', e => {
|
||
if (e.target === e.currentTarget) closeAiCorrect();
|
||
});
|
||
|
||
document.getElementById('exportCsvBtn').addEventListener('click', exportCSV);
|
||
document.getElementById('createLinkBtn').addEventListener('click', openLinkCreate);
|
||
document.querySelectorAll('#viewToggle button').forEach(b => b.addEventListener('click', () => setView(b.dataset.view)));
|
||
document.getElementById('editCancelBtn').addEventListener('click', closeEdit);
|
||
document.getElementById('linkCreateCloseBtn').addEventListener('click', closeLinkCreate);
|
||
document.getElementById('linkCreateSaveBtn').addEventListener('click', createLink);
|
||
document.getElementById('linkCopyBtn').addEventListener('click', copyLink);
|
||
|
||
(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();
|
||
connectLiveUpdates();
|
||
}
|
||
})(); |