Добавил вкладку заявок

This commit is contained in:
dipatrik10
2026-06-02 16:07:30 +03:00
parent 6d9f6b7a42
commit 08b8607f05
11 changed files with 456 additions and 157 deletions

View File

@@ -3,7 +3,7 @@ if (!['localhost', '127.0.0.1'].includes(window.location.hostname)) {
import('./otel.js').catch(e => console.warn('OTel init skipped:', e.message));
}
import { isAuthenticatedAsAny, logout } from './api.js';
import { api, isAuthenticatedAsAny, logout } from './api.js';
import { applyRippleEffect, closeAllDropdownsOnOutsideClick } from './utils.js';
import { startDropdownAutoObserver, initAllCustomDropdowns } from './dropdown.js';
@@ -38,6 +38,14 @@ const ROUTES = {
await module.initUsers();
}
},
'teacher-requests': {
title: 'Заявки',
file: '/admin/views/teacher-requests.html',
init: async () => {
const module = await import('./views/teacher-requests.js');
await module.initTeacherRequests();
}
},
groups: {
title: 'Управление группами',
file: '/admin/views/groups.html',
@@ -115,7 +123,7 @@ const ROUTES = {
const ROLE_NAVIGATION = {
ADMIN: {
defaultTab: 'dashboard',
tabs: ['dashboard', 'schedule', 'academic-calendar', 'auditorium-workload', 'schedule-view', 'classrooms', 'groups', 'university-structure', 'subjects', 'users']
tabs: ['dashboard', 'teacher-requests', 'schedule', 'academic-calendar', 'auditorium-workload', 'schedule-view', 'classrooms', 'groups', 'university-structure', 'subjects', 'users']
},
EDUCATION_OFFICE: {
defaultTab: 'dashboard',
@@ -143,6 +151,8 @@ const menuToggle = document.getElementById('menu-toggle');
const sidebarCloseBtn = document.getElementById('sidebar-close-btn');
const btnLogout = document.getElementById('btn-logout');
const main = document.querySelector('.main');
const teacherRequestsNavBadge = document.getElementById('teacher-requests-nav-badge');
const teacherRequestsTitleBadge = document.getElementById('teacher-requests-title-badge');
const roleNavigation = ROLE_NAVIGATION[currentRole] || ROLE_NAVIGATION.SCHEDULE_VIEWER;
const allowedTabs = new Set(roleNavigation.tabs);
@@ -150,6 +160,7 @@ const allowedTabs = new Set(roleNavigation.tabs);
applyRippleEffect();
closeAllDropdownsOnOutsideClick();
configureRoleInterface();
refreshTeacherRequestBadge();
// Init sidebar state from localStorage on load
if (window.innerWidth > 768 && localStorage.getItem('sidebar-collapsed') === 'true') {
@@ -207,6 +218,10 @@ btnLogout.addEventListener('click', async () => {
window.location.href = '/';
});
teacherRequestsTitleBadge?.addEventListener('click', () => {
switchTab('teacher-requests');
});
// Navigation
navItems.forEach(item => {
item.addEventListener('click', (e) => {
@@ -271,6 +286,42 @@ function configureRoleInterface() {
document.querySelector('.logo span').textContent = firstAllowedTitle;
}
async function refreshTeacherRequestBadge(countOverride = null) {
if (currentRole !== 'ADMIN') return;
try {
const count = countOverride === null
? (await api.get('/api/teacher-requests?status=PENDING') || []).length
: countOverride;
updateTeacherRequestBadge(teacherRequestsNavBadge, count, count > 99 ? '99+' : String(count));
updateTeacherRequestBadge(teacherRequestsTitleBadge, count, requestCountLabel(count));
} catch (error) {
if (teacherRequestsNavBadge) teacherRequestsNavBadge.hidden = true;
if (teacherRequestsTitleBadge) teacherRequestsTitleBadge.hidden = true;
console.warn('Не удалось загрузить счётчик заявок:', error.message);
}
}
function updateTeacherRequestBadge(element, count, label) {
if (!element) return;
const normalizedCount = Number(count) || 0;
element.textContent = label;
element.hidden = normalizedCount <= 0;
element.setAttribute('aria-hidden', normalizedCount <= 0 ? 'true' : 'false');
element.setAttribute('aria-label', normalizedCount > 0 ? `Заявок в очереди: ${normalizedCount}` : 'Заявок в очереди нет');
}
function requestCountLabel(count) {
const mod10 = count % 10;
const mod100 = count % 100;
if (mod10 === 1 && mod100 !== 11) return `${count} заявка`;
if (mod10 >= 2 && mod10 <= 4 && (mod100 < 12 || mod100 > 14)) return `${count} заявки`;
return `${count} заявок`;
}
document.addEventListener('teacherRequestsChanged', (event) => {
refreshTeacherRequestBadge(Number.isFinite(event.detail?.count) ? event.detail.count : null);
});
const hashTab = window.location.hash.replace('#', '');
switchTab(allowedTabs.has(hashTab) ? hashTab : roleNavigation.defaultTab);

View File

@@ -0,0 +1,136 @@
import { api } from '../api.js';
import { escapeHtml, hideAlert, showAlert } from '../utils.js';
export async function initTeacherRequests() {
const requestsTbody = document.getElementById('teacher-requests-tbody');
const refreshButton = document.getElementById('teacher-requests-refresh');
const requestsCount = document.getElementById('teacher-requests-count');
let departments = [];
async function loadDepartments() {
departments = await api.get('/api/departments');
}
async function loadRequests() {
requestsTbody.innerHTML = '<tr><td colspan="7" class="loading-row">Загрузка...</td></tr>';
hideAlert('teacher-requests-alert');
try {
const requests = await api.get('/api/teacher-requests?status=PENDING');
const normalizedRequests = Array.isArray(requests) ? requests : [];
renderRequests(normalizedRequests);
updateCount(normalizedRequests.length);
document.dispatchEvent(new CustomEvent('teacherRequestsChanged', {
detail: { count: normalizedRequests.length }
}));
} catch (error) {
requestsTbody.innerHTML = `<tr><td colspan="7" class="loading-row">${escapeHtml(error.message || 'Ошибка загрузки заявок')}</td></tr>`;
updateCount(0);
}
}
function renderRequests(requests) {
if (!requests.length) {
requestsTbody.innerHTML = '<tr><td colspan="7" class="loading-row">Нет заявок на рассмотрении</td></tr>';
return;
}
requestsTbody.innerHTML = requests.map(request => `
<tr data-request-id="${escapeHtml(request.id)}">
<td>
<select class="teacher-request-department" data-native-select="true">
${departments.map(department => `
<option value="${escapeHtml(department.id)}" ${String(department.id) === String(request.departmentId) ? 'selected' : ''}>
${escapeHtml(department.departmentName || department.name || department.id)}
</option>
`).join('')}
</select>
</td>
<td><input type="text" class="teacher-request-username" value="${escapeHtml(request.username || '')}" maxlength="50"></td>
<td><input type="text" class="teacher-request-fullname" value="${escapeHtml(request.fullName || '')}" maxlength="255"></td>
<td><input type="text" class="teacher-request-jobtitle" value="${escapeHtml(request.jobTitle || '')}" maxlength="255"></td>
<td><input type="text" class="teacher-request-password" placeholder="Минимум 8 символов"></td>
<td>${escapeHtml(request.comment || '-')}</td>
<td>
<div class="teacher-request-actions">
<button type="button" class="btn btn-sm btn-primary teacher-request-approve">Одобрить</button>
<button type="button" class="btn btn-sm btn-danger-subtle teacher-request-reject">Отклонить</button>
</div>
</td>
</tr>
`).join('');
}
function updateCount(count) {
if (!requestsCount) return;
requestsCount.textContent = requestCountLabel(count);
}
function requestCountLabel(count) {
const mod10 = count % 10;
const mod100 = count % 100;
if (mod10 === 1 && mod100 !== 11) return `${count} заявка`;
if (mod10 >= 2 && mod10 <= 4 && (mod100 < 12 || mod100 > 14)) return `${count} заявки`;
return `${count} заявок`;
}
async function approveRequest(row, requestId) {
const payload = {
departmentId: Number(row.querySelector('.teacher-request-department')?.value),
username: row.querySelector('.teacher-request-username')?.value.trim(),
fullName: row.querySelector('.teacher-request-fullname')?.value.trim(),
jobTitle: row.querySelector('.teacher-request-jobtitle')?.value.trim(),
password: row.querySelector('.teacher-request-password')?.value
};
if (!payload.departmentId || !payload.username || !payload.fullName || !payload.password) {
showAlert('teacher-requests-alert', 'Заполните кафедру, логин, ФИО и пароль', 'error');
return;
}
if (payload.password.length < 8) {
showAlert('teacher-requests-alert', 'Пароль должен быть не короче 8 символов', 'error');
return;
}
try {
await api.post(`/api/teacher-requests/${encodeURIComponent(requestId)}/approve`, payload);
showAlert('teacher-requests-alert', 'Преподаватель создан, заявка одобрена', 'success');
await loadRequests();
} catch (error) {
showAlert('teacher-requests-alert', error.message || 'Ошибка одобрения заявки', 'error');
}
}
async function rejectRequest(requestId) {
const reviewComment = prompt('Причина отклонения');
if (reviewComment === null) return;
try {
await api.post(`/api/teacher-requests/${encodeURIComponent(requestId)}/reject`, { reviewComment });
showAlert('teacher-requests-alert', 'Заявка отклонена', 'success');
await loadRequests();
} catch (error) {
showAlert('teacher-requests-alert', error.message || 'Ошибка отклонения заявки', 'error');
}
}
refreshButton?.addEventListener('click', loadRequests);
requestsTbody.addEventListener('click', async (event) => {
const approveButton = event.target.closest('.teacher-request-approve');
const rejectButton = event.target.closest('.teacher-request-reject');
if (!approveButton && !rejectButton) return;
const row = event.target.closest('tr[data-request-id]');
const requestId = row?.dataset.requestId;
if (!requestId) return;
if (approveButton) {
await approveRequest(row, requestId);
return;
}
await rejectRequest(requestId);
});
try {
await loadDepartments();
await loadRequests();
} catch (error) {
requestsTbody.innerHTML = `<tr><td colspan="7" class="loading-row">${escapeHtml(error.message || 'Ошибка загрузки')}</td></tr>`;
}
}

View File

@@ -21,9 +21,6 @@ const ROLE_BADGE = {
export async function initUsers() {
const usersTbody = document.getElementById('users-tbody');
const createForm = document.getElementById('create-form');
const teacherRequestsTbody = document.getElementById('teacher-requests-tbody');
const teacherRequestsRefresh = document.getElementById('teacher-requests-refresh');
let departments = [];
async function loadUsers() {
try {
@@ -36,53 +33,6 @@ export async function initUsers() {
}
}
async function loadDepartments() {
departments = await api.get('/api/departments');
}
async function loadTeacherRequests() {
if (!teacherRequestsTbody) return;
teacherRequestsTbody.innerHTML = '<tr><td colspan="7" class="loading-row">Загрузка...</td></tr>';
hideAlert('teacher-requests-alert');
try {
const requests = await api.get('/api/teacher-requests?status=PENDING');
renderTeacherRequests(Array.isArray(requests) ? requests : []);
} catch (error) {
teacherRequestsTbody.innerHTML = `<tr><td colspan="7" class="loading-row">${escapeHtml(error.message || 'Ошибка загрузки заявок')}</td></tr>`;
}
}
function renderTeacherRequests(requests) {
if (!requests.length) {
teacherRequestsTbody.innerHTML = '<tr><td colspan="7" class="loading-row">Нет заявок на рассмотрении</td></tr>';
return;
}
teacherRequestsTbody.innerHTML = requests.map(request => `
<tr data-request-id="${escapeHtml(request.id)}">
<td>
<select class="teacher-request-department" data-native-select="true">
${departments.map(department => `
<option value="${escapeHtml(department.id)}" ${String(department.id) === String(request.departmentId) ? 'selected' : ''}>
${escapeHtml(department.departmentName || department.name || department.id)}
</option>
`).join('')}
</select>
</td>
<td><input type="text" class="teacher-request-username" value="${escapeHtml(request.username || '')}" maxlength="50"></td>
<td><input type="text" class="teacher-request-fullname" value="${escapeHtml(request.fullName || '')}" maxlength="255"></td>
<td><input type="text" class="teacher-request-jobtitle" value="${escapeHtml(request.jobTitle || '')}" maxlength="255"></td>
<td><input type="text" class="teacher-request-password" placeholder="Минимум 8 символов"></td>
<td>${escapeHtml(request.comment || '-')}</td>
<td>
<div class="teacher-request-actions">
<button type="button" class="btn btn-sm btn-primary teacher-request-approve">Одобрить</button>
<button type="button" class="btn btn-sm btn-danger-subtle teacher-request-reject">Отклонить</button>
</div>
</td>
</tr>
`).join('');
}
function renderUsers(users) {
if (!users || !users.length) {
usersTbody.innerHTML = '<tr><td colspan="7" class="loading-row">Нет пользователей</td></tr>';
@@ -143,54 +93,6 @@ export async function initUsers() {
}
});
teacherRequestsRefresh?.addEventListener('click', loadTeacherRequests);
teacherRequestsTbody?.addEventListener('click', async (event) => {
const approveButton = event.target.closest('.teacher-request-approve');
const rejectButton = event.target.closest('.teacher-request-reject');
if (!approveButton && !rejectButton) return;
const row = event.target.closest('tr[data-request-id]');
const requestId = row?.dataset.requestId;
if (!requestId) return;
if (approveButton) {
const payload = {
departmentId: Number(row.querySelector('.teacher-request-department')?.value),
username: row.querySelector('.teacher-request-username')?.value.trim(),
fullName: row.querySelector('.teacher-request-fullname')?.value.trim(),
jobTitle: row.querySelector('.teacher-request-jobtitle')?.value.trim(),
password: row.querySelector('.teacher-request-password')?.value
};
if (!payload.departmentId || !payload.username || !payload.fullName || !payload.password) {
showAlert('teacher-requests-alert', 'Заполните кафедру, логин, ФИО и пароль', 'error');
return;
}
if (payload.password.length < 8) {
showAlert('teacher-requests-alert', 'Пароль должен быть не короче 8 символов', 'error');
return;
}
try {
await api.post(`/api/teacher-requests/${encodeURIComponent(requestId)}/approve`, payload);
showAlert('teacher-requests-alert', 'Преподаватель создан, заявка одобрена', 'success');
await Promise.all([loadTeacherRequests(), loadUsers()]);
} catch (error) {
showAlert('teacher-requests-alert', error.message || 'Ошибка одобрения заявки', 'error');
}
return;
}
const reviewComment = prompt('Причина отклонения');
if (reviewComment === null) return;
try {
await api.post(`/api/teacher-requests/${encodeURIComponent(requestId)}/reject`, { reviewComment });
showAlert('teacher-requests-alert', 'Заявка отклонена', 'success');
await loadTeacherRequests();
} catch (error) {
showAlert('teacher-requests-alert', error.message || 'Ошибка отклонения заявки', 'error');
}
});
usersTbody.addEventListener('click', async (e) => {
const deleteBtn = e.target.closest('.btn-archive-user');
if (!deleteBtn) return;
@@ -216,6 +118,5 @@ export async function initUsers() {
}
});
await loadDepartments();
await Promise.all([loadUsers(), loadTeacherRequests()]);
await loadUsers();
}