feat(modules): batch module import in admin, module usage in journal
This commit is contained in:
+2
-2
@@ -49,8 +49,8 @@ body{font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif;b
|
|||||||
.filters label{font-size:.8rem;color:var(--muted);display:block;margin-bottom:4px}
|
.filters label{font-size:.8rem;color:var(--muted);display:block;margin-bottom:4px}
|
||||||
.search-input{background:var(--card);border:1px solid var(--border);color:var(--text);border-radius:8px;padding:8px 12px;font-size:.85rem;outline:none}
|
.search-input{background:var(--card);border:1px solid var(--border);color:var(--text);border-radius:8px;padding:8px 12px;font-size:.85rem;outline:none}
|
||||||
.search-input:focus{border-color:var(--accent)}
|
.search-input:focus{border-color:var(--accent)}
|
||||||
#studentFilter,#linkStudent{background:var(--card);border:1px solid var(--border);color:var(--text);border-radius:8px;padding:8px 12px;font-size:.85rem;outline:none;width:100%;max-width:220px}
|
#studentFilter,#linkStudent,#moduleFilter{background:var(--card);border:1px solid var(--border);color:var(--text);border-radius:8px;padding:8px 12px;font-size:.85rem;outline:none;width:100%;max-width:220px}
|
||||||
#studentFilter:focus,#linkStudent:focus{border-color:var(--accent)}
|
#studentFilter:focus,#linkStudent:focus,#moduleFilter:focus{border-color:var(--accent)}
|
||||||
|
|
||||||
.pager{display:flex;gap:6px;align-items:center;justify-content:center;margin-top:20px;flex-wrap:wrap}
|
.pager{display:flex;gap:6px;align-items:center;justify-content:center;margin-top:20px;flex-wrap:wrap}
|
||||||
.pager button{background:var(--card);border:1px solid var(--border);color:var(--text);border-radius:8px;padding:6px 12px;font-size:.85rem;cursor:pointer}
|
.pager button{background:var(--card);border:1px solid var(--border);color:var(--text);border-radius:8px;padding:6px 12px;font-size:.85rem;cursor:pointer}
|
||||||
|
|||||||
+2
-3
@@ -129,9 +129,8 @@ nav a:hover{color:var(--text)}
|
|||||||
|
|
||||||
<div class="field" id="moduleField" style="display:none">
|
<div class="field" id="moduleField" style="display:none">
|
||||||
<label>Тема модуля</label>
|
<label>Тема модуля</label>
|
||||||
<select id="moduleInput">
|
<input type="text" id="moduleInput" list="moduleInputList" placeholder="Начни вводить название темы..." autocomplete="off">
|
||||||
<option value="">— выбери тему —</option>
|
<datalist id="moduleInputList"></datalist>
|
||||||
</select>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="field">
|
<div class="field">
|
||||||
|
|||||||
+3
-2
@@ -43,7 +43,7 @@
|
|||||||
<div><label>Поиск</label><input type="text" id="searchInput" class="search-input" placeholder="Имя или описание"></div>
|
<div><label>Поиск</label><input type="text" id="searchInput" class="search-input" placeholder="Имя или описание"></div>
|
||||||
<div><label>Студент</label><input type="text" id="studentFilter" list="studentFilterList" placeholder="Все / введите имя..."><datalist id="studentFilterList"></datalist></div>
|
<div><label>Студент</label><input type="text" id="studentFilter" list="studentFilterList" placeholder="Все / введите имя..."><datalist id="studentFilterList"></datalist></div>
|
||||||
<div><label>Группа</label><select id="groupFilter"><option value="">Все группы</option></select></div>
|
<div><label>Группа</label><select id="groupFilter"><option value="">Все группы</option></select></div>
|
||||||
<div><label>Модуль</label><select id="moduleFilter"><option value="">Все модули</option></select></div>
|
<div><label>Модуль</label><input type="text" id="moduleFilter" list="moduleFilterList" placeholder="Все модули" autocomplete="off"><datalist id="moduleFilterList"></datalist></div>
|
||||||
<div><label>С</label><input type="date" id="dateFrom"></div>
|
<div><label>С</label><input type="date" id="dateFrom"></div>
|
||||||
<div><label>По</label><input type="date" id="dateTo"></div>
|
<div><label>По</label><input type="date" id="dateTo"></div>
|
||||||
<div><label> </label><button type="button" id="exportCsvBtn" 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 type="button" id="exportCsvBtn" 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>
|
||||||
@@ -79,7 +79,8 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="settings-field">
|
<div class="settings-field">
|
||||||
<label>Тема модуля</label>
|
<label>Тема модуля</label>
|
||||||
<select id="editModule" class="settings-input"></select>
|
<input type="text" id="editModule" class="settings-input" list="editModuleList" placeholder="Начните вводить название..." autocomplete="off">
|
||||||
|
<datalist id="editModuleList"></datalist>
|
||||||
</div>
|
</div>
|
||||||
<div class="settings-field full">
|
<div class="settings-field full">
|
||||||
<label>Что делала(а) <span style="color:#ef4444">*</span>
|
<label>Что делала(а) <span style="color:#ef4444">*</span>
|
||||||
|
|||||||
+62
-21
@@ -5,7 +5,10 @@ const placeholderSrc = preview.src;
|
|||||||
const removePhotoBtn = document.getElementById('removePhotoBtn');
|
const removePhotoBtn = document.getElementById('removePhotoBtn');
|
||||||
const groupInput = document.getElementById('groupInput');
|
const groupInput = document.getElementById('groupInput');
|
||||||
const moduleInput = document.getElementById('moduleInput');
|
const moduleInput = document.getElementById('moduleInput');
|
||||||
|
const moduleInputList = document.getElementById('moduleInputList');
|
||||||
const moduleField = document.getElementById('moduleField');
|
const moduleField = document.getElementById('moduleField');
|
||||||
|
const moduleNames = new Map();
|
||||||
|
let moduleSearchTimer = null;
|
||||||
const toast = document.getElementById('toast');
|
const toast = document.getElementById('toast');
|
||||||
const camVideo = document.getElementById('camVideo');
|
const camVideo = document.getElementById('camVideo');
|
||||||
const camCanvas = document.getElementById('camCanvas');
|
const camCanvas = document.getElementById('camCanvas');
|
||||||
@@ -134,22 +137,46 @@ if (params.get('nameInput')) {
|
|||||||
field.innerHTML = `<label>Фамилия и имя</label><div class="fixed-name" id="fixedName">${escName(studentName)}</div>`;
|
field.innerHTML = `<label>Фамилия и имя</label><div class="fixed-name" id="fixedName">${escName(studentName)}</div>`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function renderModuleOptions(list) {
|
||||||
|
list.forEach(m => moduleNames.set(m.name, m.id));
|
||||||
|
moduleInputList.innerHTML = '';
|
||||||
|
list.forEach(m => {
|
||||||
|
const opt = document.createElement('option');
|
||||||
|
opt.value = m.name;
|
||||||
|
moduleInputList.appendChild(opt);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchModules(q, limit) {
|
||||||
|
const p = new URLSearchParams();
|
||||||
|
if (q) p.set('search', q);
|
||||||
|
p.set('limit', String(limit));
|
||||||
|
try {
|
||||||
|
const res = await fetch(`${API}/api/modules?${p}`);
|
||||||
|
if (!res.ok) return [];
|
||||||
|
const data = await res.json();
|
||||||
|
return data.modules || [];
|
||||||
|
} catch (e) { return []; }
|
||||||
|
}
|
||||||
|
|
||||||
|
async function resolveModuleId(name) {
|
||||||
|
const n = (name || '').trim();
|
||||||
|
if (!n) return null;
|
||||||
|
if (moduleNames.has(n)) return moduleNames.get(n);
|
||||||
|
const list = await fetchModules(n, 5);
|
||||||
|
renderModuleOptions(list);
|
||||||
|
const lower = n.toLowerCase();
|
||||||
|
const exact = list.find(m => m.name.toLowerCase() === lower);
|
||||||
|
return exact ? exact.id : null;
|
||||||
|
}
|
||||||
|
|
||||||
(async () => {
|
(async () => {
|
||||||
try {
|
try {
|
||||||
const mRes = await fetch(`${API}/api/modules`);
|
const list = await fetchModules('', 50);
|
||||||
if (mRes.ok) {
|
if (list.length) {
|
||||||
const mData = await mRes.json();
|
renderModuleOptions(list);
|
||||||
const list = mData.modules || [];
|
moduleInput.required = true;
|
||||||
if (list.length) {
|
moduleField.style.display = '';
|
||||||
list.forEach(m => {
|
|
||||||
const opt = document.createElement('option');
|
|
||||||
opt.value = m.id;
|
|
||||||
opt.textContent = m.lessons_count > 0 ? `${m.name} · ${m.lessons_count} зан.` : m.name;
|
|
||||||
moduleInput.appendChild(opt);
|
|
||||||
});
|
|
||||||
moduleInput.required = true;
|
|
||||||
moduleField.style.display = '';
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
} catch (e) { /* ignore */ }
|
} catch (e) { /* ignore */ }
|
||||||
const res = await fetch(`${API}/api/students`);
|
const res = await fetch(`${API}/api/students`);
|
||||||
@@ -238,11 +265,15 @@ form.addEventListener('submit', async (e) => {
|
|||||||
setTimeout(() => toast.classList.remove('show', 'error'), 3200);
|
setTimeout(() => toast.classList.remove('show', 'error'), 3200);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (moduleField.style.display !== 'none' && !moduleInput.value) {
|
let moduleId = '';
|
||||||
toast.textContent = 'Выберите тему модуля';
|
if (moduleField.style.display !== 'none') {
|
||||||
toast.classList.add('show', 'error');
|
moduleId = await resolveModuleId(moduleInput.value);
|
||||||
setTimeout(() => toast.classList.remove('show', 'error'), 3200);
|
if (!moduleId) {
|
||||||
return;
|
toast.textContent = 'Выберите тему модуля из списка подсказок';
|
||||||
|
toast.classList.add('show', 'error');
|
||||||
|
setTimeout(() => toast.classList.remove('show', 'error'), 3200);
|
||||||
|
return;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
const btn = document.getElementById('submitBtn');
|
const btn = document.getElementById('submitBtn');
|
||||||
btn.disabled = true;
|
btn.disabled = true;
|
||||||
@@ -253,7 +284,7 @@ form.addEventListener('submit', async (e) => {
|
|||||||
selectedFiles.forEach(f => fd.append('files', f, f.name));
|
selectedFiles.forEach(f => fd.append('files', f, f.name));
|
||||||
fd.append('student_name', studentName || document.getElementById('nameInput').value);
|
fd.append('student_name', studentName || document.getElementById('nameInput').value);
|
||||||
fd.append('group_id', groupInput.value);
|
fd.append('group_id', groupInput.value);
|
||||||
fd.append('module_id', moduleInput.value);
|
fd.append('module_id', moduleId || '');
|
||||||
fd.append('description', document.getElementById('descInput').value);
|
fd.append('description', document.getElementById('descInput').value);
|
||||||
fd.append('website', document.getElementById('hpWebsite').value);
|
fd.append('website', document.getElementById('hpWebsite').value);
|
||||||
|
|
||||||
@@ -285,4 +316,14 @@ document.getElementById('openCamBtn').addEventListener('click', openCam);
|
|||||||
filesClearBtn.addEventListener('click', clearFiles);
|
filesClearBtn.addEventListener('click', clearFiles);
|
||||||
document.getElementById('filesBtn').addEventListener('click', () => document.getElementById('filesInput').click());
|
document.getElementById('filesBtn').addEventListener('click', () => document.getElementById('filesInput').click());
|
||||||
document.getElementById('cancelCamBtn').addEventListener('click', closeCam);
|
document.getElementById('cancelCamBtn').addEventListener('click', closeCam);
|
||||||
document.getElementById('captureBtn').addEventListener('click', capture);
|
document.getElementById('captureBtn').addEventListener('click', capture);
|
||||||
|
moduleInput.addEventListener('input', () => {
|
||||||
|
clearTimeout(moduleSearchTimer);
|
||||||
|
const q = moduleInput.value.trim();
|
||||||
|
moduleSearchTimer = setTimeout(async () => {
|
||||||
|
renderModuleOptions(await fetchModules(q, 20));
|
||||||
|
}, 250);
|
||||||
|
});
|
||||||
|
moduleInput.addEventListener('focus', async () => {
|
||||||
|
if (!moduleInput.value.trim()) renderModuleOptions(await fetchModules('', 50));
|
||||||
|
});
|
||||||
+85
-28
@@ -5,6 +5,10 @@ let highlightClearTimer = null;
|
|||||||
let editId = null;
|
let editId = null;
|
||||||
let editGroups = [];
|
let editGroups = [];
|
||||||
let editModules = [];
|
let editModules = [];
|
||||||
|
let moduleNames = new Map();
|
||||||
|
let moduleFilterId = '';
|
||||||
|
let moduleSearchTimer = null;
|
||||||
|
let editModuleSearchTimer = null;
|
||||||
let modulesLoaded = false;
|
let modulesLoaded = false;
|
||||||
let groupsLoaded = false;
|
let groupsLoaded = false;
|
||||||
let studentsLoaded = false;
|
let studentsLoaded = false;
|
||||||
@@ -219,29 +223,64 @@ async function loadGroupOptions() {
|
|||||||
groupsLoaded = true;
|
groupsLoaded = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function loadModuleOptions() {
|
function renderModuleOptions(datalistId, list) {
|
||||||
const res = await fetch(`${API}/api/modules`);
|
list.forEach(m => moduleNames.set(m.name, m.id));
|
||||||
if (!res.ok) return;
|
const dl = document.getElementById(datalistId);
|
||||||
const data = await res.json();
|
if (!dl) return;
|
||||||
editModules = data.modules || [];
|
dl.innerHTML = '';
|
||||||
modulesLoaded = true;
|
list.forEach(m => {
|
||||||
const filt = document.getElementById('moduleFilter');
|
const opt = document.createElement('option');
|
||||||
if (filt) {
|
opt.value = m.name;
|
||||||
while (filt.options.length > 1) filt.remove(1);
|
dl.appendChild(opt);
|
||||||
editModules.forEach(m => {
|
});
|
||||||
const o = document.createElement('option');
|
|
||||||
o.value = m.id; o.textContent = m.name;
|
|
||||||
filt.appendChild(o);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function fillEditModuleSelect(selected) {
|
async function fetchModules(q, limit) {
|
||||||
const sel = document.getElementById('editModule');
|
const p = new URLSearchParams();
|
||||||
if (!sel) return;
|
if (q) p.set('search', q);
|
||||||
sel.innerHTML = '<option value="">— не выбрано —</option>' +
|
p.set('limit', String(limit));
|
||||||
editModules.map(m => `<option value="${m.id}">${esc(m.name)}</option>`).join('');
|
const res = await fetch(`${API}/api/modules?${p}`, { headers: hdr() });
|
||||||
sel.value = selected ? String(selected) : '';
|
if (!res.ok) return [];
|
||||||
|
const data = await res.json().catch(() => ({}));
|
||||||
|
return data.modules || [];
|
||||||
|
}
|
||||||
|
|
||||||
|
async function resolveModuleId(name) {
|
||||||
|
const n = (name || '').trim();
|
||||||
|
if (!n) return '';
|
||||||
|
if (moduleNames.has(n)) return String(moduleNames.get(n));
|
||||||
|
const list = await fetchModules(n, 10);
|
||||||
|
const lower = n.toLowerCase();
|
||||||
|
const exact = list.find(m => m.name.toLowerCase() === lower);
|
||||||
|
return exact ? String(exact.id) : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadModuleOptions() {
|
||||||
|
editModules = await fetchModules('', 50);
|
||||||
|
modulesLoaded = true;
|
||||||
|
renderModuleOptions('moduleFilterList', editModules);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function applyModuleFilter() {
|
||||||
|
const inp = document.getElementById('moduleFilter');
|
||||||
|
const resolved = await resolveModuleId(inp.value);
|
||||||
|
if (resolved === null) {
|
||||||
|
inp.value = '';
|
||||||
|
moduleFilterId = '';
|
||||||
|
showToast('Модуль не найден — фильтр сброшен');
|
||||||
|
} else {
|
||||||
|
moduleFilterId = resolved;
|
||||||
|
}
|
||||||
|
page = 1;
|
||||||
|
loadEntries();
|
||||||
|
}
|
||||||
|
|
||||||
|
function fillEditModuleInput(entry) {
|
||||||
|
const inp = document.getElementById('editModule');
|
||||||
|
if (!inp) return;
|
||||||
|
if (entry.module_name && entry.module_id) moduleNames.set(entry.module_name, entry.module_id);
|
||||||
|
inp.value = entry.module_name || '';
|
||||||
|
renderModuleOptions('editModuleList', editModules);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function loadStudentOptions() {
|
async function loadStudentOptions() {
|
||||||
@@ -265,8 +304,7 @@ async function loadEntries() {
|
|||||||
if (gid) p.set('group_id', gid);
|
if (gid) p.set('group_id', gid);
|
||||||
if (df) p.set('date_from', df);
|
if (df) p.set('date_from', df);
|
||||||
if (dt) p.set('date_to', dt);
|
if (dt) p.set('date_to', dt);
|
||||||
const mid = document.getElementById('moduleFilter') ? document.getElementById('moduleFilter').value : '';
|
if (moduleFilterId) p.set('module_id', moduleFilterId);
|
||||||
if (mid) p.set('module_id', mid);
|
|
||||||
p.set('limit', PAGE_SIZE);
|
p.set('limit', PAGE_SIZE);
|
||||||
p.set('offset', (page - 1) * PAGE_SIZE);
|
p.set('offset', (page - 1) * PAGE_SIZE);
|
||||||
const res = await fetch(`${API}/api/entries?${p}`, { headers: hdr() });
|
const res = await fetch(`${API}/api/entries?${p}`, { headers: hdr() });
|
||||||
@@ -368,7 +406,7 @@ async function openEdit(id) {
|
|||||||
}
|
}
|
||||||
sel.value = e.group_id;
|
sel.value = e.group_id;
|
||||||
if (!modulesLoaded) await loadModuleOptions();
|
if (!modulesLoaded) await loadModuleOptions();
|
||||||
fillEditModuleSelect(e.module_id);
|
fillEditModuleInput(e);
|
||||||
renderEditPhotos(e.photos || [], e.photo_path);
|
renderEditPhotos(e.photos || [], e.photo_path);
|
||||||
document.getElementById('editModal').classList.add('open');
|
document.getElementById('editModal').classList.add('open');
|
||||||
}
|
}
|
||||||
@@ -1158,11 +1196,14 @@ async function saveEdit() {
|
|||||||
const description = document.getElementById('editDesc').value.trim();
|
const description = document.getElementById('editDesc').value.trim();
|
||||||
if (!name || !group_id || !description) { alert('Заполните все поля'); return; }
|
if (!name || !group_id || !description) { alert('Заполните все поля'); return; }
|
||||||
|
|
||||||
|
const moduleId = await resolveModuleId(document.getElementById('editModule').value);
|
||||||
|
if (moduleId === null) { alert('Тема модуля не найдена. Выберите из списка подсказок.'); return; }
|
||||||
|
|
||||||
const formData = new FormData();
|
const formData = new FormData();
|
||||||
formData.append('student_name', name);
|
formData.append('student_name', name);
|
||||||
formData.append('group_id', group_id);
|
formData.append('group_id', group_id);
|
||||||
formData.append('description', description);
|
formData.append('description', description);
|
||||||
formData.append('module_id', document.getElementById('editModule').value);
|
formData.append('module_id', moduleId);
|
||||||
|
|
||||||
const photoFiles = document.getElementById('editPhoto').files;
|
const photoFiles = document.getElementById('editPhoto').files;
|
||||||
for (const f of photoFiles) formData.append('photo', f);
|
for (const f of photoFiles) formData.append('photo', f);
|
||||||
@@ -1206,7 +1247,24 @@ async function delEntry(id) {
|
|||||||
|
|
||||||
document.getElementById('studentFilter').addEventListener('change', () => { page = 1; loadEntries(); });
|
document.getElementById('studentFilter').addEventListener('change', () => { page = 1; loadEntries(); });
|
||||||
document.getElementById('groupFilter').addEventListener('change', () => { page = 1; loadEntries(); });
|
document.getElementById('groupFilter').addEventListener('change', () => { page = 1; loadEntries(); });
|
||||||
document.getElementById('moduleFilter').addEventListener('change', () => { page = 1; loadEntries(); });
|
document.getElementById('moduleFilter').addEventListener('change', applyModuleFilter);
|
||||||
|
document.getElementById('moduleFilter').addEventListener('keydown', e => {
|
||||||
|
if (e.key === 'Enter') { e.preventDefault(); applyModuleFilter(); }
|
||||||
|
});
|
||||||
|
document.getElementById('moduleFilter').addEventListener('input', () => {
|
||||||
|
clearTimeout(moduleSearchTimer);
|
||||||
|
const q = document.getElementById('moduleFilter').value.trim();
|
||||||
|
moduleSearchTimer = setTimeout(async () => {
|
||||||
|
renderModuleOptions('moduleFilterList', await fetchModules(q, 20));
|
||||||
|
}, 250);
|
||||||
|
});
|
||||||
|
document.getElementById('editModule').addEventListener('input', () => {
|
||||||
|
clearTimeout(editModuleSearchTimer);
|
||||||
|
const q = document.getElementById('editModule').value.trim();
|
||||||
|
editModuleSearchTimer = setTimeout(async () => {
|
||||||
|
renderModuleOptions('editModuleList', await fetchModules(q, 20));
|
||||||
|
}, 250);
|
||||||
|
});
|
||||||
document.getElementById('dateFrom').addEventListener('change', () => { page = 1; loadEntries(); });
|
document.getElementById('dateFrom').addEventListener('change', () => { page = 1; loadEntries(); });
|
||||||
document.getElementById('dateTo').addEventListener('change', () => { page = 1; loadEntries(); });
|
document.getElementById('dateTo').addEventListener('change', () => { page = 1; loadEntries(); });
|
||||||
document.getElementById('searchInput').addEventListener('input', () => {
|
document.getElementById('searchInput').addEventListener('input', () => {
|
||||||
@@ -1261,8 +1319,7 @@ async function exportCSV() {
|
|||||||
if (gid) p.set('group_id', gid);
|
if (gid) p.set('group_id', gid);
|
||||||
if (df) p.set('date_from', df);
|
if (df) p.set('date_from', df);
|
||||||
if (dt) p.set('date_to', dt);
|
if (dt) p.set('date_to', dt);
|
||||||
const mid = document.getElementById('moduleFilter') ? document.getElementById('moduleFilter').value : '';
|
if (moduleFilterId) p.set('module_id', moduleFilterId);
|
||||||
if (mid) p.set('module_id', mid);
|
|
||||||
const res = await fetch(`${API}/api/entries?${p}`, { headers: hdr() });
|
const res = await fetch(`${API}/api/entries?${p}`, { headers: hdr() });
|
||||||
const entries = (await res.json()).entries;
|
const entries = (await res.json()).entries;
|
||||||
if (!entries.length) { alert('Нет данных для экспорта'); return; }
|
if (!entries.length) { alert('Нет данных для экспорта'); return; }
|
||||||
|
|||||||
@@ -114,6 +114,83 @@ document.getElementById('moduleForm').addEventListener('submit', async (e) => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
function parseBatchModules(raw) {
|
||||||
|
const items = [];
|
||||||
|
const errors = [];
|
||||||
|
const seen = new Set();
|
||||||
|
String(raw).split('\n').forEach((line, idx) => {
|
||||||
|
const text = line.trim();
|
||||||
|
if (!text) return;
|
||||||
|
const lineNo = idx + 1;
|
||||||
|
let name = '';
|
||||||
|
let lessonsRaw = '';
|
||||||
|
const sepIndex = text.search(/[|;=\t]/);
|
||||||
|
if (sepIndex !== -1) {
|
||||||
|
name = text.slice(0, sepIndex).trim();
|
||||||
|
lessonsRaw = text.slice(sepIndex + 1).trim();
|
||||||
|
} else {
|
||||||
|
const numMatch = text.match(/^(.*\S)\s+(\d+)$/);
|
||||||
|
if (numMatch) {
|
||||||
|
name = numMatch[1].trim();
|
||||||
|
lessonsRaw = numMatch[2];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!name && !lessonsRaw) { errors.push(`Строка ${lineNo}: «${text}» — укажите название и количество занятий, например: Основы Python | 10`); return; }
|
||||||
|
if (!name) { errors.push(`Строка ${lineNo}: «${text}» — не указано название`); return; }
|
||||||
|
if (!lessonsRaw) { errors.push(`Строка ${lineNo}: «${text}» — не указано количество занятий`); return; }
|
||||||
|
const lessons = Number(lessonsRaw);
|
||||||
|
if (!Number.isInteger(lessons) || lessons < 0 || lessons > 10000) {
|
||||||
|
errors.push(`Строка ${lineNo}: «${text}» — количество занятий должно быть целым числом от 0 до 10000`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const key = name.toLowerCase();
|
||||||
|
if (seen.has(key)) { errors.push(`Строка ${lineNo}: «${text}» — дубликат названия в списке`); return; }
|
||||||
|
seen.add(key);
|
||||||
|
items.push({ name, lessons_count: lessons });
|
||||||
|
});
|
||||||
|
return { items, errors };
|
||||||
|
}
|
||||||
|
|
||||||
|
function openBatchModuleModal() {
|
||||||
|
document.getElementById('batchModuleForm').reset();
|
||||||
|
document.getElementById('batchModuleModal').classList.add('open');
|
||||||
|
setTimeout(() => document.getElementById('batchModuleText').focus(), 100);
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeBatchModuleModal() {
|
||||||
|
document.getElementById('batchModuleModal').classList.remove('open');
|
||||||
|
}
|
||||||
|
|
||||||
|
async function batchAddModules() {
|
||||||
|
const submitBtn = document.getElementById('batchModuleSubmitBtn');
|
||||||
|
const { items, errors } = parseBatchModules(document.getElementById('batchModuleText').value);
|
||||||
|
if (errors.length) {
|
||||||
|
alert('Исправьте строки:\n\n' + errors.join('\n'));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!items.length) { alert('Добавьте хотя бы один модуль'); return; }
|
||||||
|
submitBtn.disabled = true;
|
||||||
|
let added = 0, dupes = 0, failed = 0;
|
||||||
|
for (const item of items) {
|
||||||
|
const res = await fetch(`${API}/api/modules`, { method: 'POST', headers: hdrJson(), body: JSON.stringify(item) });
|
||||||
|
if (res.ok) added++;
|
||||||
|
else if (res.status === 409) dupes++;
|
||||||
|
else failed++;
|
||||||
|
}
|
||||||
|
submitBtn.disabled = false;
|
||||||
|
closeBatchModuleModal();
|
||||||
|
loadModules();
|
||||||
|
showToast(`Добавлено: ${added}${dupes ? `, дубликатов: ${dupes}` : ''}${failed ? `, ошибок: ${failed}` : ''}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
document.getElementById('batchModuleForm').addEventListener('submit', e => {
|
||||||
|
e.preventDefault();
|
||||||
|
batchAddModules();
|
||||||
|
});
|
||||||
|
document.getElementById('batchModuleBtn').addEventListener('click', openBatchModuleModal);
|
||||||
|
document.getElementById('batchModuleCancelBtn').addEventListener('click', closeBatchModuleModal);
|
||||||
|
document.getElementById('batchModuleModal').addEventListener('click', e => { if (e.target === e.currentTarget) closeBatchModuleModal(); });
|
||||||
|
|
||||||
document.getElementById('addModuleBtn').addEventListener('click', () => openModuleModal());
|
document.getElementById('addModuleBtn').addEventListener('click', () => openModuleModal());
|
||||||
document.getElementById('moduleCancelBtn').addEventListener('click', closeModuleModal);
|
document.getElementById('moduleCancelBtn').addEventListener('click', closeModuleModal);
|
||||||
document.getElementById('moduleModal').addEventListener('click', e => { if (e.target === e.currentTarget) closeModuleModal(); });
|
document.getElementById('moduleModal').addEventListener('click', e => { if (e.target === e.currentTarget) closeModuleModal(); });
|
||||||
|
|||||||
@@ -19,6 +19,9 @@
|
|||||||
<button class="btn-primary" id="addModuleBtn" type="button">
|
<button class="btn-primary" id="addModuleBtn" type="button">
|
||||||
<span style="font-size:1.2rem">+</span> Добавить модуль
|
<span style="font-size:1.2rem">+</span> Добавить модуль
|
||||||
</button>
|
</button>
|
||||||
|
<button class="btn-primary ghost" id="batchModuleBtn" type="button">
|
||||||
|
Пакетное добавление
|
||||||
|
</button>
|
||||||
<input type="text" id="moduleSearch" class="search-input" placeholder="Поиск по названию..." style="flex:1;min-width:200px;max-width:340px;padding:10px 14px;font-size:.9rem">
|
<input type="text" id="moduleSearch" class="search-input" placeholder="Поиск по названию..." style="flex:1;min-width:200px;max-width:340px;padding:10px 14px;font-size:.9rem">
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -48,6 +51,24 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="modal-overlay" id="batchModuleModal">
|
||||||
|
<div class="edit-modal" style="max-width:520px">
|
||||||
|
<h3>Пакетное добавление модулей</h3>
|
||||||
|
<form id="batchModuleForm" class="settings-stack" style="gap:12px">
|
||||||
|
<div class="settings-field">
|
||||||
|
<label>Модули — каждый с новой строки <span style="color:#ef4444">*</span></label>
|
||||||
|
<textarea id="batchModuleText" class="settings-input" style="min-height:160px;font-family:monospace" required
|
||||||
|
placeholder="Основы Python | 10 Введение в базы данных | 8 Разработка интерфейсов 12"></textarea>
|
||||||
|
<span class="hint">Название может состоять из нескольких слов. Количество занятий отделяйте символом <b>|</b>, <b>;</b>, <b>=</b> или табуляцией; если не указать разделитель — последнее число в строке считается количеством занятий. Количество занятий обязательно для каждой строки.</span>
|
||||||
|
</div>
|
||||||
|
<div class="card-foot" style="justify-content:flex-end;gap:8px">
|
||||||
|
<button type="button" class="btn-primary ghost" id="batchModuleCancelBtn">Отмена</button>
|
||||||
|
<button type="submit" class="btn-primary" id="batchModuleSubmitBtn">Добавить</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="toast" id="toast"></div>
|
<div class="toast" id="toast"></div>
|
||||||
<script src="vendor/lucide.min.js"></script>
|
<script src="vendor/lucide.min.js"></script>
|
||||||
<script src="admin.js"></script>
|
<script src="admin.js"></script>
|
||||||
@@ -58,6 +79,8 @@
|
|||||||
.module-info{min-width:0;display:flex;flex-direction:column;gap:4px}
|
.module-info{min-width:0;display:flex;flex-direction:column;gap:4px}
|
||||||
.module-name{font-weight:600;font-size:.95rem;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
|
.module-name{font-weight:600;font-size:.95rem;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
|
||||||
.module-meta{font-size:.78rem;color:var(--muted)}
|
.module-meta{font-size:.78rem;color:var(--muted)}
|
||||||
|
#batchModuleModal .hint{font-size:.78rem;color:var(--muted);line-height:1.5;display:block}
|
||||||
|
#batchModuleModal .hint b{color:var(--text)}
|
||||||
@media (max-width:640px){.module-item{flex-direction:column;align-items:flex-start}}
|
@media (max-width:640px){.module-item{flex-direction:column;align-items:flex-start}}
|
||||||
</style>
|
</style>
|
||||||
</body>
|
</body>
|
||||||
|
|||||||
Reference in New Issue
Block a user