feat: динамические формы обучения + вкладка группы в админке

This commit is contained in:
Zuev
2026-02-15 03:15:43 +03:00
parent 08ed6ebe36
commit 6774cd673c
12 changed files with 782 additions and 85 deletions

View File

@@ -339,6 +339,86 @@ tbody tr:hover {
color: var(--success);
}
/* ===== Education Form Badge ===== */
.badge-ef {
background: rgba(99, 102, 241, 0.15);
color: var(--accent-hover);
}
/* ===== Card Header Row ===== */
.card-header-row {
display: flex;
justify-content: space-between;
align-items: center;
flex-wrap: wrap;
gap: 0.75rem;
margin-bottom: 1rem;
}
.card-header-row h2 {
margin-bottom: 0;
}
.filter-row {
display: flex;
align-items: center;
gap: 0.5rem;
}
.filter-row label {
font-size: 0.78rem;
font-weight: 500;
color: var(--text-secondary);
text-transform: uppercase;
letter-spacing: 0.04em;
white-space: nowrap;
}
.filter-row select {
padding: 0.45rem 2rem 0.45rem 0.7rem;
background: var(--bg-input);
border: 1px solid transparent;
border-radius: var(--radius-sm);
color: var(--text-primary);
font-family: inherit;
font-size: 0.85rem;
outline: none;
cursor: pointer;
appearance: none;
background-image: url("data:image/svg+xml,%3Csvg width='12' height='8' viewBox='0 0 12 8' fill='none' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath d='M1 1.5L6 6.5L11 1.5' stroke='%239ca3af' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'/%3E%3C/svg%3E");
background-repeat: no-repeat;
background-position: right 0.6rem center;
transition: background var(--transition), border-color var(--transition), box-shadow var(--transition);
}
.filter-row select:focus {
background-color: var(--bg-input-focus);
border-color: var(--accent);
box-shadow: 0 0 0 3px var(--accent-glow);
}
.filter-row select option {
background: #1a1a2e;
color: var(--text-primary);
}
/* ===== Tab Content ===== */
.tab-content {
animation: fadeIn 0.2s ease;
}
@keyframes fadeIn {
from {
opacity: 0;
transform: translateY(4px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
/* ===== Delete Button ===== */
.btn-delete {
padding: 0.35rem 0.7rem;

View File

@@ -9,36 +9,103 @@
return;
}
const tbody = document.getElementById('users-tbody');
const createForm = document.getElementById('create-form');
const createAlert = document.getElementById('create-alert');
// ---- DOM refs ----
const pageTitle = document.getElementById('page-title');
const btnLogout = document.getElementById('btn-logout');
const menuToggle = document.getElementById('menu-toggle');
const sidebar = document.querySelector('.sidebar');
const sidebarOverlay = document.getElementById('sidebar-overlay');
// ---- Mobile Menu ----
function toggleSidebar() {
sidebar.classList.toggle('open');
sidebarOverlay.classList.toggle('open');
// Users
const usersTbody = document.getElementById('users-tbody');
const createForm = document.getElementById('create-form');
const createAlert = document.getElementById('create-alert');
// Groups
const groupsTbody = document.getElementById('groups-tbody');
const createGroupForm = document.getElementById('create-group-form');
const createGroupAlert = document.getElementById('create-group-alert');
const newGroupEfSelect = document.getElementById('new-group-ef');
const filterEfSelect = document.getElementById('filter-ef');
// Education Forms
const efTbody = document.getElementById('ef-tbody');
const createEfForm = document.getElementById('create-ef-form');
const createEfAlert = document.getElementById('create-ef-alert');
const navItems = document.querySelectorAll('.nav-item[data-tab]');
const tabContents = document.querySelectorAll('.tab-content');
// ---- State ----
let allGroups = [];
let allEducationForms = [];
// ---- Tab Switching ----
const TAB_TITLES = {
users: 'Управление пользователями',
groups: 'Управление группами',
'edu-forms': 'Формы обучения',
};
navItems.forEach(item => {
item.addEventListener('click', (e) => {
e.preventDefault();
switchTab(item.dataset.tab);
});
});
function switchTab(tab) {
navItems.forEach(n => n.classList.remove('active'));
document.querySelector(`.nav-item[data-tab="${tab}"]`)?.classList.add('active');
tabContents.forEach(tc => tc.style.display = 'none');
const target = document.getElementById('tab-' + tab);
if (target) target.style.display = '';
pageTitle.textContent = TAB_TITLES[tab] || '';
if (tab === 'users') loadUsers();
if (tab === 'groups') { loadEducationForms().then(() => loadGroups()); }
if (tab === 'edu-forms') loadEducationForms();
sidebar.classList.remove('open');
sidebarOverlay.classList.remove('open');
}
menuToggle.addEventListener('click', toggleSidebar);
sidebarOverlay.addEventListener('click', toggleSidebar);
// ---- Mobile Menu ----
menuToggle.addEventListener('click', () => {
sidebar.classList.toggle('open');
sidebarOverlay.classList.toggle('open');
});
sidebarOverlay.addEventListener('click', () => {
sidebar.classList.remove('open');
sidebarOverlay.classList.remove('open');
});
const ROLE_LABELS = {
ADMIN: 'Администратор',
TEACHER: 'Преподаватель',
STUDENT: 'Студент',
};
// ---- Helpers ----
const ROLE_LABELS = { ADMIN: 'Администратор', TEACHER: 'Преподаватель', STUDENT: 'Студент' };
const ROLE_BADGE = { ADMIN: 'badge-admin', TEACHER: 'badge-teacher', STUDENT: 'badge-student' };
const ROLE_BADGE = {
ADMIN: 'badge-admin',
TEACHER: 'badge-teacher',
STUDENT: 'badge-student',
};
function escapeHtml(str) {
const div = document.createElement('div');
div.textContent = str;
return div.innerHTML;
}
function showAlert(el, msg, type) {
el.className = 'form-alert ' + type;
el.textContent = msg;
}
function hideAlert(el) {
el.className = 'form-alert';
el.textContent = '';
}
// ============================================================
// USERS
// ============================================================
// ---- Load Users ----
async function loadUsers() {
try {
const res = await fetch('/api/users', {
@@ -47,112 +114,241 @@
const users = await res.json();
renderUsers(users);
} catch (e) {
tbody.innerHTML = '<tr><td colspan="4" class="loading-row">Ошибка загрузки</td></tr>';
usersTbody.innerHTML = '<tr><td colspan="4" class="loading-row">Ошибка загрузки</td></tr>';
}
}
function renderUsers(users) {
if (!users.length) {
tbody.innerHTML = '<tr><td colspan="4" class="loading-row">Нет пользователей</td></tr>';
usersTbody.innerHTML = '<tr><td colspan="4" class="loading-row">Нет пользователей</td></tr>';
return;
}
tbody.innerHTML = users.map(u => `
usersTbody.innerHTML = users.map(u => `
<tr>
<td>${u.id}</td>
<td>${escapeHtml(u.username)}</td>
<td><span class="badge ${ROLE_BADGE[u.role] || ''}">${ROLE_LABELS[u.role] || u.role}</span></td>
<td><button class="btn-delete" data-id="${u.id}">Удалить</button></td>
</tr>
`).join('');
</tr>`).join('');
}
function escapeHtml(str) {
const div = document.createElement('div');
div.textContent = str;
return div.innerHTML;
}
// ---- Create User ----
createForm.addEventListener('submit', async (e) => {
e.preventDefault();
hideAlert();
hideAlert(createAlert);
const username = document.getElementById('new-username').value.trim();
const password = document.getElementById('new-password').value;
const role = document.getElementById('new-role').value;
if (!username || !password) {
showAlert('Заполните все поля', 'error');
return;
}
if (!username || !password) { showAlert(createAlert, 'Заполните все поля', 'error'); return; }
try {
const res = await fetch('/api/users', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': 'Bearer ' + token,
},
headers: { 'Content-Type': 'application/json', 'Authorization': 'Bearer ' + token },
body: JSON.stringify({ username, password, role }),
});
const data = await res.json();
if (res.ok) {
showAlert(`Пользователь "${data.username}" создан`, 'success');
showAlert(createAlert, `Пользователь "${data.username}" создан`, 'success');
createForm.reset();
loadUsers();
} else {
showAlert(data.message || 'Ошибка создания', 'error');
showAlert(createAlert, data.message || 'Ошибка создания', 'error');
}
} catch (e) {
showAlert('Ошибка соединения', 'error');
}
} catch (e) { showAlert(createAlert, 'Ошибка соединения', 'error'); }
});
// ---- Delete User ----
tbody.addEventListener('click', async (e) => {
usersTbody.addEventListener('click', async (e) => {
const btn = e.target.closest('.btn-delete');
if (!btn) return;
const id = btn.dataset.id;
if (!confirm('Удалить пользователя?')) return;
try {
const res = await fetch('/api/users/' + id, {
const res = await fetch('/api/users/' + btn.dataset.id, {
method: 'DELETE',
headers: { 'Authorization': 'Bearer ' + token },
});
if (res.ok) {
loadUsers();
} else {
alert('Ошибка удаления');
}
} catch (e) {
alert('Ошибка соединения');
}
if (res.ok) loadUsers();
else alert('Ошибка удаления');
} catch (e) { alert('Ошибка соединения'); }
});
// ---- Logout ----
// ============================================================
// EDUCATION FORMS
// ============================================================
async function loadEducationForms() {
try {
const res = await fetch('/api/education-forms', {
headers: { 'Authorization': 'Bearer ' + token },
});
allEducationForms = await res.json();
renderEfTable(allEducationForms);
populateEfSelects(allEducationForms);
} catch (e) {
efTbody.innerHTML = '<tr><td colspan="3" class="loading-row">Ошибка загрузки</td></tr>';
}
}
function renderEfTable(forms) {
if (!forms.length) {
efTbody.innerHTML = '<tr><td colspan="3" class="loading-row">Нет форм обучения</td></tr>';
return;
}
efTbody.innerHTML = forms.map(ef => `
<tr>
<td>${ef.id}</td>
<td>${escapeHtml(ef.name)}</td>
<td><button class="btn-delete" data-id="${ef.id}">Удалить</button></td>
</tr>`).join('');
}
function populateEfSelects(forms) {
// Group creation select
const currentVal = newGroupEfSelect.value;
newGroupEfSelect.innerHTML = forms.map(ef =>
`<option value="${ef.id}">${escapeHtml(ef.name)}</option>`
).join('');
if (currentVal && forms.find(f => f.id == currentVal)) {
newGroupEfSelect.value = currentVal;
}
// Filter select
const currentFilter = filterEfSelect.value;
filterEfSelect.innerHTML = '<option value="">Все формы</option>' +
forms.map(ef =>
`<option value="${ef.id}">${escapeHtml(ef.name)}</option>`
).join('');
if (currentFilter) filterEfSelect.value = currentFilter;
}
createEfForm.addEventListener('submit', async (e) => {
e.preventDefault();
hideAlert(createEfAlert);
const name = document.getElementById('new-ef-name').value.trim();
if (!name) { showAlert(createEfAlert, 'Введите название', 'error'); return; }
try {
const res = await fetch('/api/education-forms', {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'Authorization': 'Bearer ' + token },
body: JSON.stringify({ name }),
});
const data = await res.json();
if (res.ok) {
showAlert(createEfAlert, `Форма "${data.name}" создана`, 'success');
createEfForm.reset();
loadEducationForms();
} else {
showAlert(createEfAlert, data.message || 'Ошибка создания', 'error');
}
} catch (e) { showAlert(createEfAlert, 'Ошибка соединения', 'error'); }
});
efTbody.addEventListener('click', async (e) => {
const btn = e.target.closest('.btn-delete');
if (!btn) return;
if (!confirm('Удалить форму обучения?')) return;
try {
const res = await fetch('/api/education-forms/' + btn.dataset.id, {
method: 'DELETE',
headers: { 'Authorization': 'Bearer ' + token },
});
if (res.ok) {
loadEducationForms();
} else {
const data = await res.json();
alert(data.message || 'Ошибка удаления');
}
} catch (e) { alert('Ошибка соединения'); }
});
// ============================================================
// GROUPS
// ============================================================
async function loadGroups() {
try {
const res = await fetch('/api/groups', {
headers: { 'Authorization': 'Bearer ' + token },
});
allGroups = await res.json();
applyGroupFilter();
} catch (e) {
groupsTbody.innerHTML = '<tr><td colspan="4" class="loading-row">Ошибка загрузки</td></tr>';
}
}
function applyGroupFilter() {
const filterId = filterEfSelect.value;
const filtered = filterId
? allGroups.filter(g => g.educationFormId == filterId)
: allGroups;
renderGroups(filtered);
}
filterEfSelect.addEventListener('change', applyGroupFilter);
function renderGroups(groups) {
if (!groups.length) {
groupsTbody.innerHTML = '<tr><td colspan="4" class="loading-row">Нет групп</td></tr>';
return;
}
groupsTbody.innerHTML = groups.map(g => `
<tr>
<td>${g.id}</td>
<td>${escapeHtml(g.name)}</td>
<td><span class="badge badge-ef">${escapeHtml(g.educationFormName)}</span></td>
<td><button class="btn-delete" data-id="${g.id}">Удалить</button></td>
</tr>`).join('');
}
createGroupForm.addEventListener('submit', async (e) => {
e.preventDefault();
hideAlert(createGroupAlert);
const name = document.getElementById('new-group-name').value.trim();
const educationFormId = newGroupEfSelect.value;
if (!name) { showAlert(createGroupAlert, 'Введите название группы', 'error'); return; }
if (!educationFormId) { showAlert(createGroupAlert, 'Выберите форму обучения', 'error'); return; }
try {
const res = await fetch('/api/groups', {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'Authorization': 'Bearer ' + token },
body: JSON.stringify({ name, educationFormId: Number(educationFormId) }),
});
const data = await res.json();
if (res.ok) {
showAlert(createGroupAlert, `Группа "${data.name}" создана`, 'success');
createGroupForm.reset();
loadGroups();
} else {
showAlert(createGroupAlert, data.message || 'Ошибка создания', 'error');
}
} catch (e) { showAlert(createGroupAlert, 'Ошибка соединения', 'error'); }
});
groupsTbody.addEventListener('click', async (e) => {
const btn = e.target.closest('.btn-delete');
if (!btn) return;
if (!confirm('Удалить группу?')) return;
try {
const res = await fetch('/api/groups/' + btn.dataset.id, {
method: 'DELETE',
headers: { 'Authorization': 'Bearer ' + token },
});
if (res.ok) loadGroups();
else alert('Ошибка удаления');
} catch (e) { alert('Ошибка соединения'); }
});
// ============================================================
// LOGOUT & INIT
// ============================================================
btnLogout.addEventListener('click', () => {
localStorage.removeItem('token');
localStorage.removeItem('role');
window.location.href = '/';
});
// ---- Helpers ----
function showAlert(msg, type) {
createAlert.className = 'form-alert ' + type;
createAlert.textContent = msg;
}
function hideAlert() {
createAlert.className = 'form-alert';
createAlert.textContent = '';
}
// Init
loadUsers();
})();

View File

@@ -31,7 +31,7 @@
</div>
</div>
<nav class="sidebar-nav">
<a href="/admin/" class="nav-item active">
<a href="#" class="nav-item active" data-tab="users">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"
stroke-linecap="round" stroke-linejoin="round">
<path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2" />
@@ -41,6 +41,24 @@
</svg>
Пользователи
</a>
<a href="#" class="nav-item" data-tab="groups">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"
stroke-linecap="round" stroke-linejoin="round">
<path d="M2 3h6a4 4 0 0 1 4 4v14a3 3 0 0 0-3-3H2z" />
<path d="M22 3h-6a4 4 0 0 0-4 4v14a3 3 0 0 1 3-3h7z" />
</svg>
Группы
</a>
<a href="#" class="nav-item" data-tab="edu-forms">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"
stroke-linecap="round" stroke-linejoin="round">
<path d="M4 19.5A2.5 2.5 0 0 1 6.5 17H20" />
<path d="M6.5 2H20v20H6.5A2.5 2.5 0 0 1 4 19.5v-15A2.5 2.5 0 0 1 6.5 2z" />
<line x1="9" y1="7" x2="17" y2="7" />
<line x1="9" y1="11" x2="15" y2="11" />
</svg>
Формы обучения
</a>
</nav>
<div class="sidebar-footer">
<button class="btn-logout" id="btn-logout">
@@ -69,11 +87,11 @@
<line x1="3" y1="18" x2="21" y2="18" />
</svg>
</button>
<h1>Управление пользователями</h1>
<h1 id="page-title">Управление пользователями</h1>
</header>
<section class="content">
<!-- Create User Card -->
<!-- ===== Users Tab ===== -->
<section class="content tab-content" id="tab-users">
<div class="card create-card">
<h2>Новый пользователь</h2>
<form id="create-form">
@@ -99,8 +117,6 @@
<div class="form-alert" id="create-alert" role="alert"></div>
</form>
</div>
<!-- Users Table -->
<div class="card">
<h2>Все пользователи</h2>
<div class="table-wrap">
@@ -122,6 +138,93 @@
</div>
</div>
</section>
<!-- ===== Groups Tab ===== -->
<section class="content tab-content" id="tab-groups" style="display:none;">
<div class="card create-card">
<h2>Новая группа</h2>
<form id="create-group-form">
<div class="form-row">
<div class="form-group">
<label for="new-group-name">Название группы</label>
<input type="text" id="new-group-name" placeholder="ИВТ-21-1" required>
</div>
<div class="form-group">
<label for="new-group-ef">Форма обучения</label>
<select id="new-group-ef">
<option value="">Загрузка...</option>
</select>
</div>
<button type="submit" class="btn-create">Создать</button>
</div>
<div class="form-alert" id="create-group-alert" role="alert"></div>
</form>
</div>
<div class="card">
<div class="card-header-row">
<h2>Все группы</h2>
<div class="filter-row">
<label for="filter-ef">Фильтр:</label>
<select id="filter-ef">
<option value="">Все формы</option>
</select>
</div>
</div>
<div class="table-wrap">
<table id="groups-table">
<thead>
<tr>
<th>ID</th>
<th>Название</th>
<th>Форма обучения</th>
<th></th>
</tr>
</thead>
<tbody id="groups-tbody">
<tr>
<td colspan="4" class="loading-row">Загрузка...</td>
</tr>
</tbody>
</table>
</div>
</div>
</section>
<!-- ===== Education Forms Tab ===== -->
<section class="content tab-content" id="tab-edu-forms" style="display:none;">
<div class="card create-card">
<h2>Новая форма обучения</h2>
<form id="create-ef-form">
<div class="form-row">
<div class="form-group">
<label for="new-ef-name">Название</label>
<input type="text" id="new-ef-name" placeholder="Бакалавриат" required>
</div>
<button type="submit" class="btn-create">Создать</button>
</div>
<div class="form-alert" id="create-ef-alert" role="alert"></div>
</form>
</div>
<div class="card">
<h2>Все формы обучения</h2>
<div class="table-wrap">
<table id="ef-table">
<thead>
<tr>
<th>ID</th>
<th>Название</th>
<th></th>
</tr>
</thead>
<tbody id="ef-tbody">
<tr>
<td colspan="3" class="loading-row">Загрузка...</td>
</tr>
</tbody>
</table>
</div>
</div>
</section>
</main>
<script src="admin.js"></script>