batch add students: add optional group selection in batch modal
This commit is contained in:
@@ -0,0 +1,285 @@
|
||||
<!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="students">
|
||||
<div class="layout">
|
||||
<div class="sidebar" id="sidebar"></div>
|
||||
<div class="main">
|
||||
<h2>Ученики</h2>
|
||||
<div class="add-row">
|
||||
<input type="text" id="newStudent" placeholder="Фамилия Имя" list="studentAddList">
|
||||
<datalist id="studentAddList"></datalist>
|
||||
<button onclick="addStudent()">Добавить</button>
|
||||
<button class="btn-ghost" onclick="openBatchAdd()">Пакетное добавление</button>
|
||||
<button class="btn-ghost" onclick="openBatchAttach()">Прикрепить к группе</button>
|
||||
</div>
|
||||
<div style="display:flex;gap:8px;margin-bottom:16px;flex-wrap:wrap">
|
||||
<input type="text" id="studentSearch" class="search-input" placeholder="Поиск по имени..." style="flex:1;min-width:200px;max-width:340px;padding:10px 14px;font-size:.9rem">
|
||||
<select id="studentGroupFilter" class="search-input" style="padding:10px 14px"><option value="">Все группы</option></select>
|
||||
<button class="btn-link" onclick="exportStudentsCSV()">Экспорт CSV</button>
|
||||
</div>
|
||||
<div class="list" id="studentList"></div>
|
||||
<div class="pager" id="pager"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="modal-overlay" id="batchModal" onclick="if(event.target===this)closeBatchAdd()">
|
||||
<div class="edit-modal">
|
||||
<h3>Пакетное добавление учеников</h3>
|
||||
<div>
|
||||
<label>Имена — каждое с новой строки</label>
|
||||
<textarea id="batchText" placeholder="Иванов Иван Петров Пётр Сидоров Сидор"></textarea>
|
||||
</div>
|
||||
<div>
|
||||
<label>Группа (необязательно)</label>
|
||||
<select id="batchGroup"><option value="">— без группы —</option></select>
|
||||
</div>
|
||||
<div class="actions">
|
||||
<button class="cancel" onclick="closeBatchAdd()">Отмена</button>
|
||||
<button class="save" onclick="batchAdd()">Добавить</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="modal-overlay" id="attachModal" onclick="if(event.target===this)closeBatchAttach()">
|
||||
<div class="edit-modal">
|
||||
<h3>Прикрепить к группе</h3>
|
||||
<div>
|
||||
<label>Группа</label>
|
||||
<select id="attachGroup"><option value="">— выберите группу —</option></select>
|
||||
</div>
|
||||
<div>
|
||||
<label>Ученики</label>
|
||||
<input type="text" id="attachSearch" class="search-input" placeholder="Поиск по имени..." style="width:100%;margin-bottom:8px">
|
||||
<div class="attach-list" id="attachList"></div>
|
||||
</div>
|
||||
<div class="actions">
|
||||
<button class="cancel" onclick="closeBatchAttach()">Отмена</button>
|
||||
<button class="save" onclick="batchAttach()">Прикрепить</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="toast" id="toast"></div>
|
||||
<script src="admin.js"></script>
|
||||
<script>
|
||||
// --- Students ---
|
||||
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" onclick="saveStudent(${s.id})">Сохранить</button>
|
||||
<button class="btn-link" onclick="copyStudentLink('${esc(s.name).replace(/'/g, ''')}')" title="Скопировать ссылку с подстановкой имени">Ссылка</button>
|
||||
<button class="btn-del" onclick="delStudent(${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); }
|
||||
}
|
||||
|
||||
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('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}`);
|
||||
}
|
||||
|
||||
// --- Batch attach ---
|
||||
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('attachSearch').addEventListener('input', () => {
|
||||
clearTimeout(aSearchTimer);
|
||||
aSearchTimer = setTimeout(renderAttachList, 300);
|
||||
});
|
||||
|
||||
(async () => {
|
||||
if (await checkAuth()) {
|
||||
buildSidebar(document.body.dataset.page);
|
||||
fillAutocomplete();
|
||||
loadGroupOptions();
|
||||
loadStudents();
|
||||
}
|
||||
})();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
Reference in New Issue
Block a user